diff --git a/adapters/bidder.go b/adapters/bidder.go index c18dde7c120..b659f969f71 100644 --- a/adapters/bidder.go +++ b/adapters/bidder.go @@ -156,6 +156,8 @@ type ExtraRequestInfo struct { PbsEntryPoint metrics.RequestType GlobalPrivacyControlHeader string CurrencyConversions currency.Conversions + + BidderCoreName openrtb_ext.BidderName // OW specific: required for oRTB bidder } func NewExtraRequestInfo(c currency.Conversions) ExtraRequestInfo { diff --git a/adapters/ortbbidder/bidderparams/config.go b/adapters/ortbbidder/bidderparams/config.go new file mode 100644 index 00000000000..1b75da9bf25 --- /dev/null +++ b/adapters/ortbbidder/bidderparams/config.go @@ -0,0 +1,54 @@ +package bidderparams + +// BidderParamMapper contains property details like location +type BidderParamMapper struct { + location []string +} + +// GetLocation returns the location of bidderParam +func (bpm *BidderParamMapper) GetLocation() []string { + return bpm.location +} + +// SetLocation sets the location in BidderParamMapper +// Do not modify the location of bidderParam unless you are writing unit test case +func (bpm *BidderParamMapper) SetLocation(location []string) { + bpm.location = location +} + +// config contains mappings requestParams and responseParams +type config struct { + requestParams map[string]BidderParamMapper + responseParams map[string]BidderParamMapper +} + +// BidderConfig contains map of bidderName to its requestParams and responseParams +type BidderConfig struct { + bidderConfigMap map[string]*config +} + +// setRequestParams sets the bidder specific requestParams +func (bcfg *BidderConfig) setRequestParams(bidderName string, requestParams map[string]BidderParamMapper) { + if bcfg == nil { + return + } + if bcfg.bidderConfigMap == nil { + bcfg.bidderConfigMap = make(map[string]*config) + } + if _, found := bcfg.bidderConfigMap[bidderName]; !found { + bcfg.bidderConfigMap[bidderName] = &config{} + } + bcfg.bidderConfigMap[bidderName].requestParams = requestParams +} + +// GetRequestParams returns bidder specific requestParams +func (bcfg *BidderConfig) GetRequestParams(bidderName string) (map[string]BidderParamMapper, bool) { + if bcfg == nil || len(bcfg.bidderConfigMap) == 0 { + return nil, false + } + bidderConfig, _ := bcfg.bidderConfigMap[bidderName] + if bidderConfig == nil { + return nil, false + } + return bidderConfig.requestParams, true +} diff --git a/adapters/ortbbidder/bidderparams/config_test.go b/adapters/ortbbidder/bidderparams/config_test.go new file mode 100644 index 00000000000..580689e6bf4 --- /dev/null +++ b/adapters/ortbbidder/bidderparams/config_test.go @@ -0,0 +1,321 @@ +package bidderparams + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSetRequestParams(t *testing.T) { + type fields struct { + bidderConfig *BidderConfig + } + type args struct { + bidderName string + requestParams map[string]BidderParamMapper + } + + tests := []struct { + name string + fields fields + args args + want *BidderConfig + }{ + { + name: "bidderConfig_is_nil", + fields: fields{ + bidderConfig: nil, + }, + args: args{ + bidderName: "test", + requestParams: map[string]BidderParamMapper{ + "adunit": { + location: []string{"ext", "adunit"}, + }, + }, + }, + want: nil, + }, + { + name: "bidderConfigMap_is_nil", + fields: fields{ + bidderConfig: &BidderConfig{ + bidderConfigMap: nil, + }, + }, + args: args{ + bidderName: "test", + requestParams: map[string]BidderParamMapper{ + "adunit": { + location: []string{"ext", "adunit"}, + }, + }, + }, + want: &BidderConfig{ + bidderConfigMap: map[string]*config{ + "test": { + requestParams: map[string]BidderParamMapper{ + "adunit": { + location: []string{"ext", "adunit"}, + }, + }, + }, + }, + }, + }, + { + name: "bidderName_not_found", + fields: fields{ + bidderConfig: &BidderConfig{ + bidderConfigMap: map[string]*config{}, + }, + }, + args: args{ + bidderName: "test", + requestParams: map[string]BidderParamMapper{ + "param-1": { + location: []string{"path"}, + }, + }, + }, + want: &BidderConfig{ + bidderConfigMap: map[string]*config{ + "test": { + requestParams: map[string]BidderParamMapper{ + "param-1": { + location: []string{"path"}, + }, + }, + }, + }, + }, + }, + { + name: "bidderName_found", + fields: fields{ + bidderConfig: &BidderConfig{ + bidderConfigMap: map[string]*config{ + "test": { + requestParams: map[string]BidderParamMapper{ + "param-1": { + location: []string{"path-1"}, + }, + }, + }, + }, + }, + }, + args: args{ + bidderName: "test", + requestParams: map[string]BidderParamMapper{ + "param-2": { + location: []string{"path-2"}, + }, + }, + }, + want: &BidderConfig{ + bidderConfigMap: map[string]*config{ + "test": { + requestParams: map[string]BidderParamMapper{ + "param-2": { + location: []string{"path-2"}, + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.fields.bidderConfig.setRequestParams(tt.args.bidderName, tt.args.requestParams) + assert.Equal(t, tt.want, tt.fields.bidderConfig, "mismatched bidderConfig") + }) + } +} + +func TestGetBidderRequestProperties(t *testing.T) { + type fields struct { + biddersConfig *BidderConfig + } + type args struct { + bidderName string + } + type want struct { + requestParams map[string]BidderParamMapper + found bool + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + name: "BidderConfig_is_nil", + fields: fields{ + biddersConfig: nil, + }, + args: args{ + bidderName: "test", + }, + want: want{ + requestParams: nil, + found: false, + }, + }, + { + name: "BidderConfigMap_is_nil", + fields: fields{ + biddersConfig: &BidderConfig{ + bidderConfigMap: nil, + }, + }, + args: args{ + bidderName: "test", + }, + want: want{ + requestParams: nil, + found: false, + }, + }, + { + name: "BidderName_absent_in_biddersConfigMap", + fields: fields{ + biddersConfig: &BidderConfig{ + bidderConfigMap: map[string]*config{ + "ortb": {}, + }, + }, + }, + args: args{ + bidderName: "test", + }, + want: want{ + requestParams: nil, + found: false, + }, + }, + { + name: "BidderName_present_but_config_is_nil", + fields: fields{ + biddersConfig: &BidderConfig{ + bidderConfigMap: map[string]*config{ + "ortb": nil, + }, + }, + }, + args: args{ + bidderName: "test", + }, + want: want{ + requestParams: nil, + found: false, + }, + }, + { + name: "BidderName_present_in_biddersConfigMap", + fields: fields{ + biddersConfig: &BidderConfig{ + bidderConfigMap: map[string]*config{ + "test": { + requestParams: map[string]BidderParamMapper{ + "param-1": { + location: []string{"value-1"}, + }, + }, + }, + }, + }, + }, + args: args{ + bidderName: "test", + }, + want: want{ + requestParams: map[string]BidderParamMapper{ + "param-1": { + location: []string{"value-1"}, + }, + }, + found: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + params, found := tt.fields.biddersConfig.GetRequestParams(tt.args.bidderName) + assert.Equal(t, tt.want.requestParams, params, "mismatched requestParams") + assert.Equal(t, tt.want.found, found, "mismatched found value") + }) + } +} + +func TestBidderParamMapperGetLocation(t *testing.T) { + tests := []struct { + name string + bpm BidderParamMapper + want []string + }{ + { + name: "location_is_nil", + bpm: BidderParamMapper{ + location: nil, + }, + want: nil, + }, + { + name: "location_is_non_empty", + bpm: BidderParamMapper{ + location: []string{"req", "ext"}, + }, + want: []string{"req", "ext"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.bpm.GetLocation() + assert.Equal(t, tt.want, got, "mismatched location") + }) + } +} + +func TestBidderParamMapperSetLocation(t *testing.T) { + type args struct { + location []string + } + tests := []struct { + name string + bpm BidderParamMapper + args args + want BidderParamMapper + }{ + { + name: "set_location", + bpm: BidderParamMapper{}, + args: args{ + location: []string{"req", "ext"}, + }, + want: BidderParamMapper{ + location: []string{"req", "ext"}, + }, + }, + { + name: "override_location", + bpm: BidderParamMapper{ + location: []string{"imp", "ext"}, + }, + args: args{ + location: []string{"req", "ext"}, + }, + want: BidderParamMapper{ + location: []string{"req", "ext"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.bpm.SetLocation(tt.args.location) + assert.Equal(t, tt.want, tt.bpm, "mismatched location") + }) + } +} diff --git a/adapters/ortbbidder/bidderparams/parser.go b/adapters/ortbbidder/bidderparams/parser.go new file mode 100644 index 00000000000..8d198464fcb --- /dev/null +++ b/adapters/ortbbidder/bidderparams/parser.go @@ -0,0 +1,85 @@ +package bidderparams + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + propertiesKey = "properties" + locationKey = "location" +) + +// LoadBidderConfig creates a bidderConfig from JSON files specified in dirPath directory. +func LoadBidderConfig(dirPath string, isBidderAllowed func(string) bool) (*BidderConfig, error) { + files, err := os.ReadDir(dirPath) + if err != nil { + return nil, fmt.Errorf("error:[%s] dirPath:[%s]", err.Error(), dirPath) + } + bidderConfigMap := &BidderConfig{bidderConfigMap: make(map[string]*config)} + for _, file := range files { + bidderName, ok := strings.CutSuffix(file.Name(), ".json") + if !ok { + return nil, fmt.Errorf("error:[invalid_json_file_name] filename:[%s]", file.Name()) + } + if !isBidderAllowed(bidderName) { + continue + } + requestParamsConfig, err := readFile(dirPath, file.Name()) + if err != nil { + return nil, fmt.Errorf("error:[fail_to_read_file] dir:[%s] filename:[%s] err:[%s]", dirPath, file.Name(), err.Error()) + } + requestParams, err := prepareRequestParams(bidderName, requestParamsConfig) + if err != nil { + return nil, err + } + bidderConfigMap.setRequestParams(bidderName, requestParams) + } + return bidderConfigMap, nil +} + +// readFile reads the file from directory and unmarshals it into the map[string]any +func readFile(dirPath, file string) (map[string]any, error) { + filePath := filepath.Join(dirPath, file) + content, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + var contentMap map[string]any + err = json.Unmarshal(content, &contentMap) + return contentMap, err +} + +// prepareRequestParams parse the requestParamsConfig and returns the requestParams +func prepareRequestParams(bidderName string, requestParamsConfig map[string]any) (map[string]BidderParamMapper, error) { + params, found := requestParamsConfig[propertiesKey] + if !found { + return nil, nil + } + paramsMap, ok := params.(map[string]any) + if !ok { + return nil, fmt.Errorf("error:[invalid_json_file_content_malformed_properties] bidderName:[%s]", bidderName) + } + requestParams := make(map[string]BidderParamMapper, len(paramsMap)) + for paramName, paramValue := range paramsMap { + paramValueMap, ok := paramValue.(map[string]any) + if !ok { + return nil, fmt.Errorf("error:[invalid_json_file_content] bidder:[%s] bidderParam:[%s]", bidderName, paramName) + } + location, found := paramValueMap[locationKey] + if !found { + continue + } + locationStr, ok := location.(string) + if !ok { + return nil, fmt.Errorf("error:[incorrect_location_in_bidderparam] bidder:[%s] bidderParam:[%s]", bidderName, paramName) + } + requestParams[paramName] = BidderParamMapper{ + location: strings.Split(locationStr, "."), + } + } + return requestParams, nil +} diff --git a/adapters/ortbbidder/bidderparams/parser_test.go b/adapters/ortbbidder/bidderparams/parser_test.go new file mode 100644 index 00000000000..54ae74deea0 --- /dev/null +++ b/adapters/ortbbidder/bidderparams/parser_test.go @@ -0,0 +1,365 @@ +package bidderparams + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPrepareRequestParams(t *testing.T) { + type args struct { + requestParamCfg map[string]any + bidderName string + } + type want struct { + requestParams map[string]BidderParamMapper + err error + } + tests := []struct { + name string + args args + want want + }{ + { + name: "properties_missing_from_fileContents", + args: args{ + requestParamCfg: map[string]any{ + "title": "test bidder parameters", + }, + bidderName: "testbidder", + }, + want: want{ + requestParams: nil, + err: nil, + }, + }, + { + name: "properties_data_type_invalid", + args: args{ + requestParamCfg: map[string]any{ + "title": "test bidder parameters", + "properties": "type invalid", + }, + bidderName: "testbidder", + }, + want: want{ + requestParams: nil, + err: fmt.Errorf("error:[invalid_json_file_content_malformed_properties] bidderName:[testbidder]"), + }, + }, + { + name: "bidder-params_data_type_invalid", + args: args{ + requestParamCfg: map[string]any{ + "title": "test bidder parameters", + "properties": map[string]any{ + "adunitid": "invalid-type", + }, + }, + bidderName: "testbidder", + }, + want: want{ + requestParams: nil, + err: fmt.Errorf("error:[invalid_json_file_content] bidder:[testbidder] bidderParam:[adunitid]"), + }, + }, + { + name: "bidder-params_properties_is_not_provided", + args: args{ + requestParamCfg: map[string]any{ + "title": "test bidder parameters", + "properties": map[string]any{ + "adunitid": map[string]any{ + "type": "string", + }, + }, + }, + bidderName: "testbidder", + }, + want: want{ + requestParams: map[string]BidderParamMapper{}, + err: nil, + }, + }, + { + name: "bidder-params_location_is_not_in_string", + args: args{ + requestParamCfg: map[string]any{ + "title": "test bidder parameters", + "properties": map[string]any{ + "adunitid": map[string]any{ + "type": "string", + "location": 100, + }, + }, + }, + bidderName: "testbidder", + }, + want: want{ + requestParams: nil, + err: fmt.Errorf("error:[incorrect_location_in_bidderparam] bidder:[testbidder] bidderParam:[adunitid]"), + }, + }, + { + name: "set_bidder-params_location_in_mapper", + args: args{ + requestParamCfg: map[string]any{ + "title": "test bidder parameters", + "properties": map[string]any{ + "adunitid": map[string]any{ + "type": "string", + "location": "app.adunitid", + }, + }, + }, + bidderName: "testbidder", + }, + want: want{ + requestParams: map[string]BidderParamMapper{ + "adunitid": {location: []string{"app", "adunitid"}}, + }, + err: nil, + }, + }, + { + name: "set_multiple_bidder-params_and_locations_in_mapper", + args: args{ + requestParamCfg: map[string]any{ + "title": "test bidder parameters", + "properties": map[string]any{ + "adunitid": map[string]any{ + "type": "string", + "location": "app.adunitid", + }, + "slotname": map[string]any{ + "type": "string", + "location": "ext.slot", + }, + }, + }, + bidderName: "testbidder", + }, + want: want{ + requestParams: map[string]BidderParamMapper{ + "adunitid": {location: []string{"app", "adunitid"}}, + "slotname": {location: []string{"ext", "slot"}}, + }, + err: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + requestParams, err := prepareRequestParams(tt.args.bidderName, tt.args.requestParamCfg) + assert.Equalf(t, tt.want.err, err, "updateBidderParamsMapper returned unexpected error") + assert.Equalf(t, tt.want.requestParams, requestParams, "updateBidderParamsMapper returned unexpected mapper") + }) + } +} + +func TestLoadBidderConfig(t *testing.T) { + type want struct { + biddersConfigMap *BidderConfig + err string + } + tests := []struct { + name string + want want + setup func() (string, error) + }{ + { + name: "read_directory_fail", + want: want{ + biddersConfigMap: nil, + err: "error:[open invalid-path: no such file or directory] dirPath:[invalid-path]", + }, + setup: func() (string, error) { return "invalid-path", nil }, + }, + { + name: "found_file_without_.json_extension", + want: want{ + biddersConfigMap: nil, + err: "error:[invalid_json_file_name] filename:[example.txt]", + }, + setup: func() (string, error) { + dirPath := t.TempDir() + err := os.WriteFile(dirPath+"/example.txt", []byte("anything"), 0644) + return dirPath, err + }, + }, + { + name: "oRTB_bidder_not_found", + want: want{ + biddersConfigMap: &BidderConfig{bidderConfigMap: make(map[string]*config)}, + err: "", + }, + setup: func() (string, error) { + dirPath := t.TempDir() + err := os.WriteFile(dirPath+"/example.json", []byte("anything"), 0644) + return dirPath, err + }, + }, + { + name: "oRTB_bidder_found_but_invalid_json_present", + want: want{ + biddersConfigMap: nil, + err: "error:[fail_to_read_file]", + }, + setup: func() (string, error) { + dirPath := t.TempDir() + err := os.WriteFile(dirPath+"/owortb_test.json", []byte("anything"), 0644) + return dirPath, err + }, + }, + { + name: "oRTB_bidder_found_but_bidder-params_are_absent", + want: want{ + biddersConfigMap: &BidderConfig{bidderConfigMap: map[string]*config{ + "owortb_test": { + requestParams: nil, + }, + }}, + err: "", + }, + setup: func() (string, error) { + dirPath := t.TempDir() + err := os.WriteFile(dirPath+"/owortb_test.json", []byte("{}"), 0644) + return dirPath, err + }, + }, + { + name: "oRTB_bidder_found_but_prepareBidderRequestProperties_returns_error", + want: want{ + biddersConfigMap: nil, + err: "error:[invalid_json_file_content_malformed_properties] bidderName:[owortb_test]", + }, + setup: func() (string, error) { + dirPath := t.TempDir() + err := os.WriteFile(dirPath+"/owortb_test.json", []byte(`{"properties":"invalid-properties"}`), 0644) + return dirPath, err + }, + }, + { + name: "oRTB_bidder_found_and_valid_json_contents_present", + want: want{ + biddersConfigMap: &BidderConfig{ + bidderConfigMap: map[string]*config{ + "owortb_test": { + requestParams: map[string]BidderParamMapper{ + "adunitid": {location: []string{"app", "adunit", "id"}}, + "slotname": {location: []string{"ext", "slotname"}}, + }, + }, + }}, + err: "", + }, + setup: func() (string, error) { + dirPath := t.TempDir() + err := os.WriteFile(dirPath+"/owortb_test.json", []byte(` + { + "title":"ortb bidder", + "properties": { + "adunitid": { + "type": "string", + "location": "app.adunit.id" + }, + "slotname": { + "type": "string", + "location": "ext.slotname" + } + } + } + `), 0644) + return dirPath, err + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dirPath, err := tt.setup() + assert.NoError(t, err, "setup returned unexpected error") + got, err := LoadBidderConfig(dirPath, func(bidderName string) bool { + if strings.HasPrefix(bidderName, "owortb_") { + return true + } + return false + }) + assert.Equal(t, tt.want.biddersConfigMap, got, "found incorrect mapper") + assert.Equal(t, len(tt.want.err) == 0, err == nil, "mismatched error") + if err != nil { + assert.ErrorContains(t, err, tt.want.err, "found incorrect error message") + } + }) + } +} + +func TestReadFile(t *testing.T) { + var setup = func() (string, error) { + dir := t.TempDir() + err := os.WriteFile(dir+"/owortb.json", []byte(` + { + "title":"ortb bidder", + "properties": { + "adunitid": { + "type": "string", + "location": "req.app.adunit.id" + } + } + } + `), 0644) + return dir, err + } + type args struct { + file string + } + type want struct { + err bool + node map[string]any + } + tests := []struct { + name string + args args + want want + }{ + { + name: "successful_readfile", + args: args{ + file: "owortb.json", + }, + want: want{ + err: false, + node: map[string]any{ + "title": "ortb bidder", + "properties": map[string]any{ + "adunitid": map[string]any{ + "type": "string", + "location": "req.app.adunit.id", + }, + }, + }, + }, + }, + { + name: "fail_readfile", + args: args{ + file: "invalid.json", + }, + want: want{ + err: true, + node: nil, + }, + }, + } + path, err := setup() + assert.Nil(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := readFile(path, tt.args.file) + assert.Equal(t, tt.want.err, err != nil, "mismatched error") + assert.Equal(t, tt.want.node, got, "mismatched map[string]any") + }) + } +} diff --git a/adapters/ortbbidder/ortbbidder.go b/adapters/ortbbidder/ortbbidder.go new file mode 100644 index 00000000000..4cc3f349f8a --- /dev/null +++ b/adapters/ortbbidder/ortbbidder.go @@ -0,0 +1,188 @@ +package ortbbidder + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v2/adapters" + "github.com/prebid/prebid-server/v2/adapters/ortbbidder/bidderparams" + "github.com/prebid/prebid-server/v2/config" + "github.com/prebid/prebid-server/v2/openrtb_ext" +) + +// adapter implements adapters.Bidder interface +type adapter struct { + adapterInfo + bidderParamsConfig *bidderparams.BidderConfig +} + +const ( + RequestModeSingle string = "single" +) + +// adapterInfo contains oRTB bidder specific info required in MakeRequests/MakeBids functions +type adapterInfo struct { + config.Adapter + extraInfo extraAdapterInfo + bidderName openrtb_ext.BidderName +} +type extraAdapterInfo struct { + RequestMode string `json:"requestMode"` +} + +// global instance to hold bidderParamsConfig +var g_bidderParamsConfig *bidderparams.BidderConfig + +// InitBidderParamsConfig initializes a g_bidderParamsConfig instance from the files provided in dirPath. +func InitBidderParamsConfig(dirPath string) (err error) { + g_bidderParamsConfig, err = bidderparams.LoadBidderConfig(dirPath, isORTBBidder) + return err +} + +// makeRequest converts openrtb2.BidRequest to adapters.RequestData, sets requestParams in request if required +func (o adapterInfo) makeRequest(request *openrtb2.BidRequest, requestParams map[string]bidderparams.BidderParamMapper) (*adapters.RequestData, error) { + if request == nil { + return nil, fmt.Errorf("found nil request") + } + requestBody, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request %s", err.Error()) + } + requestBody, err = setRequestParams(requestBody, requestParams) + if err != nil { + return nil, err + } + return &adapters.RequestData{ + Method: http.MethodPost, + Uri: o.Endpoint, + Body: requestBody, + Headers: http.Header{ + "Content-Type": {"application/json;charset=utf-8"}, + "Accept": {"application/json"}, + }, + }, nil +} + +// Builder returns an instance of oRTB adapter +func Builder(bidderName openrtb_ext.BidderName, config config.Adapter, server config.Server) (adapters.Bidder, error) { + extraAdapterInfo := extraAdapterInfo{} + if len(config.ExtraAdapterInfo) > 0 { + err := json.Unmarshal([]byte(config.ExtraAdapterInfo), &extraAdapterInfo) + if err != nil { + return nil, fmt.Errorf("Failed to parse extra_info for bidder:[%s] err:[%s]", bidderName, err.Error()) + } + } + return &adapter{ + adapterInfo: adapterInfo{config, extraAdapterInfo, bidderName}, + bidderParamsConfig: g_bidderParamsConfig, + }, nil +} + +// MakeRequests prepares oRTB bidder-specific request information using which prebid server make call(s) to bidder. +func (o *adapter) MakeRequests(request *openrtb2.BidRequest, requestInfo *adapters.ExtraRequestInfo) ([]*adapters.RequestData, []error) { + if request == nil || requestInfo == nil { + return nil, []error{fmt.Errorf("Found either nil request or nil requestInfo")} + } + if o.bidderParamsConfig == nil { + return nil, []error{fmt.Errorf("Found nil bidderParamsConfig")} + } + var errs []error + adapterInfo := o.adapterInfo + requestParams, _ := o.bidderParamsConfig.GetRequestParams(o.bidderName.String()) + + // bidder request supports single impression in single HTTP call. + if adapterInfo.extraInfo.RequestMode == RequestModeSingle { + requestData := make([]*adapters.RequestData, 0, len(request.Imp)) + requestCopy := *request + for _, imp := range request.Imp { + requestCopy.Imp = []openrtb2.Imp{imp} // requestCopy contains single impression + reqData, err := adapterInfo.makeRequest(&requestCopy, requestParams) + if err != nil { + errs = append(errs, err) + continue + } + requestData = append(requestData, reqData) + } + return requestData, errs + } + // bidder request supports multi impressions in single HTTP call. + requestData, err := adapterInfo.makeRequest(request, requestParams) + if err != nil { + return nil, []error{err} + } + return []*adapters.RequestData{requestData}, nil +} + +// MakeBids prepares bidderResponse from the oRTB bidder server's http.Response +func (o *adapter) MakeBids(request *openrtb2.BidRequest, requestData *adapters.RequestData, responseData *adapters.ResponseData) (*adapters.BidderResponse, []error) { + if responseData == nil || adapters.IsResponseStatusCodeNoContent(responseData) { + return nil, nil + } + + if err := adapters.CheckResponseStatusCodeForErrors(responseData); err != nil { + return nil, []error{err} + } + + var response openrtb2.BidResponse + if err := json.Unmarshal(responseData.Body, &response); err != nil { + return nil, []error{err} + } + + bidResponse := adapters.BidderResponse{ + Bids: make([]*adapters.TypedBid, 0), + } + for _, seatBid := range response.SeatBid { + for bidInd, bid := range seatBid.Bid { + bidResponse.Bids = append(bidResponse.Bids, &adapters.TypedBid{ + Bid: &seatBid.Bid[bidInd], + BidType: getMediaTypeForBid(bid), + }) + } + } + return &bidResponse, nil +} + +// getMediaTypeForBid returns the BidType as per the bid.MType field +// bid.MType has high priority over bidExt.Prebid.Type +func getMediaTypeForBid(bid openrtb2.Bid) openrtb_ext.BidType { + var bidType openrtb_ext.BidType + if bid.MType > 0 { + bidType = getMediaTypeForBidFromMType(bid.MType) + } else { + if bid.Ext != nil { + var bidExt openrtb_ext.ExtBid + err := json.Unmarshal(bid.Ext, &bidExt) + if err == nil && bidExt.Prebid != nil { + bidType, _ = openrtb_ext.ParseBidType(string(bidExt.Prebid.Type)) + } + } + } + if bidType == "" { + // TODO : detect mediatype from bid.AdM and request.imp parameter + } + return bidType +} + +// getMediaTypeForBidFromMType returns the bidType from the MarkupType field +func getMediaTypeForBidFromMType(mtype openrtb2.MarkupType) openrtb_ext.BidType { + var bidType openrtb_ext.BidType + switch mtype { + case openrtb2.MarkupBanner: + bidType = openrtb_ext.BidTypeBanner + case openrtb2.MarkupVideo: + bidType = openrtb_ext.BidTypeVideo + case openrtb2.MarkupAudio: + bidType = openrtb_ext.BidTypeAudio + case openrtb2.MarkupNative: + bidType = openrtb_ext.BidTypeNative + } + return bidType +} + +// isORTBBidder returns true if the bidder is an oRTB bidder +func isORTBBidder(bidderName string) bool { + return strings.HasPrefix(bidderName, "owortb_") +} diff --git a/adapters/ortbbidder/ortbbidder_test.go b/adapters/ortbbidder/ortbbidder_test.go new file mode 100644 index 00000000000..b951efc4086 --- /dev/null +++ b/adapters/ortbbidder/ortbbidder_test.go @@ -0,0 +1,578 @@ +package ortbbidder + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v2/adapters" + "github.com/prebid/prebid-server/v2/adapters/adapterstest" + "github.com/prebid/prebid-server/v2/adapters/ortbbidder/bidderparams" + "github.com/prebid/prebid-server/v2/config" + "github.com/prebid/prebid-server/v2/errortypes" + "github.com/prebid/prebid-server/v2/openrtb_ext" + "github.com/stretchr/testify/assert" +) + +func TestMakeRequests(t *testing.T) { + type args struct { + request *openrtb2.BidRequest + requestInfo *adapters.ExtraRequestInfo + adapterInfo adapterInfo + bidderCfgMap *bidderparams.BidderConfig + } + type want struct { + requestData []*adapters.RequestData + errors []error + } + tests := []struct { + name string + args args + want want + }{ + { + name: "request_is_nil", + args: args{ + bidderCfgMap: &bidderparams.BidderConfig{}, + }, + want: want{ + errors: []error{fmt.Errorf("Found either nil request or nil requestInfo")}, + }, + }, + { + name: "requestInfo_is_nil", + args: args{ + bidderCfgMap: &bidderparams.BidderConfig{}, + }, + want: want{ + errors: []error{fmt.Errorf("Found either nil request or nil requestInfo")}, + }, + }, + { + name: "multi_requestmode_to_form_requestdata", + args: args{ + request: &openrtb2.BidRequest{ + ID: "reqid", + Imp: []openrtb2.Imp{ + {ID: "imp1", TagID: "tag1"}, + {ID: "imp2", TagID: "tag2"}, + }, + }, + requestInfo: &adapters.ExtraRequestInfo{ + BidderCoreName: openrtb_ext.BidderName("ortb_test_multi_requestmode"), + }, + adapterInfo: adapterInfo{config.Adapter{Endpoint: "http://test_bidder.com"}, extraAdapterInfo{RequestMode: ""}, "testbidder"}, + bidderCfgMap: &bidderparams.BidderConfig{}, + }, + want: want{ + requestData: []*adapters.RequestData{ + { + Method: http.MethodPost, + Uri: "http://test_bidder.com", + Body: []byte(`{"id":"reqid","imp":[{"id":"imp1","tagid":"tag1"},{"id":"imp2","tagid":"tag2"}]}`), + Headers: http.Header{ + "Content-Type": {"application/json;charset=utf-8"}, + "Accept": {"application/json"}, + }, + }, + }, + }, + }, + { + name: "single_requestmode_to_form_requestdata", + args: args{ + request: &openrtb2.BidRequest{ + ID: "reqid", + Imp: []openrtb2.Imp{ + {ID: "imp1", TagID: "tag1"}, + {ID: "imp2", TagID: "tag2"}, + }, + }, + requestInfo: &adapters.ExtraRequestInfo{ + BidderCoreName: openrtb_ext.BidderName("ortb_test_single_requestmode"), + }, + adapterInfo: adapterInfo{config.Adapter{Endpoint: "http://test_bidder.com"}, extraAdapterInfo{RequestMode: "single"}, "testbidder"}, + bidderCfgMap: &bidderparams.BidderConfig{}, + }, + want: want{ + requestData: []*adapters.RequestData{ + { + Method: http.MethodPost, + Uri: "http://test_bidder.com", + Body: []byte(`{"id":"reqid","imp":[{"id":"imp1","tagid":"tag1"}]}`), + Headers: http.Header{ + "Content-Type": {"application/json;charset=utf-8"}, + "Accept": {"application/json"}, + }, + }, + { + Method: http.MethodPost, + Uri: "http://test_bidder.com", + Body: []byte(`{"id":"reqid","imp":[{"id":"imp2","tagid":"tag2"}]}`), + Headers: http.Header{ + "Content-Type": {"application/json;charset=utf-8"}, + "Accept": {"application/json"}, + }, + }, + }, + }, + }, + { + name: "biddersConfigMap_is_nil", + args: args{ + request: &openrtb2.BidRequest{ + ID: "reqid", + Imp: []openrtb2.Imp{{ID: "imp1", TagID: "tag1"}}, + }, + requestInfo: &adapters.ExtraRequestInfo{ + BidderCoreName: openrtb_ext.BidderName("ortb_test_single_requestmode"), + }, + adapterInfo: adapterInfo{config.Adapter{Endpoint: "http://test_bidder.com"}, extraAdapterInfo{RequestMode: "single"}, "testbidder"}, + bidderCfgMap: nil, + }, + want: want{ + errors: []error{fmt.Errorf("Found nil bidderParamsConfig")}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + adapter := &adapter{adapterInfo: tt.args.adapterInfo, bidderParamsConfig: tt.args.bidderCfgMap} + requestData, errors := adapter.MakeRequests(tt.args.request, tt.args.requestInfo) + assert.Equalf(t, tt.want.requestData, requestData, "mismatched requestData") + assert.Equalf(t, tt.want.errors, errors, "mismatched errors") + }) + } +} + +func TestMakeBids(t *testing.T) { + type args struct { + request *openrtb2.BidRequest + requestData *adapters.RequestData + responseData *adapters.ResponseData + } + type want struct { + response *adapters.BidderResponse + errors []error + } + tests := []struct { + name string + args args + want want + }{ + { + name: "responseData_is_nil", + args: args{ + responseData: nil, + }, + want: want{ + response: nil, + errors: nil, + }, + }, + { + name: "StatusNoContent_in_responseData", + args: args{ + responseData: &adapters.ResponseData{StatusCode: http.StatusNoContent}, + }, + want: want{ + response: nil, + errors: nil, + }, + }, + { + name: "StatusBadRequest_in_responseData", + args: args{ + responseData: &adapters.ResponseData{StatusCode: http.StatusBadRequest}, + }, + want: want{ + response: nil, + errors: []error{&errortypes.BadInput{ + Message: fmt.Sprintf("Unexpected status code: %d. Run with request.debug = 1 for more info", http.StatusBadRequest), + }}, + }, + }, + { + name: "valid_response", + args: args{ + responseData: &adapters.ResponseData{ + StatusCode: http.StatusOK, + Body: []byte(`{"id":"bid-resp-id","seatbid":[{"seat":"test_bidder","bid":[{"id":"bid-1","mtype":2}]}]}`), + }, + }, + want: want{ + response: &adapters.BidderResponse{ + Bids: []*adapters.TypedBid{ + { + Bid: &openrtb2.Bid{ + ID: "bid-1", + MType: 2, + }, + BidType: "video", + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + adapter := &adapter{} + response, errs := adapter.MakeBids(tt.args.request, tt.args.requestData, tt.args.responseData) + assert.Equalf(t, tt.want.response, response, "mismatched response") + assert.Equalf(t, tt.want.errors, errs, "mismatched errors") + }) + } +} + +func TestGetMediaTypeForBid(t *testing.T) { + type args struct { + bid openrtb2.Bid + } + type want struct { + bidType openrtb_ext.BidType + } + tests := []struct { + name string + args args + want want + }{ + { + name: "valid_banner_bid", + args: args{ + bid: openrtb2.Bid{ID: "1", MType: openrtb2.MarkupBanner}, + }, + want: want{ + bidType: openrtb_ext.BidTypeBanner, + }, + }, + { + name: "valid_video_bid", + args: args{ + bid: openrtb2.Bid{ID: "2", MType: openrtb2.MarkupVideo}, + }, + want: want{ + bidType: openrtb_ext.BidTypeVideo, + }, + }, + { + name: "valid_audio_bid", + args: args{ + bid: openrtb2.Bid{ID: "3", MType: openrtb2.MarkupAudio}, + }, + want: want{ + bidType: openrtb_ext.BidTypeAudio, + }, + }, + { + name: "valid_native_bid", + args: args{ + bid: openrtb2.Bid{ID: "4", MType: openrtb2.MarkupNative}, + }, + want: want{ + bidType: openrtb_ext.BidTypeNative, + }, + }, + { + name: "invalid_bid_type", + args: args{ + bid: openrtb2.Bid{ID: "5", MType: 123}, + }, + want: want{ + bidType: "", + }, + }, + { + name: "bid.MType_has_high_priority", + args: args{ + bid: openrtb2.Bid{ID: "5", MType: openrtb2.MarkupVideo, Ext: json.RawMessage(`{"prebid":{"type":"video"}}`)}, + }, + want: want{ + bidType: "video", + }, + }, + { + name: "bid.ext.prebid.type_is_absent", + args: args{ + bid: openrtb2.Bid{ID: "5", Ext: json.RawMessage(`{"prebid":{}}`)}, + }, + want: want{ + bidType: "", + }, + }, + { + name: "bid.ext.prebid.type_json_unmarshal_fails", + args: args{ + bid: openrtb2.Bid{ID: "5", Ext: json.RawMessage(`{"prebid":{invalid-json}}`)}, + }, + want: want{ + bidType: "", + }, + }, + { + name: "bid.ext.prebid.type_is_valid", + args: args{ + bid: openrtb2.Bid{ID: "5", Ext: json.RawMessage(`{"prebid":{"type":"banner"}}`)}, + }, + want: want{ + bidType: "banner", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bidType := getMediaTypeForBid(tt.args.bid) + assert.Equal(t, tt.want.bidType, bidType, "mismatched bidType") + }) + } +} + +func TestJsonSamplesForSingleRequestMode(t *testing.T) { + oldMapper := g_bidderParamsConfig + defer func() { + g_bidderParamsConfig = oldMapper + }() + g_bidderParamsConfig = &bidderparams.BidderConfig{} + bidder, buildErr := Builder("owgeneric_single_requestmode", + config.Adapter{ + Endpoint: "http://test_bidder.com", + ExtraAdapterInfo: `{"requestMode":"single"}`, + }, config.Server{}) + if buildErr != nil { + t.Fatalf("Builder returned unexpected error %v", buildErr) + } + adapterstest.RunJSONBidderTest(t, "ortbbiddertest/owortb_generic_single_requestmode", bidder) +} + +func TestJsonSamplesForMultiRequestMode(t *testing.T) { + oldMapper := g_bidderParamsConfig + defer func() { + g_bidderParamsConfig = oldMapper + }() + g_bidderParamsConfig = &bidderparams.BidderConfig{} + bidder, buildErr := Builder("owgeneric_multi_requestmode", + config.Adapter{ + Endpoint: "http://test_bidder.com", + ExtraAdapterInfo: ``, + }, config.Server{}) + if buildErr != nil { + t.Fatalf("Builder returned unexpected error %v", buildErr) + } + adapterstest.RunJSONBidderTest(t, "ortbbiddertest/owortb_generic_multi_requestmode", bidder) +} + +func Test_makeRequest(t *testing.T) { + type fields struct { + Adapter config.Adapter + } + type args struct { + request *openrtb2.BidRequest + requestParams map[string]bidderparams.BidderParamMapper + } + type want struct { + requestData *adapters.RequestData + err error + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + name: "valid_request", + fields: fields{ + Adapter: config.Adapter{Endpoint: "https://example.com"}, + }, + args: args{ + request: &openrtb2.BidRequest{ + ID: "123", + Imp: []openrtb2.Imp{{ID: "imp1"}}, + }, + requestParams: make(map[string]bidderparams.BidderParamMapper), + }, + want: want{ + requestData: &adapters.RequestData{ + Method: http.MethodPost, + Uri: "https://example.com", + Body: []byte(`{"id":"123","imp":[{"id":"imp1"}]}`), + Headers: http.Header{ + "Content-Type": {"application/json;charset=utf-8"}, + "Accept": {"application/json"}, + }, + }, + err: nil, + }, + }, + { + name: "nil_request", + fields: fields{ + Adapter: config.Adapter{Endpoint: "https://example.com"}, + }, + args: args{ + request: nil, + requestParams: make(map[string]bidderparams.BidderParamMapper), + }, + want: want{ + requestData: nil, + err: fmt.Errorf("found nil request"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := adapterInfo{ + Adapter: tt.fields.Adapter, + } + got, err := o.makeRequest(tt.args.request, tt.args.requestParams) + assert.Equal(t, tt.want.requestData, got, "mismatched requestData") + assert.Equal(t, tt.want.err, err, "mismatched error") + }) + } +} + +func TestBuilder(t *testing.T) { + InitBidderParamsConfig("../../static/bidder-params") + type args struct { + bidderName openrtb_ext.BidderName + config config.Adapter + server config.Server + } + type want struct { + err error + bidder adapters.Bidder + } + tests := []struct { + name string + args args + want want + }{ + { + name: "fails_to_parse_extra_info", + args: args{ + bidderName: "ortbbidder", + config: config.Adapter{ + ExtraAdapterInfo: "invalid-string", + }, + server: config.Server{}, + }, + want: want{ + bidder: nil, + err: fmt.Errorf("Failed to parse extra_info for bidder:[ortbbidder] err:[invalid character 'i' looking for beginning of value]"), + }, + }, + { + name: "bidder_with_requestMode", + args: args{ + bidderName: "ortbbidder", + config: config.Adapter{ + ExtraAdapterInfo: `{"requestMode":"single"}`, + }, + server: config.Server{}, + }, + want: want{ + bidder: &adapter{ + adapterInfo: adapterInfo{ + extraInfo: extraAdapterInfo{ + RequestMode: "single", + }, + Adapter: config.Adapter{ + ExtraAdapterInfo: `{"requestMode":"single"}`, + }, + bidderName: "ortbbidder", + }, + bidderParamsConfig: g_bidderParamsConfig, + }, + err: nil, + }, + }, + { + name: "bidder_without_requestMode", + args: args{ + bidderName: "ortbbidder", + config: config.Adapter{ + ExtraAdapterInfo: "", + }, + server: config.Server{}, + }, + want: want{ + bidder: &adapter{ + adapterInfo: adapterInfo{ + Adapter: config.Adapter{ + ExtraAdapterInfo: ``, + }, + bidderName: "ortbbidder", + }, + bidderParamsConfig: g_bidderParamsConfig, + }, + err: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Builder(tt.args.bidderName, tt.args.config, tt.args.server) + assert.Equal(t, tt.want.bidder, got, "mismatched bidder") + assert.Equal(t, tt.want.err, err, "mismatched error") + }) + } +} + +func TestInitBidderParamsConfig(t *testing.T) { + tests := []struct { + name string + dirPath string + wantErr bool + }{ + { + name: "test_InitBiddersConfigMap_success", + dirPath: "../../static/bidder-params/", + wantErr: false, + }, + { + name: "test_InitBiddersConfigMap_failure", + dirPath: "/invalid_directory/", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := InitBidderParamsConfig(tt.dirPath) + assert.Equal(t, err != nil, tt.wantErr, "mismatched error") + }) + } +} + +func TestIsORTBBidder(t *testing.T) { + type args struct { + bidderName string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "ortb_bidder", + args: args{ + bidderName: "owortb_magnite", + }, + want: true, + }, + { + name: "non_ortb_bidder", + args: args{ + bidderName: "magnite", + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isORTBBidder(tt.args.bidderName) + assert.Equal(t, tt.want, got, "mismatched output of isORTBBidder") + }) + } +} diff --git a/adapters/ortbbidder/ortbbiddertest/owortb_generic_multi_requestmode/exemplary/multi_imps_req.json b/adapters/ortbbidder/ortbbiddertest/owortb_generic_multi_requestmode/exemplary/multi_imps_req.json new file mode 100644 index 00000000000..5bb92ef28c4 --- /dev/null +++ b/adapters/ortbbidder/ortbbiddertest/owortb_generic_multi_requestmode/exemplary/multi_imps_req.json @@ -0,0 +1,139 @@ +{ + "mockBidRequest": { + "id": "test-request-id", + "imp": [ + { + "id": "test-imp-id-1", + "video": { + "mimes": ["video/mp4"] + }, + "tagid": "tagid_1", + "ext": { + "prebid": { + "bidder": { + "magnite": { + "bidder_param_1": "value_1" + } + } + } + } + }, + { + "id": "test-imp-id-2", + "video": { + "mimes": ["video/mp4"] + }, + "tagid": "tagid_2", + "ext": { + "prebid": { + "bidder": { + "magnite": { + "bidder_param_2": "value_2" + } + } + } + } + } + ] + }, + "httpCalls": [ + { + "expectedRequest": { + "uri": "http://test_bidder.com", + "body": { + "id": "test-request-id", + "imp": [ + { + "id": "test-imp-id-1", + "video": { + "mimes": ["video/mp4"] + }, + "tagid": "tagid_1", + "ext": { + "prebid": { + "bidder": { + "magnite": { + "bidder_param_1": "value_1" + } + } + } + } + }, + { + "id": "test-imp-id-2", + "video": { + "mimes": ["video/mp4"] + }, + "tagid": "tagid_2", + "ext": { + "prebid": { + "bidder": { + "magnite": { + "bidder_param_2": "value_2" + } + } + } + } + } + ] + } + }, + "mockResponse": { + "status": 200, + "body": { + "id": "test-request-id", + "seatbid": [ + { + "seat": "magnite", + "bid": [{ + "id": "bid-1", + "impid": "test-imp-id-1", + "price": 0.500000, + "adm": "some-test-ad", + "crid": "crid_10", + "mtype": 2 + },{ + "id": "bid-2", + "impid": "test-imp-id-2", + "price": 10, + "adm": "some-test-ad-2", + "crid": "crid_10", + "mtype": 2 + }] + } + ], + "cur": "USD" + } + } + } + ], + + "expectedBidResponses": [ + { + "bids": [ + { + "bid": { + "id": "bid-1", + "impid": "test-imp-id-1", + "price": 0.5, + "adm": "some-test-ad", + "crid": "crid_10", + "mtype":2 + }, + "type": "video" + }, + { + "bid": { + "id": "bid-2", + "impid": "test-imp-id-2", + "price": 10, + "adm": "some-test-ad-2", + "crid": "crid_10", + "mtype":2 + }, + "type": "video" + } + ] + } + ] +} diff --git a/adapters/ortbbidder/ortbbiddertest/owortb_generic_single_requestmode/exemplary/multi_imps_req.json b/adapters/ortbbidder/ortbbiddertest/owortb_generic_single_requestmode/exemplary/multi_imps_req.json new file mode 100644 index 00000000000..efb1cb93bf1 --- /dev/null +++ b/adapters/ortbbidder/ortbbiddertest/owortb_generic_single_requestmode/exemplary/multi_imps_req.json @@ -0,0 +1,166 @@ +{ + "mockBidRequest": { + "id": "test-request-id", + "imp": [ + { + "id": "test-imp-id-1", + "video": { + "mimes": ["video/mp4"] + }, + "tagid": "tagid_1", + "ext": { + "prebid": { + "bidder": { + "magnite": { + "bidder_param_1": "value_1" + } + } + } + } + }, + { + "id": "test-imp-id-2", + "video": { + "mimes": ["video/mp4"] + }, + "tagid": "tagid_2", + "ext": { + "prebid": { + "bidder": { + "magnite": { + "bidder_param_2": "value_2" + } + } + } + } + } + ] + }, + "httpCalls": [ + { + "expectedRequest": { + "uri": "http://test_bidder.com", + "body": { + "id": "test-request-id", + "imp": [ + { + "id": "test-imp-id-1", + "video": { + "mimes": ["video/mp4"] + }, + "tagid": "tagid_1", + "ext": { + "prebid": { + "bidder": { + "magnite": { + "bidder_param_1": "value_1" + } + } + } + } + } + ] + } + }, + "mockResponse": { + "status": 200, + "body": { + "id": "test-request-id", + "seatbid": [ + { + "seat": "magnite", + "bid": [{ + "id": "bid-1", + "impid": "test-imp-id-1", + "price": 0.500000, + "adm": "some-test-ad", + "crid": "crid_10", + "mtype": 2 + }] + } + ], + "cur": "USD" + } + } + }, + { + "expectedRequest": { + "uri": "http://test_bidder.com", + "body": { + "id": "test-request-id", + "imp": [ + { + "id": "test-imp-id-2", + "video": { + "mimes": ["video/mp4"] + }, + "tagid": "tagid_2", + "ext": { + "prebid": { + "bidder": { + "magnite": { + "bidder_param_2": "value_2" + } + } + } + } + } + ] + } + }, + "mockResponse": { + "status": 200, + "body": { + "id": "test-request-id", + "seatbid": [ + { + "seat": "magnite", + "bid": [{ + "id": "bid-2", + "impid": "test-imp-id-2", + "price": 10, + "adm": "some-test-ad-2", + "crid": "crid_10", + "mtype": 2 + }] + } + ], + "cur": "USD" + } + } + } + ], + + "expectedBidResponses": [ + { + "bids": [ + { + "bid": { + "id": "bid-1", + "impid": "test-imp-id-1", + "price": 0.5, + "adm": "some-test-ad", + "crid": "crid_10", + "mtype":2 + }, + "type": "video" + } + ] + }, + { + "bids": [ + { + "bid": { + "id": "bid-2", + "impid": "test-imp-id-2", + "price": 10, + "adm": "some-test-ad-2", + "crid": "crid_10", + "mtype":2 + }, + "type": "video" + } + ] + } + ] + } diff --git a/adapters/ortbbidder/requestParamMapper.go b/adapters/ortbbidder/requestParamMapper.go new file mode 100644 index 00000000000..013db98c7c9 --- /dev/null +++ b/adapters/ortbbidder/requestParamMapper.go @@ -0,0 +1,71 @@ +package ortbbidder + +import ( + "encoding/json" + "fmt" + + "github.com/prebid/prebid-server/v2/adapters/ortbbidder/bidderparams" +) + +const ( + impKey = "imp" + extKey = "ext" + bidderKey = "bidder" + appsiteKey = "appsite" + siteKey = "site" + appKey = "app" +) + +// setRequestParams updates the requestBody based on the requestParams mapping details. +func setRequestParams(requestBody []byte, requestParams map[string]bidderparams.BidderParamMapper) ([]byte, error) { + if len(requestParams) == 0 { + return requestBody, nil + } + request := map[string]any{} + err := json.Unmarshal(requestBody, &request) + if err != nil { + return nil, err + } + imps, ok := request[impKey].([]any) + if !ok { + return nil, fmt.Errorf("error:[invalid_imp_found_in_requestbody], imp:[%v]", request[impKey]) + } + updatedRequest := false + for ind, imp := range imps { + request[impKey] = imp + imp, ok := imp.(map[string]any) + if !ok { + return nil, fmt.Errorf("error:[invalid_imp_found_in_implist], imp:[%v]", request[impKey]) + } + ext, ok := imp[extKey].(map[string]any) + if !ok { + continue + } + bidderParams, ok := ext[bidderKey].(map[string]any) + if !ok { + continue + } + for paramName, paramValue := range bidderParams { + paramMapper, ok := requestParams[paramName] + if !ok { + continue + } + // set the value in the request according to the mapping details and remove the parameter. + if setValue(request, paramMapper.GetLocation(), paramValue) { + delete(bidderParams, paramName) + updatedRequest = true + } + } + imps[ind] = request[impKey] + } + // update the impression list in the request + request[impKey] = imps + // if the request was modified, marshal it back to JSON. + if updatedRequest { + requestBody, err = json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("error:[fail_to_update_request_body] msg:[%s]", err.Error()) + } + } + return requestBody, nil +} diff --git a/adapters/ortbbidder/requestParamMapper_test.go b/adapters/ortbbidder/requestParamMapper_test.go new file mode 100644 index 00000000000..3aad8961e4b --- /dev/null +++ b/adapters/ortbbidder/requestParamMapper_test.go @@ -0,0 +1,257 @@ +package ortbbidder + +import ( + "encoding/json" + "testing" + + "github.com/prebid/prebid-server/v2/adapters/ortbbidder/bidderparams" + "github.com/stretchr/testify/assert" +) + +func TestSetRequestParams(t *testing.T) { + type args struct { + requestBody []byte + mapper map[string]bidderparams.BidderParamMapper + } + type want struct { + err string + requestBody []byte + } + tests := []struct { + name string + args args + want want + }{ + { + name: "empty_mapper", + args: args{ + requestBody: json.RawMessage(`{"imp":[{"ext":{"bidder":{}}}]}`), + }, + want: want{ + err: "", + requestBody: json.RawMessage(`{"imp":[{"ext":{"bidder":{}}}]}`), + }, + }, + { + name: "nil_requestbody", + args: args{ + requestBody: nil, + mapper: map[string]bidderparams.BidderParamMapper{ + "adunit": {}, + }, + }, + want: want{ + err: "unexpected end of JSON input", + }, + }, + { + name: "requestbody_has_invalid_imps", + args: args{ + requestBody: json.RawMessage(`{"imp":{"id":"1"}}`), + mapper: map[string]bidderparams.BidderParamMapper{ + "adunit": {}, + }, + }, + want: want{ + err: "error:[invalid_imp_found_in_requestbody], imp:[map[id:1]]", + }, + }, + { + name: "missing_imp_ext", + args: args{ + requestBody: json.RawMessage(`{"imp":[{}]}`), + mapper: map[string]bidderparams.BidderParamMapper{ + "adunit": {}, + }, + }, + want: want{ + err: "", + requestBody: json.RawMessage(`{"imp":[{}]}`), + }, + }, + { + name: "missing_bidder_in_imp_ext", + args: args{ + requestBody: json.RawMessage(`{"imp":[{"ext":{}}]}`), + mapper: map[string]bidderparams.BidderParamMapper{ + "adunit": {}, + }, + }, + want: want{ + err: "", + requestBody: json.RawMessage(`{"imp":[{"ext":{}}]}`), + }, + }, + { + name: "missing_bidderparams_in_imp_ext", + args: args{ + requestBody: json.RawMessage(`{"imp":[{"ext":{"bidder":{}}}]}`), + mapper: map[string]bidderparams.BidderParamMapper{ + "adunit": {}, + }, + }, + want: want{ + err: "", + requestBody: json.RawMessage(`{"imp":[{"ext":{"bidder":{}}}]}`), + }, + }, + { + name: "mapper_not_contains_bidder_param_location", + args: args{ + requestBody: json.RawMessage(`{"imp":[{"ext":{"bidder":{"adunit":123}}}]}`), + mapper: func() map[string]bidderparams.BidderParamMapper { + bpm := bidderparams.BidderParamMapper{} + bpm.SetLocation([]string{"ext"}) + return map[string]bidderparams.BidderParamMapper{ + "slot": bpm, + } + }(), + }, + want: want{ + err: "", + requestBody: json.RawMessage(`{"imp":[{"ext":{"bidder":{"adunit":123}}}]}`), + }, + }, + { + name: "mapper_contains_bidder_param_location", + args: args{ + requestBody: json.RawMessage(`{"imp":[{"ext":{"bidder":{"adunit":123}}}]}`), + mapper: func() map[string]bidderparams.BidderParamMapper { + bpm := bidderparams.BidderParamMapper{} + bpm.SetLocation([]string{"ext", "adunit"}) + return map[string]bidderparams.BidderParamMapper{ + "adunit": bpm, + } + }(), + }, + want: want{ + err: "", + requestBody: json.RawMessage(`{"ext":{"adunit":123},"imp":[{"ext":{"bidder":{}}}]}`), + }, + }, + { + name: "do_not_delete_bidder_param_if_failed_to_set_value", + args: args{ + requestBody: json.RawMessage(`{"imp":[{"ext":{"bidder":{"adunit":123}}}]}`), + mapper: func() map[string]bidderparams.BidderParamMapper { + bpm := bidderparams.BidderParamMapper{} + bpm.SetLocation([]string{"req", "", ""}) + return map[string]bidderparams.BidderParamMapper{ + "adunit": bpm, + } + }(), + }, + want: want{ + err: "", + requestBody: json.RawMessage(`{"imp":[{"ext":{"bidder":{"adunit":123}}}]}`), + }, + }, + { + name: "set_multiple_bidder_params", + args: args{ + requestBody: json.RawMessage(`{"app":{"name":"sampleapp"},"imp":[{"tagid":"oldtagid","ext":{"bidder":{"paramWithoutLocation":"value","adunit":123,"slot":"test_slot","wrapper":{"pubid":5890,"profile":1}}}}]}`), + mapper: func() map[string]bidderparams.BidderParamMapper { + adunit := bidderparams.BidderParamMapper{} + adunit.SetLocation([]string{"adunit", "id"}) + slot := bidderparams.BidderParamMapper{} + slot.SetLocation([]string{"imp", "tagid"}) + wrapper := bidderparams.BidderParamMapper{} + wrapper.SetLocation([]string{"app", "ext"}) + return map[string]bidderparams.BidderParamMapper{ + "adunit": adunit, + "slot": slot, + "wrapper": wrapper, + } + }(), + }, + want: want{ + err: "", + requestBody: json.RawMessage(`{"adunit":{"id":123},"app":{"ext":{"profile":1,"pubid":5890},"name":"sampleapp"},"imp":[{"ext":{"bidder":{"paramWithoutLocation":"value"}},"tagid":"test_slot"}]}`), + }, + }, + { + name: "conditional_mapping_set_app_object", + args: args{ + requestBody: json.RawMessage(`{"app":{"name":"sampleapp"},"imp":[{"tagid":"oldtagid","ext":{"bidder":{"paramWithoutLocation":"value","adunit":123,"slot":"test_slot","wrapper":{"pubid":5890,"profile":1}}}}]}`), + mapper: func() map[string]bidderparams.BidderParamMapper { + bpm := bidderparams.BidderParamMapper{} + bpm.SetLocation([]string{"appsite", "wrapper"}) + return map[string]bidderparams.BidderParamMapper{ + "wrapper": bpm, + } + }(), + }, + want: want{ + err: "", + requestBody: json.RawMessage(`{"app":{"name":"sampleapp","wrapper":{"profile":1,"pubid":5890}},"imp":[{"ext":{"bidder":{"adunit":123,"paramWithoutLocation":"value","slot":"test_slot"}},"tagid":"oldtagid"}]}`), + }, + }, + { + name: "conditional_mapping_set_site_object", + args: args{ + requestBody: json.RawMessage(`{"site":{"name":"sampleapp"},"imp":[{"tagid":"oldtagid","ext":{"bidder":{"paramWithoutLocation":"value","adunit":123,"slot":"test_slot","wrapper":{"pubid":5890,"profile":1}}}}]}`), + mapper: func() map[string]bidderparams.BidderParamMapper { + bpm := bidderparams.BidderParamMapper{} + bpm.SetLocation([]string{"appsite", "wrapper"}) + return map[string]bidderparams.BidderParamMapper{ + "wrapper": bpm, + } + }(), + }, + want: want{ + err: "", + requestBody: json.RawMessage(`{"imp":[{"ext":{"bidder":{"adunit":123,"paramWithoutLocation":"value","slot":"test_slot"}},"tagid":"oldtagid"}],"site":{"name":"sampleapp","wrapper":{"profile":1,"pubid":5890}}}`), + }, + }, + { + name: "multi_imps_bidder_params_mapping", + args: args{ + requestBody: json.RawMessage(`{"app":{"name":"sampleapp"},"imp":[{"tagid":"tagid_1","ext":{"bidder":{"paramWithoutLocation":"value","adunit":111,"slot":"test_slot_1","wrapper":{"pubid":5890,"profile":1}}}},{"tagid":"tagid_2","ext":{"bidder":{"slot":"test_slot_2","adunit":222}}}]}`), + mapper: func() map[string]bidderparams.BidderParamMapper { + adunit := bidderparams.BidderParamMapper{} + adunit.SetLocation([]string{"adunit", "id"}) + slot := bidderparams.BidderParamMapper{} + slot.SetLocation([]string{"imp", "tagid"}) + wrapper := bidderparams.BidderParamMapper{} + wrapper.SetLocation([]string{"app", "ext"}) + return map[string]bidderparams.BidderParamMapper{ + "adunit": adunit, + "slot": slot, + "wrapper": wrapper, + } + }(), + }, + want: want{ + err: "", + requestBody: json.RawMessage(`{"adunit":{"id":222},"app":{"ext":{"profile":1,"pubid":5890},"name":"sampleapp"},"imp":[{"ext":{"bidder":{"paramWithoutLocation":"value"}},"tagid":"test_slot_1"},{"ext":{"bidder":{}},"tagid":"test_slot_2"}]}`), + }, + }, + { + name: "multi_imps_bidder_params_mapping_override_if_same_param_present", + args: args{ + requestBody: json.RawMessage(`{"app":{"name":"sampleapp"},"imp":[{"tagid":"tagid_1","ext":{"bidder":{"paramWithoutLocation":"value","adunit":111}}},{"tagid":"tagid_2","ext":{"bidder":{"adunit":222}}}]}`), + mapper: func() map[string]bidderparams.BidderParamMapper { + bpm := bidderparams.BidderParamMapper{} + bpm.SetLocation([]string{"adunit", "id"}) + return map[string]bidderparams.BidderParamMapper{ + "adunit": bpm, + } + }(), + }, + want: want{ + err: "", + requestBody: json.RawMessage(`{"adunit":{"id":222},"app":{"name":"sampleapp"},"imp":[{"ext":{"bidder":{"paramWithoutLocation":"value"}},"tagid":"tagid_1"},{"ext":{"bidder":{}},"tagid":"tagid_2"}]}`), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := setRequestParams(tt.args.requestBody, tt.args.mapper) + assert.Equal(t, string(tt.want.requestBody), string(got), "mismatched request body") + assert.Equal(t, len(tt.want.err) == 0, err == nil, "mismatched error") + if err != nil { + assert.Equal(t, err.Error(), tt.want.err, "mismatched error string") + } + }) + } +} diff --git a/adapters/ortbbidder/util.go b/adapters/ortbbidder/util.go new file mode 100644 index 00000000000..5b2dccc67bf --- /dev/null +++ b/adapters/ortbbidder/util.go @@ -0,0 +1,63 @@ +package ortbbidder + +/* +setValue updates or creates a value in a node based on a specified location. +The location is a string that specifies a path through the node hierarchy, +separated by dots ('.'). The value can be any type, and the function will +create intermediate nodes as necessary if they do not exist. + +Arguments: +- node: the root of the map in which to set the value +- locations: slice of strings indicating the path to set the value. +- value: The value to set at the specified location. Can be of any type. + +Example: + - location = imp.ext.adunitid; value = 123 ==> {"imp": {"ext" : {"adunitid":123}}} +*/ +func setValue(node map[string]any, locations []string, value any) bool { + if value == nil || len(locations) == 0 { + return false + } + + lastNodeIndex := len(locations) - 1 + currentNode := node + + for index, loc := range locations { + if len(loc) == 0 { // if location part is empty string + return false + } + if index == lastNodeIndex { // if it's the last part in location, set the value + currentNode[loc] = value + break + } + nextNode := getNode(currentNode, loc) + // not the last part, navigate deeper + if nextNode == nil { + // loc does not exist, set currentNode to a new node + newNode := make(map[string]any) + currentNode[loc] = newNode + currentNode = newNode + continue + } + // loc exists, set currentNode to nextNode + nextNodeTyped, ok := nextNode.(map[string]any) + if !ok { + return false + } + currentNode = nextNodeTyped + } + return true +} + +// getNode retrieves the value for a given key from a map with special handling for the "appsite" key +func getNode(nodes map[string]any, key string) any { + switch key { + case appsiteKey: + // if key is "appsite" and if nodes contains "site" object then return nodes["site"] else return nodes["app"] + if value, ok := nodes[siteKey]; ok { + return value + } + return nodes[appKey] + } + return nodes[key] +} diff --git a/adapters/ortbbidder/util_test.go b/adapters/ortbbidder/util_test.go new file mode 100644 index 00000000000..059303f6dfb --- /dev/null +++ b/adapters/ortbbidder/util_test.go @@ -0,0 +1,243 @@ +package ortbbidder + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSetValue(t *testing.T) { + type args struct { + node map[string]any + location []string + value any + } + type want struct { + node map[string]any + status bool + } + tests := []struct { + name string + args args + want want + }{ + { + name: "set_nil_value", + args: args{ + node: map[string]any{}, + location: []string{"key"}, + value: nil, + }, + want: want{ + status: false, + node: map[string]any{}, + }, + }, + { + name: "set_value_in_empty_location", + args: args{ + node: map[string]any{}, + location: []string{}, + value: 123, + }, + want: want{ + status: false, + node: map[string]any{}, + }, + }, + { + name: "set_value_in_invalid_location_modifies_node", + args: args{ + node: map[string]any{}, + location: []string{"key", ""}, + value: 123, + }, + want: want{ + status: false, + node: map[string]any{ + "key": map[string]any{}, + }, + }, + }, + { + name: "set_value_at_root_level_in_empty_node", + args: args{ + node: map[string]any{}, + location: []string{"key"}, + value: 123, + }, + want: want{ + status: true, + node: map[string]any{"key": 123}, + }, + }, + { + name: "set_value_at_root_level_in_non-empty_node", + args: args{ + node: map[string]any{"oldKey": "oldValue"}, + location: []string{"key"}, + value: 123, + }, + want: want{ + status: true, + node: map[string]any{"oldKey": "oldValue", "key": 123}, + }, + }, + { + name: "set_value_at_non-root_level_in_non-json_node", + args: args{ + node: map[string]any{"rootKey": "rootValue"}, + location: []string{"rootKey", "key"}, + value: 123, + }, + want: want{ + status: false, + node: map[string]any{"rootKey": "rootValue"}, + }, + }, + { + name: "set_value_at_non-root_level_in_json_node", + args: args{ + node: map[string]any{"rootKey": map[string]any{ + "oldKey": "oldValue", + }}, + location: []string{"rootKey", "newKey"}, + value: 123, + }, + want: want{ + status: true, + node: map[string]any{"rootKey": map[string]any{ + "oldKey": "oldValue", + "newKey": 123, + }}, + }, + }, + { + name: "set_value_at_non-root_level_in_nested-json_node", + args: args{ + node: map[string]any{"rootKey": map[string]any{ + "parentKey1": map[string]any{ + "innerKey": "innerValue", + }, + }}, + location: []string{"rootKey", "parentKey2"}, + value: "newKeyValue", + }, + want: want{ + status: true, + node: map[string]any{"rootKey": map[string]any{ + "parentKey1": map[string]any{ + "innerKey": "innerValue", + }, + "parentKey2": "newKeyValue", + }}, + }, + }, + { + name: "override_existing_key's_value", + args: args{ + node: map[string]any{"rootKey": map[string]any{ + "parentKey": map[string]any{ + "innerKey": "innerValue", + }, + }}, + location: []string{"rootKey", "parentKey"}, + value: "newKeyValue", + }, + want: want{ + status: true, + node: map[string]any{"rootKey": map[string]any{ + "parentKey": "newKeyValue", + }}, + }, + }, + { + name: "appsite_key_app_object_present", + args: args{ + node: map[string]any{"app": map[string]any{ + "parentKey": "oldValue", + }}, + location: []string{"appsite", "parentKey"}, + value: "newKeyValue", + }, + want: want{ + status: true, + node: map[string]any{"app": map[string]any{ + "parentKey": "newKeyValue", + }}, + }, + }, + { + name: "appsite_key_site_object_present", + args: args{ + node: map[string]any{"site": map[string]any{ + "parentKey": "oldValue", + }}, + location: []string{"appsite", "parentKey"}, + value: "newKeyValue", + }, + want: want{ + status: true, + node: map[string]any{"site": map[string]any{ + "parentKey": "newKeyValue", + }}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := setValue(tt.args.node, tt.args.location, tt.args.value) + assert.Equalf(t, tt.want.node, tt.args.node, "SetValue failed to update node object") + assert.Equalf(t, tt.want.status, got, "SetValue returned invalid status") + }) + } +} + +func TestGetNode(t *testing.T) { + type args struct { + nodes map[string]any + key string + } + tests := []struct { + name string + args args + want any + }{ + { + name: "appsite_key_present_when_app_object_present", + args: args{ + nodes: map[string]any{"app": map[string]any{ + "parentKey": "oldValue", + }}, + key: "appsite", + }, + want: map[string]any{"parentKey": "oldValue"}, + }, + { + name: "appsite_key_present_when_site_object_present", + args: args{ + nodes: map[string]any{"site": map[string]any{ + "siteKey": "siteValue", + }}, + key: "appsite", + }, + want: map[string]any{"siteKey": "siteValue"}, + }, + { + name: "appsite_key_absent", + args: args{ + nodes: map[string]any{"device": map[string]any{ + "deviceKey": "deviceVal", + }}, + key: "appsite", + }, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + node := getNode(tt.args.nodes, tt.args.key) + assert.Equal(t, tt.want, node) + }) + } +} diff --git a/endpoints/openrtb2/ctv/adpod/adpod.go b/endpoints/openrtb2/ctv/adpod/adpod.go new file mode 100644 index 00000000000..a05bf8f7b39 --- /dev/null +++ b/endpoints/openrtb2/ctv/adpod/adpod.go @@ -0,0 +1,46 @@ +package adpod + +import ( + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/types" + "github.com/prebid/prebid-server/v2/metrics" + "github.com/prebid/prebid-server/v2/openrtb_ext" +) + +type PodType int8 + +const ( + Structured PodType = 0 + Dynamic PodType = 1 + Hybrid PodType = 2 + NotAdpod PodType = -1 +) + +type Adpod interface { + GetPodType() PodType + AddImpressions(imp openrtb2.Imp) + Validate() []error + GetImpressions() []openrtb2.Imp + CollectBid(bid *openrtb2.Bid, seat string) + HoldAuction() + GetAdpodSeatBids() []openrtb2.SeatBid + GetAdpodExtension(blockedVastTagID map[string]map[string][]string) *types.ImpData +} + +type AdpodCtx struct { + PubId string + Type PodType + Imps []openrtb2.Imp + ReqAdpodExt *openrtb_ext.ExtRequestAdPod + Exclusion Exclusion + MetricsEngine metrics.MetricsEngine +} + +type Exclusion struct { + AdvertiserDomainExclusion bool + IABCategoryExclusion bool +} + +func (ex *Exclusion) shouldApplyExclusion() bool { + return ex.AdvertiserDomainExclusion || ex.IABCategoryExclusion +} diff --git a/endpoints/openrtb2/ctv/adpod/dynamic_adpod.go b/endpoints/openrtb2/ctv/adpod/dynamic_adpod.go new file mode 100644 index 00000000000..97ee43e10d6 --- /dev/null +++ b/endpoints/openrtb2/ctv/adpod/dynamic_adpod.go @@ -0,0 +1,522 @@ +package adpod + +import ( + "encoding/json" + "fmt" + "math" + "net/url" + "strconv" + "strings" + "time" + + "github.com/beevik/etree" + "github.com/buger/jsonparser" + "github.com/gofrs/uuid" + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v2/endpoints/events" + "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/combination" + "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/constant" + "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/impressions" + "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/response" + "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/types" + "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/util" + "github.com/prebid/prebid-server/v2/metrics" + "github.com/prebid/prebid-server/v2/openrtb_ext" + "github.com/prebid/prebid-server/v2/util/ptrutil" +) + +type dynamicAdpod struct { + AdpodCtx + MinPodDuration int64 `json:"-"` + MaxPodDuration int64 `json:"-"` + MaxExtended int64 `json:"-"` + Imp openrtb2.Imp `json:"-"` + VideoExt *openrtb_ext.ExtVideoAdPod `json:"vidext,omitempty"` + ImpConfigs []*types.ImpAdPodConfig `json:"imp,omitempty"` + AdpodBid *types.AdPodBid `json:"-"` + WinningBids *types.AdPodBid `json:"-"` + Error *openrtb_ext.ExtBidderMessage `json:"ec,omitempty"` +} + +func NewDynamicAdpod(pubId string, imp openrtb2.Imp, adpodExt openrtb_ext.ExtVideoAdPod, metricsEngine metrics.MetricsEngine, reqAdpodExt *openrtb_ext.ExtRequestAdPod) *dynamicAdpod { + //set Pod Duration + minPodDuration := imp.Video.MinDuration + maxPodDuration := imp.Video.MaxDuration + + if adpodExt.AdPod == nil { + adpodExt = openrtb_ext.ExtVideoAdPod{ + Offset: ptrutil.ToPtr(0), + AdPod: &openrtb_ext.VideoAdPod{ + MinAds: ptrutil.ToPtr(1), + MaxAds: ptrutil.ToPtr(int(imp.Video.MaxSeq)), + MinDuration: ptrutil.ToPtr(int(imp.Video.MinDuration)), + MaxDuration: ptrutil.ToPtr(int(imp.Video.MaxDuration)), + AdvertiserExclusionPercent: ptrutil.ToPtr(100), + IABCategoryExclusionPercent: ptrutil.ToPtr(100), + }, + } + maxPodDuration = imp.Video.PodDur + } + + adpod := dynamicAdpod{ + AdpodCtx: AdpodCtx{ + PubId: pubId, + Type: Dynamic, + ReqAdpodExt: reqAdpodExt, + MetricsEngine: metricsEngine, + }, + Imp: imp, + VideoExt: &adpodExt, + MinPodDuration: minPodDuration, + MaxPodDuration: maxPodDuration, + } + + return &adpod +} + +func (da *dynamicAdpod) GetPodType() PodType { + return da.Type +} + +func (da *dynamicAdpod) AddImpressions(imp openrtb2.Imp) { + da.Imps = append(da.Imps, imp) +} + +func (da *dynamicAdpod) GetImpressions() []openrtb2.Imp { + da.getAdPodImpConfigs() + + // Generate Impressions based on configs + for i := range da.ImpConfigs { + da.AddImpressions(newImpression(da.Imp, da.ImpConfigs[i])) + } + + return da.Imps +} + +func (da *dynamicAdpod) CollectBid(bid *openrtb2.Bid, seat string) { + originalImpId, sequence := util.DecodeImpressionID(bid.ImpID) + + if da.AdpodBid == nil { + da.AdpodBid = &types.AdPodBid{ + Bids: make([]*types.Bid, 0), + OriginalImpID: originalImpId, + SeatName: string(openrtb_ext.BidderOWPrebidCTV), + } + } + + ext := openrtb_ext.ExtBid{} + if bid.Ext != nil { + json.Unmarshal(bid.Ext, &ext) + } + + //get duration of creative + duration, status := getBidDuration(bid, da.ReqAdpodExt, da.ImpConfigs, da.ImpConfigs[sequence-1].MaxDuration) + + da.AdpodBid.Bids = append(da.AdpodBid.Bids, &types.Bid{ + Bid: bid, + ExtBid: ext, + Status: status, + Duration: int(duration), + DealTierSatisfied: util.GetDealTierSatisfied(&ext), + Seat: seat, + }) +} + +func (da *dynamicAdpod) HoldAuction() { + if da.AdpodBid == nil || len(da.AdpodBid.Bids) == 0 { + return + } + + // Check if we need sorting + // sort.Slice(da.AdpodBid.Bids, func(i, j int) bool { return da.AdpodBid.Bids[i].Price > da.AdpodBid.Bids[j].Price }) + + buckets := util.GetDurationWiseBidsBucket(da.AdpodBid.Bids) + if len(buckets) == 0 { + da.Error = util.DurationMismatchWarning + return + } + + //combination generator + comb := combination.NewCombination(buckets, uint64(da.MinPodDuration), uint64(da.MaxPodDuration), da.VideoExt.AdPod) + + //adpod generator + adpodGenerator := response.NewAdPodGenerator(buckets, comb, da.VideoExt.AdPod, da.MetricsEngine) + + adpodBid := adpodGenerator.GetAdPodBids() + if adpodBid == nil { + da.Error = util.UnableToGenerateAdPodWarning + return + } + adpodBid.OriginalImpID = da.AdpodBid.OriginalImpID + adpodBid.SeatName = da.AdpodBid.SeatName + + da.WinningBids = adpodBid + +} + +func (da *dynamicAdpod) Validate() []error { + var valdiationErrs []error + + if da.VideoExt == nil { + return valdiationErrs + } + + extErrs := da.VideoExt.Validate() + if len(extErrs) > 0 { + valdiationErrs = append(valdiationErrs, extErrs...) + } + + durationErrs := da.VideoExt.AdPod.ValidateAdPodDurations(da.MinPodDuration, da.MaxPodDuration, da.MaxExtended) + if len(durationErrs) > 0 { + valdiationErrs = append(valdiationErrs, durationErrs...) + } + + return valdiationErrs +} + +func (da *dynamicAdpod) GetAdpodSeatBids() []openrtb2.SeatBid { + // Record Rejected bids + da.recordRejectedAdPodBids(da.PubId) + + return da.getBidResponseSeatBids() +} + +func (da *dynamicAdpod) GetAdpodExtension(blockedVastTagID map[string]map[string][]string) *types.ImpData { + da.setBidExtParams() + + data := types.ImpData{ + ImpID: da.Imp.ID, + Bid: da.AdpodBid, + VideoExt: da.VideoExt, + Config: da.ImpConfigs, + BlockedVASTTags: blockedVastTagID[da.Imp.ID], + Error: da.Error, + } + + return &data +} + +/***************************** Dynamic adpod processing method ************************************/ + +// getAdPodImpsConfigs will return number of impressions configurations within adpod +func (da *dynamicAdpod) getAdPodImpConfigs() { + // monitor + start := time.Now() + selectedAlgorithm := impressions.SelectAlgorithm(da.ReqAdpodExt) + impGen := impressions.NewImpressions(da.MinPodDuration, da.MaxPodDuration, da.ReqAdpodExt, da.VideoExt.AdPod, selectedAlgorithm) + impRanges := impGen.Get() + labels := metrics.PodLabels{AlgorithmName: impressions.MonitorKey[selectedAlgorithm], NoOfImpressions: new(int)} + + //log number of impressions in stats + *labels.NoOfImpressions = len(impRanges) + da.MetricsEngine.RecordPodImpGenTime(labels, start) + + // check if algorithm has generated impressions + if len(impRanges) == 0 { + da.Error = &openrtb_ext.ExtBidderMessage{ + Code: util.UnableToGenerateImpressionsError.Code(), + Message: util.UnableToGenerateImpressionsError.Message, + } + return + } + + config := make([]*types.ImpAdPodConfig, len(impRanges)) + for i, value := range impRanges { + config[i] = &types.ImpAdPodConfig{ + ImpID: util.GetCTVImpressionID(da.Imp.ID, i+1), + MinDuration: value[0], + MaxDuration: value[1], + SequenceNumber: int8(i + 1), /* Must be starting with 1 */ + } + } + + da.ImpConfigs = config +} + +// newImpression will clone existing impression object and create video object with ImpAdPodConfig. +func newImpression(imp openrtb2.Imp, config *types.ImpAdPodConfig) openrtb2.Imp { + video := *imp.Video + video.MinDuration = config.MinDuration + video.MaxDuration = config.MaxDuration + video.Sequence = config.SequenceNumber + video.MaxExtended = 0 + + video.Ext = jsonparser.Delete(video.Ext, "adpod") + video.Ext = jsonparser.Delete(video.Ext, "offset") + if string(video.Ext) == "{}" { + video.Ext = nil + } + + newImp := imp + newImp.ID = config.ImpID + newImp.Video = &video + return newImp +} + +/* +getBidDuration determines the duration of video ad from given bid. +it will try to get the actual ad duration returned by the bidder using prebid.video.duration +if prebid.video.duration not present then uses defaultDuration passed as an argument +if video lengths matching policy is present for request then it will validate and update duration based on policy +*/ +func getBidDuration(bid *openrtb2.Bid, reqExt *openrtb_ext.ExtRequestAdPod, config []*types.ImpAdPodConfig, defaultDuration int64) (int64, constant.BidStatus) { + + // C1: Read it from bid.ext.prebid.video.duration field + duration, err := jsonparser.GetInt(bid.Ext, "prebid", "video", "duration") + if nil != err || duration <= 0 { + // incase if duration is not present use impression duration directly as it is + return defaultDuration, constant.StatusOK + } + + // C2: Based on video lengths matching policy validate and return duration + if nil != reqExt && len(reqExt.VideoAdDurationMatching) > 0 { + return getDurationBasedOnDurationMatchingPolicy(duration, reqExt.VideoAdDurationMatching, config) + } + + //default return duration which is present in bid.ext.prebid.vide.duration field + return duration, constant.StatusOK +} + +// getDurationBasedOnDurationMatchingPolicy will return duration based on durationmatching policy +func getDurationBasedOnDurationMatchingPolicy(duration int64, policy openrtb_ext.OWVideoAdDurationMatchingPolicy, config []*types.ImpAdPodConfig) (int64, constant.BidStatus) { + switch policy { + case openrtb_ext.OWExactVideoAdDurationMatching: + tmp := util.GetNearestDuration(duration, config) + if tmp != duration { + return duration, constant.StatusDurationMismatch + } + //its and valid duration return it with StatusOK + + case openrtb_ext.OWRoundupVideoAdDurationMatching: + tmp := util.GetNearestDuration(duration, config) + if tmp == -1 { + return duration, constant.StatusDurationMismatch + } + //update duration with nearest one duration + duration = tmp + //its and valid duration return it with StatusOK + } + + return duration, constant.StatusOK +} + +/***************************Bid Response Processing************************/ + +func (da *dynamicAdpod) getBidResponseSeatBids() []openrtb2.SeatBid { + if da.WinningBids == nil || len(da.WinningBids.Bids) == 0 { + return nil + } + + bid := da.getAdPodBid(da.WinningBids) + if bid == nil { + return nil + } + + adpodSeat := openrtb2.SeatBid{ + Seat: da.AdpodBid.SeatName, + } + adpodSeat.Bid = append(adpodSeat.Bid, *bid.Bid) + + return []openrtb2.SeatBid{adpodSeat} +} + +// getAdPodBid +func (da *dynamicAdpod) getAdPodBid(adpod *types.AdPodBid) *types.Bid { + bid := types.Bid{ + Bid: &openrtb2.Bid{}, + } + + //TODO: Write single for loop to get all details + bidID, err := uuid.NewV4() + if err == nil { + bid.ID = bidID.String() + } else { + bid.ID = adpod.Bids[0].ID + } + + bid.ImpID = adpod.OriginalImpID + bid.Price = adpod.Price + bid.ADomain = adpod.ADomain[:] + bid.Cat = adpod.Cat[:] + bid.AdM = *getAdPodBidCreative(adpod, true) + bid.Ext = getAdPodBidExtension(adpod) + return &bid +} + +// getAdPodBidCreative get commulative adpod bid details +func getAdPodBidCreative(adpod *types.AdPodBid, generatedBidID bool) *string { + doc := etree.NewDocument() + vast := doc.CreateElement(constant.VASTElement) + sequenceNumber := 1 + var version float64 = 2.0 + + for _, bid := range adpod.Bids { + var newAd *etree.Element + + if strings.HasPrefix(bid.AdM, constant.HTTPPrefix) { + newAd = etree.NewElement(constant.VASTAdElement) + wrapper := newAd.CreateElement(constant.VASTWrapperElement) + vastAdTagURI := wrapper.CreateElement(constant.VASTAdTagURIElement) + vastAdTagURI.CreateCharData(bid.AdM) + } else { + adDoc := etree.NewDocument() + if err := adDoc.ReadFromString(bid.AdM); err != nil { + continue + } + + if generatedBidID == false { + // adjust bidid in video event trackers and update + adjustBidIDInVideoEventTrackers(adDoc, bid.Bid) + adm, err := adDoc.WriteToString() + if nil != err { + util.JLogf("ERROR, %v", err.Error()) + } else { + bid.AdM = adm + } + } + + vastTag := adDoc.SelectElement(constant.VASTElement) + if vastTag == nil { + util.Logf("error:[vast_element_missing_in_adm] seat:[%s] adm:[%s]", bid.Seat, bid.AdM) + continue + } + + //Get Actual VAST Version + bidVASTVersion, _ := strconv.ParseFloat(vastTag.SelectAttrValue(constant.VASTVersionAttribute, constant.VASTDefaultVersionStr), 64) + version = math.Max(version, bidVASTVersion) + + ads := vastTag.SelectElements(constant.VASTAdElement) + if len(ads) > 0 { + newAd = ads[0].Copy() + } + } + + if nil != newAd { + //creative.AdId attribute needs to be updated + newAd.CreateAttr(constant.VASTSequenceAttribute, fmt.Sprint(sequenceNumber)) + vast.AddChild(newAd) + sequenceNumber++ + } + } + + if int(version) > len(constant.VASTVersionsStr) { + version = constant.VASTMaxVersion + } + + vast.CreateAttr(constant.VASTVersionAttribute, constant.VASTVersionsStr[int(version)]) + bidAdM, err := doc.WriteToString() + if err != nil { + fmt.Printf("ERROR, %v", err.Error()) + return nil + } + return &bidAdM +} + +func adjustBidIDInVideoEventTrackers(doc *etree.Document, bid *openrtb2.Bid) { + // adjusment: update bid.id with ctv module generated bid.id + creatives := events.FindCreatives(doc) + for _, creative := range creatives { + trackingEvents := creative.FindElements("TrackingEvents/Tracking") + if nil != trackingEvents { + // update bidid= value with ctv generated bid id for this bid + for _, trackingEvent := range trackingEvents { + u, e := url.Parse(trackingEvent.Text()) + if nil == e { + values, e := url.ParseQuery(u.RawQuery) + // only do replacment if operId=8 + if nil == e && nil != values["bidid"] && nil != values["operId"] && values["operId"][0] == "8" { + values.Set("bidid", bid.ID) + } else { + continue + } + + //OTT-183: Fix + if nil != values["operId"] && values["operId"][0] == "8" { + operID := values.Get("operId") + values.Del("operId") + values.Add("_operId", operID) // _ (underscore) will keep it as first key + } + + u.RawQuery = values.Encode() // encode sorts query params by key. _ must be first (assuing no other query param with _) + // replace _operId with operId + u.RawQuery = strings.ReplaceAll(u.RawQuery, "_operId", "operId") + trackingEvent.SetText(u.String()) + } + } + } + } +} + +// getAdPodBidExtension get commulative adpod bid details +func getAdPodBidExtension(adpod *types.AdPodBid) json.RawMessage { + bidExt := &openrtb_ext.ExtOWBid{ + ExtBid: openrtb_ext.ExtBid{ + Prebid: &openrtb_ext.ExtBidPrebid{ + Type: openrtb_ext.BidTypeVideo, + Video: &openrtb_ext.ExtBidPrebidVideo{}, + }, + }, + AdPod: &openrtb_ext.BidAdPodExt{ + RefBids: make([]string, len(adpod.Bids)), + }, + } + + for i, bid := range adpod.Bids { + //get unique bid id + bidID := bid.ID + if bid.ExtBid.Prebid != nil && bid.ExtBid.Prebid.BidId != "" { + bidID = bid.ExtBid.Prebid.BidId + } + + //adding bid id in adpod.refbids + bidExt.AdPod.RefBids[i] = bidID + + //updating exact duration of adpod creative + bidExt.Prebid.Video.Duration += int(bid.Duration) + + //setting bid status as winning bid + bid.Status = constant.StatusWinningBid + } + rawExt, _ := json.Marshal(bidExt) + return rawExt +} + +// recordRejectedAdPodBids records the bids lost in ad-pod auction using metricsEngine +func (da *dynamicAdpod) recordRejectedAdPodBids(pubID string) { + if da.AdpodBid != nil && len(da.AdpodBid.Bids) > 0 { + for _, bid := range da.AdpodBid.Bids { + if bid.Status != constant.StatusWinningBid { + reason := ConvertAPRCToNBRC(bid.Status) + if reason == nil { + continue + } + rejReason := strconv.FormatInt(int64(*reason), 10) + da.MetricsEngine.RecordRejectedBids(pubID, bid.Seat, rejReason) + } + } + } + +} + +// setBidExtParams function sets the prebid.video.duration and adpod.aprc parameters +func (da *dynamicAdpod) setBidExtParams() { + if da.AdpodBid != nil { + for _, bid := range da.AdpodBid.Bids { + //update adm + //bid.AdM = constant.VASTDefaultTag + + //add duration value + raw, err := jsonparser.Set(bid.Ext, []byte(strconv.Itoa(int(bid.Duration))), "prebid", "video", "duration") + if nil == err { + bid.Ext = raw + } + + //add bid filter reason value + raw, err = jsonparser.Set(bid.Ext, []byte(strconv.FormatInt(bid.Status, 10)), "adpod", "aprc") + if nil == err { + bid.Ext = raw + } + } + } + +} diff --git a/endpoints/openrtb2/ctv/adpod/dynamic_adpod_test.go b/endpoints/openrtb2/ctv/adpod/dynamic_adpod_test.go new file mode 100644 index 00000000000..445b79523ff --- /dev/null +++ b/endpoints/openrtb2/ctv/adpod/dynamic_adpod_test.go @@ -0,0 +1,487 @@ +package adpod + +import ( + "encoding/json" + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/constant" + "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/types" + "github.com/prebid/prebid-server/v2/metrics" + "github.com/prebid/prebid-server/v2/openrtb_ext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestGetDurationBasedOnDurationMatchingPolicy(t *testing.T) { + type args struct { + duration int64 + policy openrtb_ext.OWVideoAdDurationMatchingPolicy + config []*types.ImpAdPodConfig + } + type want struct { + duration int64 + status constant.BidStatus + } + tests := []struct { + name string + args args + want want + }{ + { + name: "empty_duration_policy", + args: args{ + duration: 10, + policy: "", + config: []*types.ImpAdPodConfig{ + {MaxDuration: 10}, + {MaxDuration: 20}, + {MaxDuration: 30}, + {MaxDuration: 40}, + }, + }, + want: want{ + duration: 10, + status: constant.StatusOK, + }, + }, + { + name: "policy_exact", + args: args{ + duration: 10, + policy: openrtb_ext.OWExactVideoAdDurationMatching, + config: []*types.ImpAdPodConfig{ + {MaxDuration: 10}, + {MaxDuration: 20}, + {MaxDuration: 30}, + {MaxDuration: 40}, + }, + }, + want: want{ + duration: 10, + status: constant.StatusOK, + }, + }, + { + name: "policy_exact_didnot_match", + args: args{ + duration: 15, + policy: openrtb_ext.OWExactVideoAdDurationMatching, + config: []*types.ImpAdPodConfig{ + {MaxDuration: 10}, + {MaxDuration: 20}, + {MaxDuration: 30}, + {MaxDuration: 40}, + }, + }, + want: want{ + duration: 15, + status: constant.StatusDurationMismatch, + }, + }, + { + name: "policy_roundup_exact", + args: args{ + duration: 20, + policy: openrtb_ext.OWRoundupVideoAdDurationMatching, + config: []*types.ImpAdPodConfig{ + {MaxDuration: 10}, + {MaxDuration: 20}, + {MaxDuration: 30}, + {MaxDuration: 40}, + }, + }, + want: want{ + duration: 20, + status: constant.StatusOK, + }, + }, + { + name: "policy_roundup", + args: args{ + duration: 25, + policy: openrtb_ext.OWRoundupVideoAdDurationMatching, + config: []*types.ImpAdPodConfig{ + {MaxDuration: 10}, + {MaxDuration: 20}, + {MaxDuration: 30}, + {MaxDuration: 40}, + }, + }, + want: want{ + duration: 30, + status: constant.StatusOK, + }, + }, + { + name: "policy_roundup_didnot_match", + args: args{ + duration: 45, + policy: openrtb_ext.OWRoundupVideoAdDurationMatching, + config: []*types.ImpAdPodConfig{ + {MaxDuration: 10}, + {MaxDuration: 20}, + {MaxDuration: 30}, + {MaxDuration: 40}, + }, + }, + want: want{ + duration: 45, + status: constant.StatusDurationMismatch, + }, + }, + + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + duration, status := getDurationBasedOnDurationMatchingPolicy(tt.args.duration, tt.args.policy, tt.args.config) + assert.Equal(t, tt.want.duration, duration) + assert.Equal(t, tt.want.status, status) + }) + } +} + +func TestGetBidDuration(t *testing.T) { + type args struct { + bid *openrtb2.Bid + reqExt *openrtb_ext.ExtRequestAdPod + config []*types.ImpAdPodConfig + defaultDuration int64 + } + type want struct { + duration int64 + status constant.BidStatus + } + var tests = []struct { + name string + args args + want want + expect int + }{ + { + name: "nil_bid_ext", + args: args{ + bid: &openrtb2.Bid{}, + reqExt: nil, + config: nil, + defaultDuration: 100, + }, + want: want{ + duration: 100, + status: constant.StatusOK, + }, + }, + { + name: "use_default_duration", + args: args{ + bid: &openrtb2.Bid{ + Ext: json.RawMessage(`{"tmp":123}`), + }, + reqExt: nil, + config: nil, + defaultDuration: 100, + }, + want: want{ + duration: 100, + status: constant.StatusOK, + }, + }, + { + name: "invalid_duration_in_bid_ext", + args: args{ + bid: &openrtb2.Bid{ + Ext: json.RawMessage(`{"prebid":{"video":{"duration":"invalid"}}}`), + }, + reqExt: nil, + config: nil, + defaultDuration: 100, + }, + want: want{ + duration: 100, + status: constant.StatusOK, + }, + }, + { + name: "0sec_duration_in_bid_ext", + args: args{ + bid: &openrtb2.Bid{ + Ext: json.RawMessage(`{"prebid":{"video":{"duration":0}}}`), + }, + reqExt: nil, + config: nil, + defaultDuration: 100, + }, + want: want{ + duration: 100, + status: constant.StatusOK, + }, + }, + { + name: "negative_duration_in_bid_ext", + args: args{ + bid: &openrtb2.Bid{ + Ext: json.RawMessage(`{"prebid":{"video":{"duration":-30}}}`), + }, + reqExt: nil, + config: nil, + defaultDuration: 100, + }, + want: want{ + duration: 100, + status: constant.StatusOK, + }, + }, + { + name: "30sec_duration_in_bid_ext", + args: args{ + bid: &openrtb2.Bid{ + Ext: json.RawMessage(`{"prebid":{"video":{"duration":30}}}`), + }, + reqExt: nil, + config: nil, + defaultDuration: 100, + }, + want: want{ + duration: 30, + status: constant.StatusOK, + }, + }, + { + name: "duration_matching_empty", + args: args{ + bid: &openrtb2.Bid{ + Ext: json.RawMessage(`{"prebid":{"video":{"duration":30}}}`), + }, + reqExt: &openrtb_ext.ExtRequestAdPod{ + VideoAdDurationMatching: "", + }, + config: nil, + defaultDuration: 100, + }, + want: want{ + duration: 30, + status: constant.StatusOK, + }, + }, + { + name: "duration_matching_exact", + args: args{ + bid: &openrtb2.Bid{ + Ext: json.RawMessage(`{"prebid":{"video":{"duration":30}}}`), + }, + reqExt: &openrtb_ext.ExtRequestAdPod{ + VideoAdDurationMatching: openrtb_ext.OWExactVideoAdDurationMatching, + }, + config: []*types.ImpAdPodConfig{ + {MaxDuration: 10}, + {MaxDuration: 20}, + {MaxDuration: 30}, + {MaxDuration: 40}, + }, + defaultDuration: 100, + }, + want: want{ + duration: 30, + status: constant.StatusOK, + }, + }, + { + name: "duration_matching_exact_not_present", + args: args{ + bid: &openrtb2.Bid{ + Ext: json.RawMessage(`{"prebid":{"video":{"duration":35}}}`), + }, + reqExt: &openrtb_ext.ExtRequestAdPod{ + VideoAdDurationMatching: openrtb_ext.OWExactVideoAdDurationMatching, + }, + config: []*types.ImpAdPodConfig{ + {MaxDuration: 10}, + {MaxDuration: 20}, + {MaxDuration: 30}, + {MaxDuration: 40}, + }, + defaultDuration: 100, + }, + want: want{ + duration: 35, + status: constant.StatusDurationMismatch, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + duration, status := getBidDuration(tt.args.bid, tt.args.reqExt, tt.args.config, tt.args.defaultDuration) + assert.Equal(t, tt.want.duration, duration) + assert.Equal(t, tt.want.status, status) + }) + } +} + +func TestRecordAdPodRejectedBids(t *testing.T) { + type args struct { + bids types.AdPodBid + } + + type want struct { + expectedCalls int + } + + tests := []struct { + description string + args args + want want + }{ + { + description: "multiple rejected bids", + args: args{ + bids: types.AdPodBid{ + Bids: []*types.Bid{ + { + Bid: &openrtb2.Bid{}, + Status: constant.StatusCategoryExclusion, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{}, + Status: constant.StatusWinningBid, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{}, + Status: constant.StatusOK, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{}, + Status: 100, + Seat: "pubmatic", + }, + }, + }, + }, + want: want{ + expectedCalls: 2, + }, + }, + } + + for _, test := range tests { + me := &metrics.MetricsEngineMock{} + me.On("RecordRejectedBids", mock.Anything, mock.Anything, mock.Anything).Return() + + deps := dynamicAdpod{ + AdpodCtx: AdpodCtx{ + MetricsEngine: me, + }, + AdpodBid: &test.args.bids, + } + deps.recordRejectedAdPodBids("pub_001") + me.AssertNumberOfCalls(t, "RecordRejectedBids", test.want.expectedCalls) + } +} + +func TestSetBidExtParams(t *testing.T) { + type args struct { + adpodBid *types.AdPodBid + } + type want struct { + adpodBid *types.AdPodBid + } + tests := []struct { + name string + args args + want want + }{ + { + name: "sample", + args: args{ + adpodBid: &types.AdPodBid{ + Bids: []*types.Bid{ + { + Bid: &openrtb2.Bid{ + Ext: json.RawMessage(`{"prebid": {"video": {} },"adpod": {}}`), + }, + Duration: 10, + Status: 1, + }, + }, + }, + }, + want: want{ + adpodBid: &types.AdPodBid{ + Bids: []*types.Bid{ + { + Bid: &openrtb2.Bid{ + Ext: json.RawMessage(`{"prebid": {"video": {"duration":10} },"adpod": {"aprc":1}}`), + }, + Duration: 10, + Status: 1, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + adpod := dynamicAdpod{ + AdpodBid: tt.args.adpodBid, + } + + adpod.setBidExtParams() + assert.Equal(t, tt.want.adpodBid.Bids[0].Ext, adpod.AdpodBid.Bids[0].Ext) + }) + } +} + +func TestGetAdPodBidCreative(t *testing.T) { + type args struct { + adpod *types.AdPodBid + generatedBidID bool + } + tests := []struct { + name string + args args + want string + }{ + { + name: "VAST_element_missing_in_adm", + args: args{ + adpod: &types.AdPodBid{ + Bids: []*types.Bid{ + { + Bid: &openrtb2.Bid{ + AdM: "any_creative_without_vast", + }, + }, + }, + }, + generatedBidID: false, + }, + want: "", + }, + { + name: "VAST_element_present_in_adm", + args: args{ + adpod: &types.AdPodBid{ + Bids: []*types.Bid{ + { + Bid: &openrtb2.Bid{ + AdM: "url_creative", + }, + }, + }, + }, + generatedBidID: false, + }, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getAdPodBidCreative(tt.args.adpod, tt.args.generatedBidID) + assert.Equalf(t, tt.want, *got, "found incorrect creative") + }) + } +} diff --git a/endpoints/openrtb2/ctv/adpod/structured_adpod.go b/endpoints/openrtb2/ctv/adpod/structured_adpod.go new file mode 100644 index 00000000000..1c4cf16e735 --- /dev/null +++ b/endpoints/openrtb2/ctv/adpod/structured_adpod.go @@ -0,0 +1,379 @@ +package adpod + +import ( + "encoding/json" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/types" + "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/util" + "github.com/prebid/prebid-server/v2/metrics" + "github.com/prebid/prebid-server/v2/openrtb_ext" +) + +type structuredAdpod struct { + AdpodCtx + ImpBidMap map[string][]*types.Bid + WinningBid map[string]types.Bid + SelectedCategories map[string]bool + SelectedDomains map[string]bool +} + +type Slot struct { + ImpId string + Index int + TotalBids int +} + +func NewStructuredAdpod(pubId string, metricsEngine metrics.MetricsEngine, reqAdpodExt *openrtb_ext.ExtRequestAdPod) *structuredAdpod { + adpod := structuredAdpod{ + AdpodCtx: AdpodCtx{ + PubId: pubId, + Type: Structured, + ReqAdpodExt: reqAdpodExt, + MetricsEngine: metricsEngine, + }, + ImpBidMap: make(map[string][]*types.Bid), + WinningBid: make(map[string]types.Bid), + } + + return &adpod +} + +func (da *structuredAdpod) GetPodType() PodType { + return da.Type +} + +func (sa *structuredAdpod) AddImpressions(imp openrtb2.Imp) { + sa.Imps = append(sa.Imps, imp) +} + +func (sa *structuredAdpod) GetImpressions() []openrtb2.Imp { + return sa.Imps +} + +func (sa *structuredAdpod) CollectBid(bid *openrtb2.Bid, seat string) { + ext := openrtb_ext.ExtBid{} + if bid.Ext != nil { + json.Unmarshal(bid.Ext, &ext) + } + + adpodBid := types.Bid{ + Bid: bid, + ExtBid: ext, + DealTierSatisfied: util.GetDealTierSatisfied(&ext), + Seat: seat, + } + bids := sa.ImpBidMap[bid.ImpID] + + bids = append(bids, &adpodBid) + sa.ImpBidMap[bid.ImpID] = bids +} + +func (sa *structuredAdpod) HoldAuction() { + if len(sa.ImpBidMap) == 0 { + return + } + + // Sort Bids impression wise + for _, bids := range sa.ImpBidMap { + util.SortBids(bids) + } + + // Create Slots + slots := make([]Slot, 0) + + for impId, bids := range sa.ImpBidMap { + slot := Slot{ + ImpId: impId, + Index: 0, + TotalBids: len(bids), + } + slots = append(slots, slot) + } + + sa.selectBidForSlot(slots) + + // Select Winning bids + for i := range slots { + bids := sa.ImpBidMap[slots[i].ImpId] + // Add validations on len of array and index chosen + sa.WinningBid[slots[i].ImpId] = *bids[slots[i].Index] + } + +} + +func (sa *structuredAdpod) Validate() []error { + return nil +} + +func (sa *structuredAdpod) GetAdpodSeatBids() []openrtb2.SeatBid { + if len(sa.WinningBid) == 0 { + return nil + } + + var seatBid []openrtb2.SeatBid + for _, bid := range sa.WinningBid { + adpodSeat := openrtb2.SeatBid{ + Bid: []openrtb2.Bid{*bid.Bid}, + Seat: bid.Seat, + } + seatBid = append(seatBid, adpodSeat) + } + + return seatBid +} + +func (sa *structuredAdpod) GetAdpodExtension(blockedVastTagID map[string]map[string][]string) *types.ImpData { + return nil +} + +/****************************Exclusion*******************************/ + +func (sa *structuredAdpod) addCategories(categories []string) { + if sa.SelectedCategories == nil { + sa.SelectedCategories = make(map[string]bool) + } + + for _, cat := range categories { + sa.SelectedCategories[cat] = true + } +} + +func (sa *structuredAdpod) addDomains(domains []string) { + if sa.SelectedDomains == nil { + sa.SelectedDomains = make(map[string]bool) + } + + for _, domain := range domains { + sa.SelectedDomains[domain] = true + } +} + +func (sa *structuredAdpod) isCategoryAlreadySelected(bid *types.Bid) bool { + if bid == nil || bid.Cat == nil { + return false + } + + if sa.SelectedCategories == nil { + return false + } + + for i := range bid.Cat { + if _, ok := sa.SelectedCategories[bid.Cat[i]]; ok { + return true + } + } + + return false +} + +func (sa *structuredAdpod) isDomainAlreadySelected(bid *types.Bid) bool { + if bid == nil || bid.ADomain == nil { + return false + } + + if sa.SelectedDomains == nil { + return false + } + + for i := range bid.ADomain { + if _, ok := sa.SelectedDomains[bid.ADomain[i]]; ok { + return true + } + } + + return false +} + +func (sa *structuredAdpod) isCatOverlap(cats []string, catMap map[string]bool) bool { + if !sa.Exclusion.IABCategoryExclusion { + return false + } + + return isAtrributesOverlap(cats, catMap) +} + +func (sa *structuredAdpod) isDomainOverlap(domains []string, domainMap map[string]bool) bool { + if !sa.Exclusion.AdvertiserDomainExclusion { + return false + } + + return isAtrributesOverlap(domains, domainMap) +} + +func isAtrributesOverlap(attributes []string, checkMap map[string]bool) bool { + for _, item := range attributes { + if _, ok := checkMap[item]; ok { + return true + } + } + return false +} + +/*******************Structured Adpod Auction Methods***********************/ + +func isDealBid(bid *types.Bid) bool { + return bid.DealTierSatisfied +} + +func (sa *structuredAdpod) isOverlap(bid *types.Bid, catMap map[string]bool, domainMap map[string]bool) bool { + return sa.isCatOverlap(bid.Cat, catMap) || sa.isDomainOverlap(bid.ADomain, domainMap) +} + +func (sa *structuredAdpod) selectBidForSlot(slots []Slot) { + if len(slots) == 0 { + return + } + + slotIndex := sa.getSlotIndexWithHighestBid(slots) + + // Get current bid for selected slot + selectedSlot := slots[slotIndex] + slotBids := sa.ImpBidMap[selectedSlot.ImpId] + selectedBid := slotBids[selectedSlot.Index] + + if sa.Exclusion.shouldApplyExclusion() { + if bidIndex, ok := sa.isBetterBidThanDeal(slots, slotIndex, selectedSlot, selectedBid); ok { + selectedSlot.Index = bidIndex + slots[slotIndex] = selectedSlot + } else if sa.isCategoryAlreadySelected(selectedBid) || sa.isDomainAlreadySelected(selectedBid) { + // Get bid for current slot for which category is not overlapping + for i := selectedSlot.Index + 1; i < len(slotBids); i++ { + if !sa.isCategoryAlreadySelected(slotBids[i]) && !sa.isDomainAlreadySelected(slotBids[i]) { + selectedSlot.Index = i + break + } + } + + // Update selected Slot in slots array + slots[slotIndex] = selectedSlot + } + } + + // Add bid categories to selected categories + sa.addCategories(slotBids[selectedSlot.Index].Cat) + sa.addDomains(slotBids[selectedSlot.Index].ADomain) + + // Swap selected slot at initial position + slots[0], slots[slotIndex] = slots[slotIndex], slots[0] + + sa.selectBidForSlot(slots[1:]) +} + +func (sa *structuredAdpod) getSlotIndexWithHighestBid(slots []Slot) int { + var index int + maxBid := &types.Bid{ + Bid: &openrtb2.Bid{}, + } + + for i := range slots { + impBids := sa.ImpBidMap[slots[i].ImpId] + bid := impBids[slots[i].Index] + + if bid.DealTierSatisfied == maxBid.DealTierSatisfied { + if bid.Price > maxBid.Price { + maxBid = bid + index = i + } + } else if bid.DealTierSatisfied { + maxBid = bid + index = i + } + } + + return index +} + +// isBetterBidAvailable checks if a better bid is available for the selected slot. +// It returns true if +func (sa *structuredAdpod) isBetterBidAvailable(slots []Slot, selectedSlotIndex int, selectedBid *types.Bid) bool { + if len(selectedBid.Cat) == 0 && len(selectedBid.ADomain) == 0 { + return false + } + + catMap := createMapFromSlice(selectedBid.Cat) + domainMap := createMapFromSlice(selectedBid.ADomain) + + return sa.shouldUpdateSelectedBid(slots, selectedSlotIndex, catMap, domainMap) +} + +// shouldUpdateSelectedBid checks if a bid should be updated for a selected slot. +func (sa *structuredAdpod) shouldUpdateSelectedBid(slots []Slot, selectedSlotIndex int, catMap map[string]bool, domainMap map[string]bool) bool { + for i := range slots { + if selectedSlotIndex == i { + continue + } + slotBids := sa.ImpBidMap[slots[i].ImpId] + slotIndex := slots[i].Index + + // Get bid for current slot + bid := slotBids[slotIndex] + + if bid.DealTierSatisfied && sa.isOverlap(bid, catMap, domainMap) { + return sa.shouldUpdateBid(slotBids, slotIndex, catMap, domainMap) + } + } + return false +} + +// shouldUpdateBid checks if a bid should be updated for a selected slot. +// It iterates through the remaining slot bids of overlapped slot starting from the given slot index, +// and checks exclusions conditions for only deal bids. +// It will ensure more deal bids in final adpod. +func (sa *structuredAdpod) shouldUpdateBid(slotBids []*types.Bid, slotIndex int, catMap map[string]bool, domainMap map[string]bool) bool { + for i := slotIndex + 1; i < len(slotBids); i++ { + bid := slotBids[i] + + if !bid.DealTierSatisfied { + break + } + + if !sa.isOverlap(bid, catMap, domainMap) { + return false + } + } + return true +} + +func (sa *structuredAdpod) getBetterBid(slotBids []*types.Bid, selectedBid *types.Bid, selectedBidtIndex int) (int, bool) { + catMap := createMapFromSlice(selectedBid.Cat) + domainMap := createMapFromSlice(selectedBid.ADomain) + + for i := selectedBidtIndex + 1; i < len(slotBids); i++ { + bid := slotBids[i] + + // Check for deal bid and select if exclusion conditions are satisfied + if isDealBid(bid) { + if !sa.isOverlap(bid, catMap, domainMap) { + return i, true + } + continue + } + + // New selected bid exclusion parameters should not be overlaped + if sa.isOverlap(bid, catMap, domainMap) { + continue + } + + // Check for bid price is greater than deal price + if bid.Price > selectedBid.Price { + return i, true + } + } + + return selectedBidtIndex, false +} + +func (sa *structuredAdpod) isBetterBidThanDeal(slots []Slot, selectedSlotIndx int, selectedSlot Slot, selectedBid *types.Bid) (int, bool) { + selectedBidIndex := selectedSlot.Index + + if !isDealBid(selectedBid) { + return selectedBidIndex, false + } + + if !sa.isBetterBidAvailable(slots, selectedSlotIndx, selectedBid) { + return selectedBidIndex, false + } + + return sa.getBetterBid(sa.ImpBidMap[selectedSlot.ImpId], selectedBid, selectedBidIndex) +} diff --git a/endpoints/openrtb2/ctv/adpod/structured_adpod_test.go b/endpoints/openrtb2/ctv/adpod/structured_adpod_test.go new file mode 100644 index 00000000000..70b3ceee8d0 --- /dev/null +++ b/endpoints/openrtb2/ctv/adpod/structured_adpod_test.go @@ -0,0 +1,862 @@ +package adpod + +import ( + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/types" + "github.com/stretchr/testify/assert" +) + +func TestStructuredAdpodPerformAuctionAndExclusion(t *testing.T) { + type fields struct { + AdpodCtx AdpodCtx + ImpBidMap map[string][]*types.Bid + WinningBid map[string]types.Bid + } + tests := []struct { + name string + fields fields + wantWinningBid map[string]types.Bid + }{ + { + name: "only_price_based_auction_with_no_exclusion", + fields: fields{ + AdpodCtx: AdpodCtx{ + Type: Structured, + }, + ImpBidMap: map[string][]*types.Bid{ + "imp1": { + { + Bid: &openrtb2.Bid{ + Price: 2, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 5, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 1, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + }, + "imp2": { + { + Bid: &openrtb2.Bid{ + Price: 4, + }, + DealTierSatisfied: false, + Seat: "index", + }, + { + Bid: &openrtb2.Bid{ + Price: 6, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + }, + "imp3": { + { + Bid: &openrtb2.Bid{ + Price: 10, + }, + DealTierSatisfied: false, + Seat: "god", + }, + }, + }, + WinningBid: make(map[string]types.Bid), + }, + wantWinningBid: map[string]types.Bid{ + "imp1": { + Bid: &openrtb2.Bid{ + Price: 5, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + "imp2": { + Bid: &openrtb2.Bid{ + Price: 6, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + "imp3": { + Bid: &openrtb2.Bid{ + Price: 10, + }, + DealTierSatisfied: false, + Seat: "god", + }, + }, + }, + { + name: "only_price_based_auction_with_exclusion", + fields: fields{ + AdpodCtx: AdpodCtx{ + Type: Structured, + Exclusion: Exclusion{ + IABCategoryExclusion: true, + AdvertiserDomainExclusion: true, + }, + }, + ImpBidMap: map[string][]*types.Bid{ + "imp1": { + { + Bid: &openrtb2.Bid{ + Price: 6, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 1, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + }, + "imp2": { + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "index", + }, + { + Bid: &openrtb2.Bid{ + Price: 3, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + }, + "imp3": { + { + Bid: &openrtb2.Bid{ + Price: 10, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: false, + Seat: "god", + }, + { + Bid: &openrtb2.Bid{ + Price: 1, + }, + DealTierSatisfied: false, + Seat: "god", + }, + }, + }, + WinningBid: make(map[string]types.Bid), + }, + wantWinningBid: map[string]types.Bid{ + "imp1": { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + "imp2": { + Bid: &openrtb2.Bid{ + Price: 3, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + "imp3": { + Bid: &openrtb2.Bid{ + Price: 10, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: false, + Seat: "god", + }, + }, + }, + { + name: "price_and_deal_based_auction_with_no_exclusion", + fields: fields{ + AdpodCtx: AdpodCtx{ + Type: Structured, + }, + ImpBidMap: map[string][]*types.Bid{ + "imp1": { + { + Bid: &openrtb2.Bid{ + Price: 6, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 1, + }, + DealTierSatisfied: true, + Seat: "pubmatic", + }, + }, + "imp2": { + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "index", + }, + { + Bid: &openrtb2.Bid{ + Price: 3, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + }, + "imp3": { + { + Bid: &openrtb2.Bid{ + Price: 10, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "god", + }, + { + Bid: &openrtb2.Bid{ + Price: 1, + }, + DealTierSatisfied: false, + Seat: "god", + }, + }, + }, + WinningBid: make(map[string]types.Bid), + }, + wantWinningBid: map[string]types.Bid{ + "imp1": { + Bid: &openrtb2.Bid{ + Price: 1, + }, + DealTierSatisfied: true, + Seat: "pubmatic", + }, + "imp2": { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "index", + }, + "imp3": { + Bid: &openrtb2.Bid{ + Price: 10, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "god", + }, + }, + }, + { + name: "price_and_deal_based_auction_with_exclusion", + fields: fields{ + AdpodCtx: AdpodCtx{ + Type: Structured, + Exclusion: Exclusion{ + IABCategoryExclusion: true, + AdvertiserDomainExclusion: true, + }, + }, + ImpBidMap: map[string][]*types.Bid{ + "imp1": { + { + Bid: &openrtb2.Bid{ + Price: 6, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 1, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + }, + "imp2": { + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "index", + }, + { + Bid: &openrtb2.Bid{ + Price: 3, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + }, + "imp3": { + { + Bid: &openrtb2.Bid{ + Price: 10, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "god", + }, + { + Bid: &openrtb2.Bid{ + Price: 1, + }, + DealTierSatisfied: false, + Seat: "god", + }, + }, + }, + WinningBid: make(map[string]types.Bid), + }, + wantWinningBid: map[string]types.Bid{ + "imp1": { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + "imp2": { + Bid: &openrtb2.Bid{ + Price: 3, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + "imp3": { + Bid: &openrtb2.Bid{ + Price: 10, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "god", + }, + }, + }, + { + name: "price_and_deal_based_auction_with_exclusion_better_price_1", + fields: fields{ + AdpodCtx: AdpodCtx{ + Type: Structured, + Exclusion: Exclusion{ + IABCategoryExclusion: true, + AdvertiserDomainExclusion: true, + }, + }, + ImpBidMap: map[string][]*types.Bid{ + "imp1": { + { + Bid: &openrtb2.Bid{ + Price: 6, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 1, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + }, + "imp2": { + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "index", + }, + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 3, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + }, + "imp3": { + { + Bid: &openrtb2.Bid{ + Price: 8, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "god", + }, + { + Bid: &openrtb2.Bid{ + Price: 9, + }, + DealTierSatisfied: false, + Seat: "god", + }, + }, + }, + WinningBid: make(map[string]types.Bid), + }, + wantWinningBid: map[string]types.Bid{ + "imp1": { + Bid: &openrtb2.Bid{ + Price: 6, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "pubmatic", + }, + "imp2": { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + "imp3": { + Bid: &openrtb2.Bid{ + Price: 9, + }, + DealTierSatisfied: false, + Seat: "god", + }, + }, + }, + { + name: "price_and_deal_based_auction_with_exclusion_better_price_2", + fields: fields{ + AdpodCtx: AdpodCtx{ + Type: Structured, + Exclusion: Exclusion{ + IABCategoryExclusion: true, + AdvertiserDomainExclusion: true, + }, + }, + ImpBidMap: map[string][]*types.Bid{ + "imp1": { + { + Bid: &openrtb2.Bid{ + Price: 6, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 1, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + }, + "imp2": { + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "index", + }, + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 3, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + }, + "imp3": { + { + Bid: &openrtb2.Bid{ + Price: 8, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "god", + }, + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "god", + }, + { + Bid: &openrtb2.Bid{ + Price: 9, + }, + DealTierSatisfied: false, + Seat: "god", + }, + }, + }, + WinningBid: make(map[string]types.Bid), + }, + wantWinningBid: map[string]types.Bid{ + "imp1": { + Bid: &openrtb2.Bid{ + Price: 6, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "pubmatic", + }, + "imp2": { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + "imp3": { + Bid: &openrtb2.Bid{ + Price: 9, + }, + DealTierSatisfied: false, + Seat: "god", + }, + }, + }, + { + name: "price_and_deal_based_auction_with_exclusion_better_price_3", + fields: fields{ + AdpodCtx: AdpodCtx{ + Type: Structured, + Exclusion: Exclusion{ + IABCategoryExclusion: true, + AdvertiserDomainExclusion: true, + }, + }, + ImpBidMap: map[string][]*types.Bid{ + "imp1": { + { + Bid: &openrtb2.Bid{ + Price: 6, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 1, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + }, + "imp2": { + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "index", + }, + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 3, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + }, + "imp3": { + { + Bid: &openrtb2.Bid{ + Price: 8, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "god", + }, + { + Bid: &openrtb2.Bid{ + Price: 9, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: false, + Seat: "god", + }, + { + Bid: &openrtb2.Bid{ + Price: 4, + }, + DealTierSatisfied: false, + Seat: "god", + }, + }, + }, + WinningBid: make(map[string]types.Bid), + }, + wantWinningBid: map[string]types.Bid{ + "imp1": { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + "imp2": { + Bid: &openrtb2.Bid{ + Price: 3, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + "imp3": { + Bid: &openrtb2.Bid{ + Price: 8, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "god", + }, + }, + }, + { + name: "price_and_deal_based_auction_with_exclusion_better_price_4", + fields: fields{ + AdpodCtx: AdpodCtx{ + Type: Structured, + Exclusion: Exclusion{ + IABCategoryExclusion: true, + AdvertiserDomainExclusion: true, + }, + }, + ImpBidMap: map[string][]*types.Bid{ + "imp1": { + { + Bid: &openrtb2.Bid{ + Price: 6, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 1, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + }, + "imp2": { + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "index", + }, + { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + { + Bid: &openrtb2.Bid{ + Price: 3, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + }, + "imp3": { + { + Bid: &openrtb2.Bid{ + Price: 8, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "god", + }, + { + Bid: &openrtb2.Bid{ + Price: 10, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: false, + Seat: "god", + }, + { + Bid: &openrtb2.Bid{ + Price: 9, + }, + DealTierSatisfied: false, + Seat: "god", + }, + }, + }, + WinningBid: make(map[string]types.Bid), + }, + wantWinningBid: map[string]types.Bid{ + "imp1": { + Bid: &openrtb2.Bid{ + Price: 6, + Cat: []string{"IAB-1"}, + }, + DealTierSatisfied: true, + Seat: "pubmatic", + }, + "imp2": { + Bid: &openrtb2.Bid{ + Price: 4, + Cat: []string{"IAB-2"}, + }, + DealTierSatisfied: false, + Seat: "pubmatic", + }, + "imp3": { + Bid: &openrtb2.Bid{ + Price: 9, + }, + DealTierSatisfied: false, + Seat: "god", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sa := &structuredAdpod{ + AdpodCtx: tt.fields.AdpodCtx, + ImpBidMap: tt.fields.ImpBidMap, + WinningBid: tt.fields.WinningBid, + } + sa.HoldAuction() + + assert.Equal(t, sa.WinningBid, tt.wantWinningBid, "Auction failed") + }) + } +} diff --git a/endpoints/openrtb2/ctv/adpod/util.go b/endpoints/openrtb2/ctv/adpod/util.go new file mode 100644 index 00000000000..5945be9f642 --- /dev/null +++ b/endpoints/openrtb2/ctv/adpod/util.go @@ -0,0 +1,85 @@ +package adpod + +import ( + "errors" + "strconv" + + "github.com/buger/jsonparser" + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/openrtb/v20/openrtb3" + "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/constant" + "github.com/prebid/prebid-server/v2/exchange" + "github.com/prebid/prebid-server/v2/modules/pubmatic/openwrap/models/nbr" + "github.com/prebid/prebid-server/v2/openrtb_ext" +) + +func AddTargetingKey(bid *openrtb2.Bid, key openrtb_ext.TargetingKey, value string) error { + if nil == bid { + return errors.New("Invalid bid") + } + + raw, err := jsonparser.Set(bid.Ext, []byte(strconv.Quote(value)), "prebid", "targeting", string(key)) + if nil == err { + bid.Ext = raw + } + return err +} + +// ConvertAPRCToNBRC converts the aprc to NonBidStatusCode +func ConvertAPRCToNBRC(bidStatus int64) *openrtb3.NoBidReason { + var nbrCode openrtb3.NoBidReason + + switch bidStatus { + case constant.StatusOK: + nbrCode = nbr.LossBidLostToHigherBid + case constant.StatusCategoryExclusion: + nbrCode = exchange.ResponseRejectedCreativeCategoryExclusions + case constant.StatusDomainExclusion: + nbrCode = exchange.ResponseRejectedCreativeAdvertiserExclusions + case constant.StatusDurationMismatch: + nbrCode = exchange.ResponseRejectedInvalidCreative + + default: + return nil + } + return &nbrCode +} + +func GetPodType(imp openrtb2.Imp, extAdpod openrtb_ext.ExtVideoAdPod) PodType { + if extAdpod.AdPod != nil { + return Dynamic + } + + if len(imp.Video.PodID) > 0 && imp.Video.PodDur > 0 { + return Dynamic + } + + if len(imp.Video.PodID) > 0 { + return Structured + } + + return NotAdpod +} + +func ConvertToV25VideoRequest(request *openrtb2.BidRequest) { + for i := range request.Imp { + imp := request.Imp[i] + + if imp.Video == nil { + continue + } + + // Remove 2.6 Adpod parameters + imp.Video.PodID = "" + imp.Video.PodDur = 0 + imp.Video.MaxSeq = 0 + } +} + +func createMapFromSlice(slice []string) map[string]bool { + resultMap := make(map[string]bool) + for _, item := range slice { + resultMap[item] = true + } + return resultMap +} diff --git a/endpoints/openrtb2/ctv/adpod/util_test.go b/endpoints/openrtb2/ctv/adpod/util_test.go new file mode 100644 index 00000000000..57855b38461 --- /dev/null +++ b/endpoints/openrtb2/ctv/adpod/util_test.go @@ -0,0 +1,85 @@ +package adpod + +import ( + "encoding/json" + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v2/openrtb_ext" + "github.com/stretchr/testify/assert" +) + +func TestAddTargetingKeys(t *testing.T) { + var tests = []struct { + scenario string // Testcase scenario + key string + value string + bidExt string + expect map[string]string + }{ + {scenario: "key_not_exists", key: "hb_pb_cat_dur", value: "some_value", bidExt: `{"prebid":{"targeting":{}}}`, expect: map[string]string{"hb_pb_cat_dur": "some_value"}}, + {scenario: "key_already_exists", key: "hb_pb_cat_dur", value: "new_value", bidExt: `{"prebid":{"targeting":{"hb_pb_cat_dur":"old_value"}}}`, expect: map[string]string{"hb_pb_cat_dur": "new_value"}}, + } + for _, test := range tests { + t.Run(test.scenario, func(t *testing.T) { + bid := new(openrtb2.Bid) + bid.Ext = []byte(test.bidExt) + key := openrtb_ext.TargetingKey(test.key) + assert.Nil(t, AddTargetingKey(bid, key, test.value)) + extBid := openrtb_ext.ExtBid{} + json.Unmarshal(bid.Ext, &extBid) + assert.Equal(t, test.expect, extBid.Prebid.Targeting) + }) + } + assert.Equal(t, "Invalid bid", AddTargetingKey(nil, openrtb_ext.HbCategoryDurationKey, "some value").Error()) +} + +func TestConvertToV25VideoRequest(t *testing.T) { + type args struct { + request *openrtb2.BidRequest + } + tests := []struct { + name string + args args + want *openrtb2.BidRequest + }{ + { + name: "Remove adpod parameters", + args: args{ + request: &openrtb2.BidRequest{ + ID: "request", + Imp: []openrtb2.Imp{ + { + ID: "imp1", + Video: &openrtb2.Video{ + MinDuration: 10, + MaxDuration: 30, + PodDur: 90, + PodID: "pod1", + MaxSeq: 3, + }, + }, + }, + }, + }, + want: &openrtb2.BidRequest{ + ID: "request", + Imp: []openrtb2.Imp{ + { + ID: "imp1", + Video: &openrtb2.Video{ + MinDuration: 10, + MaxDuration: 30, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ConvertToV25VideoRequest(tt.args.request) + assert.Equal(t, tt.want, tt.args.request, "Failed to remove adpod paramaters") + }) + } +} diff --git a/endpoints/openrtb2/ctv/response/adpod_generator.go b/endpoints/openrtb2/ctv/response/adpod_generator.go index 67301776936..c63817104c2 100644 --- a/endpoints/openrtb2/ctv/response/adpod_generator.go +++ b/endpoints/openrtb2/ctv/response/adpod_generator.go @@ -1,11 +1,9 @@ package response import ( - "fmt" "sync" "time" - "github.com/prebid/openrtb/v20/openrtb2" "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/combination" "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/constant" "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/types" @@ -40,30 +38,24 @@ type highestCombination struct { // AdPodGenerator AdPodGenerator type AdPodGenerator struct { IAdPodGenerator - request *openrtb2.BidRequest - impIndex int - buckets types.BidsBuckets - comb combination.ICombination - adpod *openrtb_ext.VideoAdPod - met metrics.MetricsEngine + buckets types.BidsBuckets + comb combination.ICombination + adpod *openrtb_ext.VideoAdPod + met metrics.MetricsEngine } // NewAdPodGenerator will generate adpod based on configuration -func NewAdPodGenerator(request *openrtb2.BidRequest, impIndex int, buckets types.BidsBuckets, comb combination.ICombination, adpod *openrtb_ext.VideoAdPod, met metrics.MetricsEngine) *AdPodGenerator { +func NewAdPodGenerator(buckets types.BidsBuckets, comb combination.ICombination, adpod *openrtb_ext.VideoAdPod, met metrics.MetricsEngine) *AdPodGenerator { return &AdPodGenerator{ - request: request, - impIndex: impIndex, - buckets: buckets, - comb: comb, - adpod: adpod, - met: met, + buckets: buckets, + comb: comb, + adpod: adpod, + met: met, } } // GetAdPodBids will return Adpod based on configurations func (o *AdPodGenerator) GetAdPodBids() *types.AdPodBid { - defer util.TimeTrack(time.Now(), fmt.Sprintf("Tid:%v ImpId:%v adpodgenerator", o.request.ID, o.request.Imp[o.impIndex].ID)) - results := o.getAdPodBids(10 * time.Millisecond) adpodBid := o.getMaxAdPodBid(results) @@ -74,8 +66,8 @@ func (o *AdPodGenerator) cleanup(wg *sync.WaitGroup, responseCh chan *highestCom defer func() { close(responseCh) for extra := range responseCh { - if nil != extra { - util.Logf("Tid:%v ImpId:%v Delayed Response Durations:%v Bids:%v", o.request.ID, o.request.Imp[o.impIndex].ID, extra.durations, extra.bidIDs) + if extra != nil { + util.Logf("Delayed Response Durations:%v Bids:%v", extra.durations, extra.bidIDs) } } }() @@ -83,9 +75,6 @@ func (o *AdPodGenerator) cleanup(wg *sync.WaitGroup, responseCh chan *highestCom } func (o *AdPodGenerator) getAdPodBids(timeout time.Duration) []*highestCombination { - start := time.Now() - defer util.TimeTrack(start, fmt.Sprintf("Tid:%v ImpId:%v getAdPodBids", o.request.ID, o.request.Imp[o.impIndex].ID)) - maxRoutines := 2 isTimedOutORReceivedAllResponses := false results := []*highestCombination{} @@ -103,7 +92,6 @@ func (o *AdPodGenerator) getAdPodBids(timeout time.Duration) []*highestCombinati hbc := o.getUniqueBids(durations) hbc.timeTakenCombGen = combGenElapsedTime responseCh <- hbc - util.Logf("Tid:%v GetUniqueBids Durations:%v Price:%v DealBids:%v Time:%v Bids:%v combGenElapsedTime:%v", o.request.ID, hbc.durations[:], hbc.price, hbc.nDealBids, hbc.timeTakenCompExcl, hbc.bidIDs[:], combGenElapsedTime) } combinationCount := 0 for i := 0; i < maxRoutines; i++ { @@ -122,7 +110,7 @@ func (o *AdPodGenerator) getAdPodBids(timeout time.Duration) []*highestCombinati hbc := o.getUniqueBids(durations) hbc.timeTakenCombGen = combGenElapsedTime responseCh <- hbc - util.Logf("Tid:%v GetUniqueBids Durations:%v Price:%v DealBids:%v Time:%v Bids:%v combGenElapsedTime:%v", o.request.ID, hbc.durations[:], hbc.price, hbc.nDealBids, hbc.timeTakenCompExcl, hbc.bidIDs[:], combGenElapsedTime) + util.Logf("GetUniqueBids Durations:%v Price:%v DealBids:%v Time:%v Bids:%v combGenElapsedTime:%v", hbc.durations[:], hbc.price, hbc.nDealBids, hbc.timeTakenCompExcl, hbc.bidIDs[:], combGenElapsedTime) } wg.Done() }() @@ -138,7 +126,7 @@ func (o *AdPodGenerator) getAdPodBids(timeout time.Duration) []*highestCombinati select { case hbc, ok := <-responseCh: - if false == ok { + if !ok { isTimedOutORReceivedAllResponses = true break } @@ -150,7 +138,6 @@ func (o *AdPodGenerator) getAdPodBids(timeout time.Duration) []*highestCombinati } case <-ticker.C: isTimedOutORReceivedAllResponses = true - util.Logf("Tid:%v ImpId:%v GetAdPodBids Timeout Reached %v", o.request.ID, o.request.Imp[o.impIndex].ID, timeout) } } @@ -177,8 +164,7 @@ func (o *AdPodGenerator) getAdPodBids(timeout time.Duration) []*highestCombinati } func (o *AdPodGenerator) getMaxAdPodBid(results []*highestCombination) *types.AdPodBid { - if 0 == len(results) { - util.Logf("Tid:%v ImpId:%v NoBid", o.request.ID, o.request.Imp[o.impIndex].ID) + if len(results) == 0 { return nil } @@ -201,8 +187,7 @@ func (o *AdPodGenerator) getMaxAdPodBid(results []*highestCombination) *types.Ad } } - if nil == maxResult { - util.Logf("Tid:%v ImpId:%v All Combination Filtered in Ad Exclusion", o.request.ID, o.request.Imp[o.impIndex].ID) + if maxResult == nil { return nil } @@ -223,7 +208,7 @@ func (o *AdPodGenerator) getMaxAdPodBid(results []*highestCombination) *types.Ad adpodBid.Cat = append(adpodBid.Cat, cat) } - util.Logf("Tid:%v ImpId:%v Selected Durations:%v Price:%v Bids:%v", o.request.ID, o.request.Imp[o.impIndex].ID, maxResult.durations[:], maxResult.price, maxResult.bidIDs[:]) + util.Logf("Selected Durations:%v Price:%v Bids:%v", maxResult.durations, maxResult.price, maxResult.bidIDs) return adpodBid } @@ -233,11 +218,9 @@ func (o *AdPodGenerator) getUniqueBids(durationSequence []int) *highestCombinati data := [][]*types.Bid{} combinations := []int{} - defer util.TimeTrack(startTime, fmt.Sprintf("Tid:%v ImpId:%v getUniqueBids:%v", o.request.ID, o.request.Imp[o.impIndex].ID, durationSequence)) - uniqueDuration := 0 for index, duration := range durationSequence { - if 0 != index && durationSequence[index-1] == duration { + if index != 0 && durationSequence[index-1] == duration { combinations[uniqueDuration-1]++ continue } @@ -274,7 +257,7 @@ func findUniqueCombinations(data [][]*types.Bid, combination []int, maxCategoryS filterBids := map[string]*filteredBid{} // maintain highest price combination - for true { + for { ehc, inext, jnext, rc = evaluate(data[:], indices[:], totalBids, maxCategoryScore, maxDomainScore) if nil != ehc { @@ -296,7 +279,7 @@ func findUniqueCombinations(data [][]*types.Bid, combination []int, maxCategoryS } } - if -1 == inext { + if inext == -1 { inext, jnext = n-1, 0 } diff --git a/endpoints/openrtb2/ctv/response/adpod_generator_test.go b/endpoints/openrtb2/ctv/response/adpod_generator_test.go index 9cbf3619dee..7944d58acea 100644 --- a/endpoints/openrtb2/ctv/response/adpod_generator_test.go +++ b/endpoints/openrtb2/ctv/response/adpod_generator_test.go @@ -378,14 +378,10 @@ func TestAdPodGenerator_getMaxAdPodBid(t *testing.T) { Price: 10, }, }, - // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - o := &AdPodGenerator{ - request: tt.fields.request, - impIndex: tt.fields.impIndex, - } + o := &AdPodGenerator{} got := o.getMaxAdPodBid(tt.args.results) if nil != got { sort.Strings(got.ADomain) diff --git a/endpoints/openrtb2/ctv/util/util.go b/endpoints/openrtb2/ctv/util/util.go index 1c0aac8523b..49f28178dbb 100644 --- a/endpoints/openrtb2/ctv/util/util.go +++ b/endpoints/openrtb2/ctv/util/util.go @@ -38,14 +38,14 @@ func GetDurationWiseBidsBucket(bids []*types.Bid) types.BidsBuckets { for k, v := range result { //sort.Slice(v[:], func(i, j int) bool { return v[i].Price > v[j].Price }) - sortBids(v[:]) + SortBids(v[:]) result[k] = v } return result } -func sortBids(bids []*types.Bid) { +func SortBids(bids []*types.Bid) { sort.Slice(bids, func(i, j int) bool { if bids[i].DealTierSatisfied == bids[j].DealTierSatisfied { return bids[i].Price > bids[j].Price diff --git a/endpoints/openrtb2/ctv/util/util_test.go b/endpoints/openrtb2/ctv/util/util_test.go index 136f1b3c9a1..d96cd451c65 100644 --- a/endpoints/openrtb2/ctv/util/util_test.go +++ b/endpoints/openrtb2/ctv/util/util_test.go @@ -164,8 +164,8 @@ func TestSortByDealPriority(t *testing.T) { for _, bid := range bids { fmt.Println(bid.ID, ",", bid.Price, ",", bid.DealTierSatisfied) } - sortBids(bids[:]) - fmt.Println("After sort") + SortBids(bids[:]) + actual := []string{} for _, bid := range bids { fmt.Println(bid.ID, ",", bid.Price, ", ", bid.DealTierSatisfied) diff --git a/endpoints/openrtb2/ctv_auction.go b/endpoints/openrtb2/ctv_auction.go index b4ac7212e30..91cb1be54ff 100644 --- a/endpoints/openrtb2/ctv_auction.go +++ b/endpoints/openrtb2/ctv_auction.go @@ -5,29 +5,19 @@ import ( "encoding/json" "errors" "fmt" - "math" "net/http" - "net/url" - "sort" - "strconv" "strings" "time" - "github.com/beevik/etree" "github.com/buger/jsonparser" - uuid "github.com/gofrs/uuid" "github.com/golang/glog" "github.com/julienschmidt/httprouter" "github.com/prebid/openrtb/v20/openrtb2" - "github.com/prebid/openrtb/v20/openrtb3" accountService "github.com/prebid/prebid-server/v2/account" "github.com/prebid/prebid-server/v2/analytics" "github.com/prebid/prebid-server/v2/config" - "github.com/prebid/prebid-server/v2/endpoints/events" - "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/combination" + "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/adpod" "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/constant" - "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/impressions" - "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/response" "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/types" "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/util" "github.com/prebid/prebid-server/v2/errortypes" @@ -36,7 +26,6 @@ import ( "github.com/prebid/prebid-server/v2/hooks" "github.com/prebid/prebid-server/v2/hooks/hookexecution" "github.com/prebid/prebid-server/v2/metrics" - "github.com/prebid/prebid-server/v2/modules/pubmatic/openwrap/models/nbr" "github.com/prebid/prebid-server/v2/openrtb_ext" "github.com/prebid/prebid-server/v2/privacy" "github.com/prebid/prebid-server/v2/stored_requests" @@ -50,12 +39,12 @@ type ctvEndpointDeps struct { endpointDeps request *openrtb2.BidRequest reqExt *openrtb_ext.ExtRequestAdPod - impData []*types.ImpData - videoSeats []*openrtb2.SeatBid //stores pure video impression bids - impIndices map[string]int - isAdPodRequest bool impsExtPrebidBidder map[string]map[string]map[string]interface{} impPartnerBlockedTagIDMap map[string]map[string][]string + impToPodId map[string]string + podCtx map[string]adpod.Adpod + videoImps []openrtb2.Imp + videoSeats []*openrtb2.SeatBid //stores pure video impression bids labels metrics.Labels } @@ -163,27 +152,32 @@ func (deps *ctvEndpointDeps) CTVAuctionEndpoint(w http.ResponseWriter, r *http.R } request = reqWrapper.BidRequest - util.JLogf("Original BidRequest", request) //TODO: REMOVE LOG - //init deps.init(request) + // Read Request extension + if errs := deps.readRequestExtension(); len(errs) > 0 { + writeError(errs, w, &deps.labels) + return + } + + // set adpod context + if errs := deps.prepareAdpodCtx(request); len(errs) > 0 { + writeError(errs, w, &deps.labels) + return + } + //Set Default Values deps.setDefaultValues() - util.JLogf("Extensions Request Extension", deps.reqExt) - util.JLogf("Extensions ImpData", deps.impData) //Validate CTV BidRequest - if err := deps.validateBidRequest(); err != nil { - errL = append(errL, err...) - writeError(errL, w, &deps.labels) + if errs := deps.ValidateAdpodCtx(); errs != nil { + writeError(errs, w, &deps.labels) return } - if deps.isAdPodRequest { - //Create New BidRequest + if len(deps.podCtx) > 0 { request = deps.createBidRequest(request) - util.JLogf("CTV BidRequest", request) //TODO: REMOVE LOG } //Parsing Cookies and Set Stats @@ -263,33 +257,18 @@ func (deps *ctvEndpointDeps) CTVAuctionEndpoint(w http.ResponseWriter, r *http.R if err != nil { util.JLogf("Error setting seatNonBid in responseExt: %v", err) //TODO: REMOVE LOG } - util.JLogf("BidResponse", response) //TODO: REMOVE LOG - - if deps.isAdPodRequest { - //Validate Bid Response - if err := deps.validateBidResponse(request, response); err != nil { - errL = append(errL, err) - writeError(errL, w, &deps.labels) - return - } + if len(deps.podCtx) > 0 { //Create Impression Bids - deps.getBids(response) + deps.collectBids(response) - //Do AdPod Exclusions - bids := deps.doAdPodExclusions() + //Hold Auction + deps.doAdpodAuction() //Create Bid Response - adPodBidResponse := deps.createAdPodBidResponse(response, bids) - - //Set bid.Ext params - adpod.aprc, prebid.video.duration - deps.setBidExtParams() - - deps.recordRejectedAdPodBids(deps.labels.PubID) + adPodBidResponse := deps.createAdPodBidResponse(response) adPodBidResponse.Ext = deps.getBidResponseExt(response) response = adPodBidResponse - - util.JLogf("CTV BidResponse", response) //TODO: REMOVE LOG } ao.Response = response @@ -325,62 +304,107 @@ func (deps *ctvEndpointDeps) holdAuction(ctx context.Context, auctionRequest exc func (deps *ctvEndpointDeps) init(req *openrtb2.BidRequest) { deps.request = req - deps.impData = make([]*types.ImpData, len(req.Imp)) - deps.impIndices = make(map[string]int, len(req.Imp)) + deps.impToPodId = make(map[string]string) +} + +/* +PrepareAdpodCtx will check for adpod param, and create adpod context if they are +available in the request. It will check for both ORTB 2.6 and legacy adpod parmaters. +*/ +func (deps *ctvEndpointDeps) prepareAdpodCtx(request *openrtb2.BidRequest) (errs []error) { + deps.podCtx = make(map[string]adpod.Adpod) + + for _, imp := range request.Imp { + if imp.Video != nil { + // check for adpod in the extension + extAdpod, err := deps.readVideoAdPodExt(imp) + if err != nil { + errs = append(errs, err) + } - for i := range req.Imp { - deps.impIndices[req.Imp[i].ID] = i - deps.impData[i] = &types.ImpData{} + switch adpod.GetPodType(imp, extAdpod) { + case adpod.Dynamic: + deps.createDynamicAdpodCtx(imp, extAdpod) + case adpod.Structured: + deps.createStructuredAdpodCtx(imp) + default: + // Pure video impressions + deps.videoImps = append(deps.videoImps, imp) + } + } } + return } -func (deps *ctvEndpointDeps) readVideoAdPodExt() (err []error) { - for index, imp := range deps.request.Imp { - if nil != imp.Video { - vidExt := openrtb_ext.ExtVideoAdPod{} - if len(imp.Video.Ext) > 0 { - errL := json.Unmarshal(imp.Video.Ext, &vidExt) - if nil != err { - err = append(err, errL) - continue - } +func (deps *ctvEndpointDeps) createDynamicAdpodCtx(imp openrtb2.Imp, adpodExt openrtb_ext.ExtVideoAdPod) { + podId := imp.Video.PodID + if len(podId) == 0 { + podId = imp.ID + } + deps.impToPodId[imp.ID] = podId - imp.Video.Ext = jsonparser.Delete(imp.Video.Ext, constant.CTVAdpod) - imp.Video.Ext = jsonparser.Delete(imp.Video.Ext, constant.CTVOffset) - if string(imp.Video.Ext) == `{}` { - imp.Video.Ext = nil - } - } + deps.podCtx[podId] = adpod.NewDynamicAdpod(deps.labels.PubID, imp, adpodExt, deps.metricsEngine, deps.reqExt) +} - if nil == vidExt.AdPod { - if nil == deps.reqExt { - continue - } - vidExt.AdPod = &openrtb_ext.VideoAdPod{} - } +func (deps *ctvEndpointDeps) createStructuredAdpodCtx(imp openrtb2.Imp) { + deps.impToPodId[imp.ID] = imp.Video.PodID - //Use Request Level Parameters - if nil != deps.reqExt { - vidExt.AdPod.Merge(&deps.reqExt.VideoAdPod) - } + podContext, ok := deps.podCtx[imp.Video.PodID] + if !ok { + podContext = adpod.NewStructuredAdpod(deps.labels.PubID, deps.metricsEngine, deps.reqExt) + } - //Set Default Values - vidExt.SetDefaultValue() - vidExt.AdPod.SetDefaultAdDurations(imp.Video.MinDuration, imp.Video.MaxDuration) + podContext.AddImpressions(imp) + deps.podCtx[imp.Video.PodID] = podContext +} - deps.impData[index].VideoExt = &vidExt +func (deps *ctvEndpointDeps) ValidateAdpodCtx() []error { + var errs []error + for _, eachpod := range deps.podCtx { + err := eachpod.Validate() + if err != nil { + errs = append(errs, err...) } } - return err + + return errs +} + +func (deps *ctvEndpointDeps) readVideoAdPodExt(imp openrtb2.Imp) (openrtb_ext.ExtVideoAdPod, error) { + var adpodExt openrtb_ext.ExtVideoAdPod + + if imp.Video != nil && len(imp.Video.Ext) > 0 { + err := json.Unmarshal(imp.Video.Ext, &adpodExt) + if err != nil { + return adpodExt, err + } + } + + if adpodExt.AdPod == nil && deps.reqExt == nil { + return adpodExt, nil + } + + if deps.reqExt != nil && deps.reqExt.VideoAdPod != nil { + if adpodExt.AdPod == nil { + adpodExt.AdPod = &openrtb_ext.VideoAdPod{} + } + adpodExt.AdPod.Merge(deps.reqExt.VideoAdPod) + } + + //Set Default Values + if adpodExt.AdPod != nil { + adpodExt.SetDefaultValue() + adpodExt.AdPod.SetDefaultAdDurations(imp.Video.MinDuration, imp.Video.MaxDuration) + } + + return adpodExt, nil } func (deps *ctvEndpointDeps) readRequestExtension() (err []error) { if len(deps.request.Ext) > 0 { - //TODO: use jsonparser library for get adpod and remove that key extAdPod, jsonType, _, errL := jsonparser.Get(deps.request.Ext, constant.CTVAdpod) - - if nil != errL { + if errL != nil { //parsing error if jsonparser.NotExist != jsonType { //assuming key not present @@ -389,7 +413,6 @@ func (deps *ctvEndpointDeps) readRequestExtension() (err []error) { } } else { deps.reqExt = &openrtb_ext.ExtRequestAdPod{} - if errL := json.Unmarshal(extAdPod, deps.reqExt); nil != errL { err = append(err, errL) return @@ -397,70 +420,19 @@ func (deps *ctvEndpointDeps) readRequestExtension() (err []error) { deps.reqExt.SetDefaultValue() } - } - return -} - -func (deps *ctvEndpointDeps) readExtensions() (err []error) { - if errL := deps.readRequestExtension(); nil != errL { - err = append(err, errL...) - } - - if errL := deps.readVideoAdPodExt(); nil != errL { - err = append(err, errL...) - } - return err -} - -func (deps *ctvEndpointDeps) setIsAdPodRequest() { - deps.isAdPodRequest = false - for _, data := range deps.impData { - if nil != data.VideoExt && nil != data.VideoExt.AdPod { - deps.isAdPodRequest = true - break - } + err = deps.reqExt.Validate() } + return } // setDefaultValues will set adpod and other default values func (deps *ctvEndpointDeps) setDefaultValues() { - //read and set extension values - deps.readExtensions() - - //set request is adpod request or normal request - deps.setIsAdPodRequest() - - if deps.isAdPodRequest { + if len(deps.podCtx) > 0 { deps.readImpExtensionsAndTags() } } -// validateBidRequest will validate AdPod specific mandatory Parameters and returns error -func (deps *ctvEndpointDeps) validateBidRequest() (err []error) { - //validating video extension adpod configurations - if nil != deps.reqExt { - err = deps.reqExt.Validate() - } - - for index, imp := range deps.request.Imp { - if nil != imp.Video && nil != deps.impData[index].VideoExt { - ext := deps.impData[index].VideoExt - if errL := ext.Validate(); nil != errL { - err = append(err, errL...) - } - - if nil != ext.AdPod { - if errL := ext.AdPod.ValidateAdPodDurations(imp.Video.MinDuration, imp.Video.MaxDuration, imp.Video.MaxExtended); nil != errL { - err = append(err, errL...) - } - } - } - - } - return -} - // readImpExtensionsAndTags will read the impression extensions func (deps *ctvEndpointDeps) readImpExtensionsAndTags() (errs []error) { deps.impsExtPrebidBidder = make(map[string]map[string]map[string]interface{}) @@ -509,22 +481,26 @@ func (deps *ctvEndpointDeps) readImpExtensionsAndTags() (errs []error) { // createBidRequest will return new bid request with all things copy from bid request except impression objects func (deps *ctvEndpointDeps) createBidRequest(req *openrtb2.BidRequest) *openrtb2.BidRequest { ctvRequest := *req + var imps []openrtb2.Imp - //get configurations for all impressions - deps.getAllAdPodImpsConfigs() + for _, adpodCtx := range deps.podCtx { + imps = append(imps, adpodCtx.GetImpressions()...) + } - //createImpressions - ctvRequest.Imp = deps.createImpressions() + if len(deps.videoImps) > 0 { + imps = append(imps, deps.videoImps...) + } + ctvRequest.Imp = imps + + adpod.ConvertToV25VideoRequest(&ctvRequest) deps.filterImpsVastTagsByDuration(&ctvRequest) - //TODO: remove adpod extension if not required to send further return &ctvRequest } // filterImpsVastTagsByDuration checks if a Vast tag should be called for a generated impression based on the duration of tag and impression func (deps *ctvEndpointDeps) filterImpsVastTagsByDuration(bidReq *openrtb2.BidRequest) { - for impCount, imp := range bidReq.Imp { index := strings.LastIndex(imp.ID, "_") if index == -1 { @@ -588,15 +564,6 @@ func (deps *ctvEndpointDeps) filterImpsVastTagsByDuration(bidReq *openrtb2.BidRe imp.Ext = impExt bidReq.Imp[impCount] = imp } - - for impID, blockedTags := range deps.impPartnerBlockedTagIDMap { - for _, datum := range deps.impData { - if datum.ImpID == impID { - datum.BlockedVASTTags = blockedTags - break - } - } - } } func remove(slice []string, item string) []string { @@ -615,157 +582,44 @@ func remove(slice []string, item string) []string { return append(slice[:index], slice[index+1:]...) } -// getAllAdPodImpsConfigs will return all impression adpod configurations -func (deps *ctvEndpointDeps) getAllAdPodImpsConfigs() { - for index, imp := range deps.request.Imp { - if nil == imp.Video || nil == deps.impData[index].VideoExt || nil == deps.impData[index].VideoExt.AdPod { - continue - } - deps.impData[index].ImpID = imp.ID - - config, err := deps.getAdPodImpsConfigs(&imp, deps.impData[index].VideoExt.AdPod) - if err != nil { - deps.impData[index].Error = util.ErrToBidderMessage(err) - continue - } - deps.impData[index].Config = config[:] - } -} - -// getAdPodImpsConfigs will return number of impressions configurations within adpod -func (deps *ctvEndpointDeps) getAdPodImpsConfigs(imp *openrtb2.Imp, adpod *openrtb_ext.VideoAdPod) ([]*types.ImpAdPodConfig, error) { - // monitor - start := time.Now() - selectedAlgorithm := impressions.SelectAlgorithm(deps.reqExt) - impGen := impressions.NewImpressions(imp.Video.MinDuration, imp.Video.MaxDuration, deps.reqExt, adpod, selectedAlgorithm) - impRanges := impGen.Get() - labels := metrics.PodLabels{AlgorithmName: impressions.MonitorKey[selectedAlgorithm], NoOfImpressions: new(int)} - - //log number of impressions in stats - *labels.NoOfImpressions = len(impRanges) - deps.metricsEngine.RecordPodImpGenTime(labels, start) - - // check if algorithm has generated impressions - if len(impRanges) == 0 { - return nil, util.UnableToGenerateImpressionsError - } - - config := make([]*types.ImpAdPodConfig, len(impRanges)) - for i, value := range impRanges { - config[i] = &types.ImpAdPodConfig{ - ImpID: util.GetCTVImpressionID(imp.ID, i+1), - MinDuration: value[0], - MaxDuration: value[1], - SequenceNumber: int8(i + 1), /* Must be starting with 1 */ - } - } - return config[:], nil -} - -// createImpressions will create multiple impressions based on adpod configurations -func (deps *ctvEndpointDeps) createImpressions() []openrtb2.Imp { - impCount := 0 - for _, imp := range deps.impData { - if nil == imp.Error { - if len(imp.Config) == 0 { - impCount = impCount + 1 - } else { - impCount = impCount + len(imp.Config) - } - } - } - - count := 0 - imps := make([]openrtb2.Imp, impCount) - for index, imp := range deps.request.Imp { - if nil == deps.impData[index].Error { - adPodConfig := deps.impData[index].Config - if len(adPodConfig) == 0 { - //non adpod request it will be normal video impression - imps[count] = imp - count++ - } else { - //for adpod request it will create new impression based on configurations - for _, config := range adPodConfig { - imps[count] = *(newImpression(&imp, config)) - count++ - } - } - } - } - return imps[:] -} - -// newImpression will clone existing impression object and create video object with ImpAdPodConfig. -func newImpression(imp *openrtb2.Imp, config *types.ImpAdPodConfig) *openrtb2.Imp { - video := *imp.Video - video.MinDuration = config.MinDuration - video.MaxDuration = config.MaxDuration - video.Sequence = config.SequenceNumber - video.MaxExtended = 0 - //TODO: remove video adpod extension if not required - - newImp := *imp - newImp.ID = config.ImpID - //newImp.BidFloor = 0 - newImp.Video = &video - return &newImp -} - /********************* Prebid BidResponse Processing *********************/ -// validateBidResponse -func (deps *ctvEndpointDeps) validateBidResponse(req *openrtb2.BidRequest, resp *openrtb2.BidResponse) error { - //remove bids withoug cat and adomain - - return nil -} - -// getBids reads bids from bidresponse object -func (deps *ctvEndpointDeps) getBids(resp *openrtb2.BidResponse) { +func (deps *ctvEndpointDeps) collectBids(response *openrtb2.BidResponse) { var vseat *openrtb2.SeatBid - result := make(map[string]*types.AdPodBid) - for i := range resp.SeatBid { - seat := resp.SeatBid[i] + for i := range response.SeatBid { + seat := response.SeatBid[i] vseat = nil - for j := range seat.Bid { bid := &seat.Bid[j] - if len(bid.ID) == 0 { - bidID, err := uuid.NewV4() - if nil != err { - continue - } - bid.ID = bidID.String() - } - if bid.Price == 0 { - //filter invalid bids continue } - originalImpID, sequenceNumber := deps.getImpressionID(bid.ImpID) - if sequenceNumber < 0 { - continue + if len(bid.ID) == 0 { + bidId, err := jsonparser.GetString(bid.Ext, "prebid", "bidid") + if err == nil { + bid.ID = bidId + } } + originalImpID, _ := util.DecodeImpressionID(bid.ImpID) //TODO: check if we can reomove and maintain map + value, err := util.GetTargeting(openrtb_ext.HbCategoryDurationKey, openrtb_ext.BidderName(seat.Seat), *bid) if nil == err { // ignore error - addTargetingKey(bid, openrtb_ext.HbCategoryDurationKey, value) + adpod.AddTargetingKey(bid, openrtb_ext.HbCategoryDurationKey, value) } value, err = util.GetTargeting(openrtb_ext.HbpbConstantKey, openrtb_ext.BidderName(seat.Seat), *bid) if nil == err { // ignore error - addTargetingKey(bid, openrtb_ext.HbpbConstantKey, value) + adpod.AddTargetingKey(bid, openrtb_ext.HbpbConstantKey, value) } - index := deps.impIndices[originalImpID] - if len(deps.impData[index].Config) == 0 { - //adding pure video bids + podId, ok := deps.impToPodId[originalImpID] + if !ok { if vseat == nil { vseat = &openrtb2.SeatBid{ Seat: seat.Seat, @@ -775,436 +629,61 @@ func (deps *ctvEndpointDeps) getBids(resp *openrtb2.BidResponse) { deps.videoSeats = append(deps.videoSeats, vseat) } vseat.Bid = append(vseat.Bid, *bid) - } else { - //reading extension, ingorning parsing error - ext := openrtb_ext.ExtBid{} - if nil != bid.Ext { - json.Unmarshal(bid.Ext, &ext) - } - - //Adding adpod bids - impBids, ok := result[originalImpID] - if !ok { - impBids = &types.AdPodBid{ - OriginalImpID: originalImpID, - SeatName: string(openrtb_ext.BidderOWPrebidCTV), - } - result[originalImpID] = impBids - } - - if deps.cfg.GenerateBidID == false { - //making unique bid.id's per impression - bid.ID = util.GetUniqueBidID(bid.ID, len(impBids.Bids)+1) - } - - //get duration of creative - duration, status := getBidDuration(bid, deps.reqExt, deps.impData[index].Config, - deps.impData[index].Config[sequenceNumber-1].MaxDuration) - - impBids.Bids = append(impBids.Bids, &types.Bid{ - Bid: bid, - ExtBid: ext, - Status: status, - Duration: int(duration), - DealTierSatisfied: util.GetDealTierSatisfied(&ext), - Seat: seat.Seat, - }) - } - } - } - - //Sort Bids by Price - for index, imp := range deps.request.Imp { - impBids, ok := result[imp.ID] - if ok { - //sort bids - sort.Slice(impBids.Bids[:], func(i, j int) bool { return impBids.Bids[i].Price > impBids.Bids[j].Price }) - deps.impData[index].Bid = impBids - } - } -} - -// getImpressionID will return impression id and sequence number -func (deps *ctvEndpointDeps) getImpressionID(id string) (string, int) { - //get original impression id and sequence number - originalImpID, sequenceNumber := util.DecodeImpressionID(id) - - //check originalImpID present in request or not - index, ok := deps.impIndices[originalImpID] - if !ok { - //if not present check impression id present in request or not - _, ok = deps.impIndices[id] - if !ok { - return id, -1 - } - return originalImpID, 0 - } - - if sequenceNumber < 0 || sequenceNumber > len(deps.impData[index].Config) { - return id, -1 - } - - return originalImpID, sequenceNumber -} - -// doAdPodExclusions -func (deps *ctvEndpointDeps) doAdPodExclusions() types.AdPodBids { - defer util.TimeTrack(time.Now(), fmt.Sprintf("Tid:%v doAdPodExclusions", deps.request.ID)) - - result := types.AdPodBids{} - for index := 0; index < len(deps.request.Imp); index++ { - bid := deps.impData[index].Bid - if nil != bid && len(bid.Bids) > 0 { - //TODO: MULTI ADPOD IMPRESSIONS - //duration wise buckets sorted - buckets := util.GetDurationWiseBidsBucket(bid.Bids[:]) - - if len(buckets) == 0 { - deps.impData[index].Error = util.DurationMismatchWarning continue } - //combination generator - comb := combination.NewCombination( - buckets, - uint64(deps.request.Imp[index].Video.MinDuration), - uint64(deps.request.Imp[index].Video.MaxDuration), - deps.impData[index].VideoExt.AdPod) - - //adpod generator - adpodGenerator := response.NewAdPodGenerator(deps.request, index, buckets, comb, deps.impData[index].VideoExt.AdPod, deps.metricsEngine) - - adpodBids := adpodGenerator.GetAdPodBids() - if adpodBids == nil { - deps.impData[index].Error = util.UnableToGenerateAdPodWarning + adpodCtx, ok := deps.podCtx[podId] + if !ok { continue } - adpodBids.OriginalImpID = bid.OriginalImpID - adpodBids.SeatName = bid.SeatName - result = append(result, adpodBids) + adpodCtx.CollectBid(bid, seat.Seat) } } - return result } -/********************* Creating CTV BidResponse *********************/ - -// createAdPodBidResponse -func (deps *ctvEndpointDeps) createAdPodBidResponse(resp *openrtb2.BidResponse, adpods types.AdPodBids) *openrtb2.BidResponse { - defer util.TimeTrack(time.Now(), fmt.Sprintf("Tid:%v createAdPodBidResponse", deps.request.ID)) - - bidResp := &openrtb2.BidResponse{ - ID: resp.ID, - Cur: resp.Cur, - CustomData: resp.CustomData, - SeatBid: deps.getBidResponseSeatBids(adpods), +func (deps *ctvEndpointDeps) doAdpodAuction() { + for _, adpodCtx := range deps.podCtx { + adpodCtx.HoldAuction() } - return bidResp } -func (deps *ctvEndpointDeps) getBidResponseSeatBids(adpods types.AdPodBids) []openrtb2.SeatBid { - seats := []openrtb2.SeatBid{} +/********************* Creating CTV BidResponse *********************/ + +// createAdPodBidResponse +func (deps *ctvEndpointDeps) createAdPodBidResponse(resp *openrtb2.BidResponse) *openrtb2.BidResponse { + var seatbids []openrtb2.SeatBid //append pure video request seats for _, seat := range deps.videoSeats { - seats = append(seats, *seat) - } - - var adpodSeat *openrtb2.SeatBid - for _, adpod := range adpods { - if len(adpod.Bids) == 0 { - continue - } - - bid := deps.getAdPodBid(adpod) - if bid != nil { - if nil == adpodSeat { - adpodSeat = &openrtb2.SeatBid{ - Seat: adpod.SeatName, - } - } - adpodSeat.Bid = append(adpodSeat.Bid, *bid.Bid) - } - } - if nil != adpodSeat { - seats = append(seats, *adpodSeat) - } - return seats[:] -} - -// getAdPodBid -func (deps *ctvEndpointDeps) getAdPodBid(adpod *types.AdPodBid) *types.Bid { - bid := types.Bid{ - Bid: &openrtb2.Bid{}, - } - - //TODO: Write single for loop to get all details - bidID, err := uuid.NewV4() - if nil == err { - bid.ID = bidID.String() - } else { - bid.ID = adpod.Bids[0].ID - } - - bid.ImpID = adpod.OriginalImpID - bid.Price = adpod.Price - bid.ADomain = adpod.ADomain[:] - bid.Cat = adpod.Cat[:] - bid.AdM = *getAdPodBidCreative(adpod, deps.cfg.GenerateBidID) - bid.Ext = getAdPodBidExtension(adpod) - return &bid -} - -// getAdPodBidCreative get commulative adpod bid details -func getAdPodBidCreative(adpod *types.AdPodBid, generatedBidID bool) *string { - doc := etree.NewDocument() - vast := doc.CreateElement(constant.VASTElement) - sequenceNumber := 1 - var version float64 = 2.0 - - for _, bid := range adpod.Bids { - var newAd *etree.Element - - if strings.HasPrefix(bid.AdM, constant.HTTPPrefix) { - newAd = etree.NewElement(constant.VASTAdElement) - wrapper := newAd.CreateElement(constant.VASTWrapperElement) - vastAdTagURI := wrapper.CreateElement(constant.VASTAdTagURIElement) - vastAdTagURI.CreateCharData(bid.AdM) - } else { - adDoc := etree.NewDocument() - if err := adDoc.ReadFromString(bid.AdM); err != nil { - continue - } - - if generatedBidID == false { - // adjust bidid in video event trackers and update - adjustBidIDInVideoEventTrackers(adDoc, bid.Bid) - adm, err := adDoc.WriteToString() - if nil != err { - util.JLogf("ERROR, %v", err.Error()) - } else { - bid.AdM = adm - } - } - - vastTag := adDoc.SelectElement(constant.VASTElement) - if vastTag == nil { - util.Logf("error:[vast_element_missing_in_adm] seat:[%s] adm:[%s]", bid.Seat, bid.AdM) - continue - } - - //Get Actual VAST Version - bidVASTVersion, _ := strconv.ParseFloat(vastTag.SelectAttrValue(constant.VASTVersionAttribute, constant.VASTDefaultVersionStr), 64) - version = math.Max(version, bidVASTVersion) - - ads := vastTag.SelectElements(constant.VASTAdElement) - if len(ads) > 0 { - newAd = ads[0].Copy() - } - } - - if nil != newAd { - //creative.AdId attribute needs to be updated - newAd.CreateAttr(constant.VASTSequenceAttribute, fmt.Sprint(sequenceNumber)) - vast.AddChild(newAd) - sequenceNumber++ - } - } - - if int(version) > len(constant.VASTVersionsStr) { - version = constant.VASTMaxVersion + seatbids = append(seatbids, *seat) } - vast.CreateAttr(constant.VASTVersionAttribute, constant.VASTVersionsStr[int(version)]) - bidAdM, err := doc.WriteToString() - if nil != err { - fmt.Printf("ERROR, %v", err.Error()) - return nil - } - return &bidAdM -} - -// getAdPodBidExtension get commulative adpod bid details -func getAdPodBidExtension(adpod *types.AdPodBid) json.RawMessage { - bidExt := &openrtb_ext.ExtOWBid{ - ExtBid: openrtb_ext.ExtBid{ - Prebid: &openrtb_ext.ExtBidPrebid{ - Type: openrtb_ext.BidTypeVideo, - Video: &openrtb_ext.ExtBidPrebidVideo{}, - }, - }, - AdPod: &openrtb_ext.BidAdPodExt{ - RefBids: make([]string, len(adpod.Bids)), - }, - } - - for i, bid := range adpod.Bids { - //get unique bid id - bidID := bid.ID - if bid.ExtBid.Prebid != nil && bid.ExtBid.Prebid.BidId != "" { - bidID = bid.ExtBid.Prebid.BidId - } - - //adding bid id in adpod.refbids - bidExt.AdPod.RefBids[i] = bidID - - //updating exact duration of adpod creative - bidExt.Prebid.Video.Duration += int(bid.Duration) - - //setting bid status as winning bid - bid.Status = constant.StatusWinningBid - } - rawExt, _ := json.Marshal(bidExt) - return rawExt -} - -// getDurationBasedOnDurationMatchingPolicy will return duration based on durationmatching policy -func getDurationBasedOnDurationMatchingPolicy(duration int64, policy openrtb_ext.OWVideoAdDurationMatchingPolicy, config []*types.ImpAdPodConfig) (int64, constant.BidStatus) { - switch policy { - case openrtb_ext.OWExactVideoAdDurationMatching: - tmp := util.GetNearestDuration(duration, config) - if tmp != duration { - return duration, constant.StatusDurationMismatch - } - //its and valid duration return it with StatusOK - - case openrtb_ext.OWRoundupVideoAdDurationMatching: - tmp := util.GetNearestDuration(duration, config) - if tmp == -1 { - return duration, constant.StatusDurationMismatch - } - //update duration with nearest one duration - duration = tmp - //its and valid duration return it with StatusOK + for _, adpod := range deps.podCtx { + seatbids = append(seatbids, adpod.GetAdpodSeatBids()...) } - return duration, constant.StatusOK -} - -/* -getBidDuration determines the duration of video ad from given bid. -it will try to get the actual ad duration returned by the bidder using prebid.video.duration -if prebid.video.duration not present then uses defaultDuration passed as an argument -if video lengths matching policy is present for request then it will validate and update duration based on policy -*/ -func getBidDuration(bid *openrtb2.Bid, reqExt *openrtb_ext.ExtRequestAdPod, config []*types.ImpAdPodConfig, defaultDuration int64) (int64, constant.BidStatus) { - - // C1: Read it from bid.ext.prebid.video.duration field - duration, err := jsonparser.GetInt(bid.Ext, "prebid", "video", "duration") - if nil != err || duration <= 0 { - // incase if duration is not present use impression duration directly as it is - return defaultDuration, constant.StatusOK - } - - // C2: Based on video lengths matching policy validate and return duration - if nil != reqExt && len(reqExt.VideoAdDurationMatching) > 0 { - return getDurationBasedOnDurationMatchingPolicy(duration, reqExt.VideoAdDurationMatching, config) - } - - //default return duration which is present in bid.ext.prebid.vide.duration field - return duration, constant.StatusOK -} - -func addTargetingKey(bid *openrtb2.Bid, key openrtb_ext.TargetingKey, value string) error { - if nil == bid { - return errors.New("Invalid bid") - } - - raw, err := jsonparser.Set(bid.Ext, []byte(strconv.Quote(value)), "prebid", "targeting", string(key)) - if nil == err { - bid.Ext = raw - } - return err -} - -func adjustBidIDInVideoEventTrackers(doc *etree.Document, bid *openrtb2.Bid) { - // adjusment: update bid.id with ctv module generated bid.id - creatives := events.FindCreatives(doc) - for _, creative := range creatives { - trackingEvents := creative.FindElements("TrackingEvents/Tracking") - if nil != trackingEvents { - // update bidid= value with ctv generated bid id for this bid - for _, trackingEvent := range trackingEvents { - u, e := url.Parse(trackingEvent.Text()) - if nil == e { - values, e := url.ParseQuery(u.RawQuery) - // only do replacment if operId=8 - if nil == e && nil != values["bidid"] && nil != values["operId"] && values["operId"][0] == "8" { - values.Set("bidid", bid.ID) - } else { - continue - } - - //OTT-183: Fix - if nil != values["operId"] && values["operId"][0] == "8" { - operID := values.Get("operId") - values.Del("operId") - values.Add("_operId", operID) // _ (underscore) will keep it as first key - } - - u.RawQuery = values.Encode() // encode sorts query params by key. _ must be first (assuing no other query param with _) - // replace _operId with operId - u.RawQuery = strings.ReplaceAll(u.RawQuery, "_operId", "operId") - trackingEvent.SetText(u.String()) - } - } - } - } -} - -// ConvertAPRCToNBRC converts the aprc to NonBidStatusCode -func ConvertAPRCToNBRC(bidStatus int64) *openrtb3.NoBidReason { - var nbrCode openrtb3.NoBidReason - - switch bidStatus { - case constant.StatusOK: - nbrCode = nbr.LossBidLostToHigherBid - case constant.StatusCategoryExclusion: - nbrCode = exchange.ResponseRejectedCreativeCategoryExclusions - case constant.StatusDomainExclusion: - nbrCode = exchange.ResponseRejectedCreativeAdvertiserExclusions - case constant.StatusDurationMismatch: - nbrCode = exchange.ResponseRejectedInvalidCreative - - default: - return nil - } - return &nbrCode -} - -// recordRejectedAdPodBids records the bids lost in ad-pod auction using metricsEngine -func (deps *ctvEndpointDeps) recordRejectedAdPodBids(pubID string) { - - for _, imp := range deps.impData { - if nil != imp.Bid && len(imp.Bid.Bids) > 0 { - for _, bid := range imp.Bid.Bids { - if bid.Status != constant.StatusWinningBid { - reason := ConvertAPRCToNBRC(bid.Status) - if reason == nil { - continue - } - rejReason := strconv.FormatInt(int64(*reason), 10) - deps.metricsEngine.RecordRejectedBids(pubID, bid.Seat, rejReason) - } - } - } + bidResp := &openrtb2.BidResponse{ + ID: resp.ID, + Cur: resp.Cur, + CustomData: resp.CustomData, + SeatBid: deps.combineBidsSeatWise(seatbids), } + return bidResp } // getBidResponseExt prepare and return the bidresponse extension func (deps *ctvEndpointDeps) getBidResponseExt(resp *openrtb2.BidResponse) (data json.RawMessage) { - var err error - adpodExt := types.BidResponseAdPodExt{ Response: *resp, - Config: make(map[string]*types.ImpData, len(deps.impData)), + Config: make(map[string]*types.ImpData), } - for index, imp := range deps.impData { - if nil != imp.VideoExt && nil != imp.VideoExt.AdPod { - adpodExt.Config[deps.request.Imp[index].ID] = imp + for podId, adpodCtx := range deps.podCtx { + ext := adpodCtx.GetAdpodExtension(deps.impPartnerBlockedTagIDMap) + if ext != nil { + adpodExt.Config[podId] = ext } } @@ -1234,31 +713,27 @@ func (deps *ctvEndpointDeps) getBidResponseExt(resp *openrtb2.BidResponse) (data return nil } } - return data[:] + return data } -// setBidExtParams function sets the prebid.video.duration and adpod.aprc parameters -func (deps *ctvEndpointDeps) setBidExtParams() { - - for _, imp := range deps.impData { - if imp.Bid != nil { - for _, bid := range imp.Bid.Bids { - - //update adm - //bid.AdM = constant.VASTDefaultTag +func (deps *ctvEndpointDeps) combineBidsSeatWise(seatBids []openrtb2.SeatBid) []openrtb2.SeatBid { + if len(seatBids) == 0 { + return nil + } - //add duration value - raw, err := jsonparser.Set(bid.Ext, []byte(strconv.Itoa(int(bid.Duration))), "prebid", "video", "duration") - if nil == err { - bid.Ext = raw - } + seatMap := map[string][]openrtb2.Bid{} + for _, seatBid := range seatBids { + seatMap[seatBid.Seat] = append(seatMap[seatBid.Seat], seatBid.Bid...) + } - //add bid filter reason value - raw, err = jsonparser.Set(bid.Ext, []byte(strconv.FormatInt(bid.Status, 10)), "adpod", "aprc") - if nil == err { - bid.Ext = raw - } - } + var responseSeatBids []openrtb2.SeatBid + for seat, bids := range seatMap { + seat := openrtb2.SeatBid{ + Bid: bids, + Seat: seat, } + responseSeatBids = append(responseSeatBids, seat) } + + return responseSeatBids } diff --git a/endpoints/openrtb2/ctv_auction_test.go b/endpoints/openrtb2/ctv_auction_test.go index 01a161a87e9..e50d3b85315 100644 --- a/endpoints/openrtb2/ctv_auction_test.go +++ b/endpoints/openrtb2/ctv_auction_test.go @@ -1,43 +1,24 @@ package openrtb2 import ( + "bytes" "encoding/json" + "net/http" + "net/http/httptest" + "os" "testing" + "github.com/julienschmidt/httprouter" "github.com/prebid/openrtb/v20/openrtb2" - "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/constant" + "github.com/prebid/prebid-server/v2/config" + "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/adpod" "github.com/prebid/prebid-server/v2/endpoints/openrtb2/ctv/types" "github.com/prebid/prebid-server/v2/metrics" "github.com/prebid/prebid-server/v2/openrtb_ext" + "github.com/prebid/prebid-server/v2/util/ptrutil" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" ) -func TestAddTargetingKeys(t *testing.T) { - var tests = []struct { - scenario string // Testcase scenario - key string - value string - bidExt string - expect map[string]string - }{ - {scenario: "key_not_exists", key: "hb_pb_cat_dur", value: "some_value", bidExt: `{"prebid":{"targeting":{}}}`, expect: map[string]string{"hb_pb_cat_dur": "some_value"}}, - {scenario: "key_already_exists", key: "hb_pb_cat_dur", value: "new_value", bidExt: `{"prebid":{"targeting":{"hb_pb_cat_dur":"old_value"}}}`, expect: map[string]string{"hb_pb_cat_dur": "new_value"}}, - } - for _, test := range tests { - t.Run(test.scenario, func(t *testing.T) { - bid := new(openrtb2.Bid) - bid.Ext = []byte(test.bidExt) - key := openrtb_ext.TargetingKey(test.key) - assert.Nil(t, addTargetingKey(bid, key, test.value)) - extBid := openrtb_ext.ExtBid{} - json.Unmarshal(bid.Ext, &extBid) - assert.Equal(t, test.expect, extBid.Prebid.Targeting) - }) - } - assert.Equal(t, "Invalid bid", addTargetingKey(nil, openrtb_ext.HbCategoryDurationKey, "some value").Error()) -} - func TestFilterImpsVastTagsByDuration(t *testing.T) { type inputParams struct { request *openrtb2.BidRequest @@ -250,322 +231,13 @@ func TestFilterImpsVastTagsByDuration(t *testing.T) { t.Run(tc.testName, func(t *testing.T) { t.Parallel() - deps := ctvEndpointDeps{request: tc.input.request, impData: tc.input.impData} + deps := ctvEndpointDeps{request: tc.input.request} deps.readImpExtensionsAndTags() outputBids := tc.input.generatedRequest deps.filterImpsVastTagsByDuration(outputBids) assert.Equal(t, tc.expectedOutput.reqs, *outputBids, "Expected length of impressions array was %d but actual was %d", tc.expectedOutput.reqs, outputBids) - - for i, datum := range deps.impData { - assert.Equal(t, tc.expectedOutput.blockedTags[i], datum.BlockedVASTTags, "Expected and actual impData was different") - } - }) - } -} - -func TestGetBidDuration(t *testing.T) { - type args struct { - bid *openrtb2.Bid - reqExt *openrtb_ext.ExtRequestAdPod - config []*types.ImpAdPodConfig - defaultDuration int64 - } - type want struct { - duration int64 - status constant.BidStatus - } - var tests = []struct { - name string - args args - want want - expect int - }{ - { - name: "nil_bid_ext", - args: args{ - bid: &openrtb2.Bid{}, - reqExt: nil, - config: nil, - defaultDuration: 100, - }, - want: want{ - duration: 100, - status: constant.StatusOK, - }, - }, - { - name: "use_default_duration", - args: args{ - bid: &openrtb2.Bid{ - Ext: json.RawMessage(`{"tmp":123}`), - }, - reqExt: nil, - config: nil, - defaultDuration: 100, - }, - want: want{ - duration: 100, - status: constant.StatusOK, - }, - }, - { - name: "invalid_duration_in_bid_ext", - args: args{ - bid: &openrtb2.Bid{ - Ext: json.RawMessage(`{"prebid":{"video":{"duration":"invalid"}}}`), - }, - reqExt: nil, - config: nil, - defaultDuration: 100, - }, - want: want{ - duration: 100, - status: constant.StatusOK, - }, - }, - { - name: "0sec_duration_in_bid_ext", - args: args{ - bid: &openrtb2.Bid{ - Ext: json.RawMessage(`{"prebid":{"video":{"duration":0}}}`), - }, - reqExt: nil, - config: nil, - defaultDuration: 100, - }, - want: want{ - duration: 100, - status: constant.StatusOK, - }, - }, - { - name: "negative_duration_in_bid_ext", - args: args{ - bid: &openrtb2.Bid{ - Ext: json.RawMessage(`{"prebid":{"video":{"duration":-30}}}`), - }, - reqExt: nil, - config: nil, - defaultDuration: 100, - }, - want: want{ - duration: 100, - status: constant.StatusOK, - }, - }, - { - name: "30sec_duration_in_bid_ext", - args: args{ - bid: &openrtb2.Bid{ - Ext: json.RawMessage(`{"prebid":{"video":{"duration":30}}}`), - }, - reqExt: nil, - config: nil, - defaultDuration: 100, - }, - want: want{ - duration: 30, - status: constant.StatusOK, - }, - }, - { - name: "duration_matching_empty", - args: args{ - bid: &openrtb2.Bid{ - Ext: json.RawMessage(`{"prebid":{"video":{"duration":30}}}`), - }, - reqExt: &openrtb_ext.ExtRequestAdPod{ - VideoAdDurationMatching: "", - }, - config: nil, - defaultDuration: 100, - }, - want: want{ - duration: 30, - status: constant.StatusOK, - }, - }, - { - name: "duration_matching_exact", - args: args{ - bid: &openrtb2.Bid{ - Ext: json.RawMessage(`{"prebid":{"video":{"duration":30}}}`), - }, - reqExt: &openrtb_ext.ExtRequestAdPod{ - VideoAdDurationMatching: openrtb_ext.OWExactVideoAdDurationMatching, - }, - config: []*types.ImpAdPodConfig{ - {MaxDuration: 10}, - {MaxDuration: 20}, - {MaxDuration: 30}, - {MaxDuration: 40}, - }, - defaultDuration: 100, - }, - want: want{ - duration: 30, - status: constant.StatusOK, - }, - }, - { - name: "duration_matching_exact_not_present", - args: args{ - bid: &openrtb2.Bid{ - Ext: json.RawMessage(`{"prebid":{"video":{"duration":35}}}`), - }, - reqExt: &openrtb_ext.ExtRequestAdPod{ - VideoAdDurationMatching: openrtb_ext.OWExactVideoAdDurationMatching, - }, - config: []*types.ImpAdPodConfig{ - {MaxDuration: 10}, - {MaxDuration: 20}, - {MaxDuration: 30}, - {MaxDuration: 40}, - }, - defaultDuration: 100, - }, - want: want{ - duration: 35, - status: constant.StatusDurationMismatch, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - duration, status := getBidDuration(tt.args.bid, tt.args.reqExt, tt.args.config, tt.args.defaultDuration) - assert.Equal(t, tt.want.duration, duration) - assert.Equal(t, tt.want.status, status) - }) - } -} - -func Test_getDurationBasedOnDurationMatchingPolicy(t *testing.T) { - type args struct { - duration int64 - policy openrtb_ext.OWVideoAdDurationMatchingPolicy - config []*types.ImpAdPodConfig - } - type want struct { - duration int64 - status constant.BidStatus - } - tests := []struct { - name string - args args - want want - }{ - { - name: "empty_duration_policy", - args: args{ - duration: 10, - policy: "", - config: []*types.ImpAdPodConfig{ - {MaxDuration: 10}, - {MaxDuration: 20}, - {MaxDuration: 30}, - {MaxDuration: 40}, - }, - }, - want: want{ - duration: 10, - status: constant.StatusOK, - }, - }, - { - name: "policy_exact", - args: args{ - duration: 10, - policy: openrtb_ext.OWExactVideoAdDurationMatching, - config: []*types.ImpAdPodConfig{ - {MaxDuration: 10}, - {MaxDuration: 20}, - {MaxDuration: 30}, - {MaxDuration: 40}, - }, - }, - want: want{ - duration: 10, - status: constant.StatusOK, - }, - }, - { - name: "policy_exact_didnot_match", - args: args{ - duration: 15, - policy: openrtb_ext.OWExactVideoAdDurationMatching, - config: []*types.ImpAdPodConfig{ - {MaxDuration: 10}, - {MaxDuration: 20}, - {MaxDuration: 30}, - {MaxDuration: 40}, - }, - }, - want: want{ - duration: 15, - status: constant.StatusDurationMismatch, - }, - }, - { - name: "policy_roundup_exact", - args: args{ - duration: 20, - policy: openrtb_ext.OWRoundupVideoAdDurationMatching, - config: []*types.ImpAdPodConfig{ - {MaxDuration: 10}, - {MaxDuration: 20}, - {MaxDuration: 30}, - {MaxDuration: 40}, - }, - }, - want: want{ - duration: 20, - status: constant.StatusOK, - }, - }, - { - name: "policy_roundup", - args: args{ - duration: 25, - policy: openrtb_ext.OWRoundupVideoAdDurationMatching, - config: []*types.ImpAdPodConfig{ - {MaxDuration: 10}, - {MaxDuration: 20}, - {MaxDuration: 30}, - {MaxDuration: 40}, - }, - }, - want: want{ - duration: 30, - status: constant.StatusOK, - }, - }, - { - name: "policy_roundup_didnot_match", - args: args{ - duration: 45, - policy: openrtb_ext.OWRoundupVideoAdDurationMatching, - config: []*types.ImpAdPodConfig{ - {MaxDuration: 10}, - {MaxDuration: 20}, - {MaxDuration: 30}, - {MaxDuration: 40}, - }, - }, - want: want{ - duration: 45, - status: constant.StatusDurationMismatch, - }, - }, - - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - duration, status := getDurationBasedOnDurationMatchingPolicy(tt.args.duration, tt.args.policy, tt.args.config) - assert.Equal(t, tt.want.duration, duration) - assert.Equal(t, tt.want.status, status) }) } } @@ -596,7 +268,6 @@ func TestCreateAdPodBidResponse(t *testing.T) { ID: "id1", Cur: "USD", CustomData: "custom", - SeatBid: make([]openrtb2.SeatBid, 0), }, }, }, @@ -608,75 +279,13 @@ func TestCreateAdPodBidResponse(t *testing.T) { ID: "1", }, } - actual := deps.createAdPodBidResponse(tt.args.resp, nil) + actual := deps.createAdPodBidResponse(tt.args.resp) assert.Equal(t, tt.want.resp, actual) }) } } -func TestSetBidExtParams(t *testing.T) { - type args struct { - impData []*types.ImpData - } - type want struct { - impData []*types.ImpData - } - tests := []struct { - name string - args args - want want - }{ - { - name: "sample", - args: args{ - impData: []*types.ImpData{ - { - Bid: &types.AdPodBid{ - Bids: []*types.Bid{ - { - Bid: &openrtb2.Bid{ - Ext: json.RawMessage(`{"prebid": {"video": {} },"adpod": {}}`), - }, - Duration: 10, - Status: 1, - }, - }, - }, - }, - }, - }, - want: want{ - impData: []*types.ImpData{ - { - Bid: &types.AdPodBid{ - Bids: []*types.Bid{ - { - Bid: &openrtb2.Bid{ - Ext: json.RawMessage(`{"prebid": {"video": {"duration":10} },"adpod": {"aprc":1}}`), - }, - Duration: 10, - Status: 1, - }, - }, - }, - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - - deps := ctvEndpointDeps{ - impData: tt.args.impData, - } - deps.setBidExtParams() - assert.Equal(t, tt.want.impData[0].Bid.Bids[0].Ext, deps.impData[0].Bid.Bids[0].Ext) - }) - } -} - func TestGetAdPodExt(t *testing.T) { type args struct { resp *openrtb2.BidResponse @@ -742,23 +351,25 @@ func TestGetAdPodExt(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - deps := ctvEndpointDeps{ - impData: []*types.ImpData{ + req := &openrtb2.BidRequest{ + Imp: []openrtb2.Imp{ { - ImpID: "imp1", - VideoExt: &openrtb_ext.ExtVideoAdPod{ - AdPod: &openrtb_ext.VideoAdPod{}, - }, - Bid: &types.AdPodBid{ - Bids: []*types.Bid{}, - }, + ID: "imp1", + Video: &openrtb2.Video{}, }, }, - request: &openrtb2.BidRequest{ - Imp: []openrtb2.Imp{ - {ID: "imp1"}, - }, + } + + videoExt := openrtb_ext.ExtVideoAdPod{ + AdPod: &openrtb_ext.VideoAdPod{}, + } + dynamicAdpod := adpod.NewDynamicAdpod("test-pub", req.Imp[0], videoExt, &metrics.MetricsEngineMock{}, nil) + + deps := ctvEndpointDeps{ + podCtx: map[string]adpod.Adpod{ + "imp1": dynamicAdpod, }, + request: req, } actual := deps.getBidResponseExt(tt.args.resp) assert.Equal(t, string(tt.want.data), string(actual)) @@ -766,122 +377,220 @@ func TestGetAdPodExt(t *testing.T) { } } -func TestRecordAdPodRejectedBids(t *testing.T) { - - type args struct { - bids types.AdPodBid +func TestGetAdpodConfigFromExtension(t *testing.T) { + type fields struct { + endpointDeps endpointDeps + request *openrtb2.BidRequest + reqExt *openrtb_ext.ExtRequestAdPod + videoSeats []*openrtb2.SeatBid + impsExtPrebidBidder map[string]map[string]map[string]interface{} + impPartnerBlockedTagIDMap map[string]map[string][]string + podCtx map[string]adpod.Adpod + labels metrics.Labels } - - type want struct { - expectedCalls int + type args struct { + imp openrtb2.Imp } - tests := []struct { - description string - args args - want want + name string + fields fields + args args + want openrtb_ext.ExtVideoAdPod + wantErr bool }{ { - description: "multiple rejected bids", + name: "Adpod_Config_available_in_the_impression_extension", + fields: fields{ + endpointDeps: endpointDeps{}, + request: &openrtb2.BidRequest{}, + }, args: args{ - bids: types.AdPodBid{ - Bids: []*types.Bid{ - { - Bid: &openrtb2.Bid{}, - Status: constant.StatusCategoryExclusion, - Seat: "pubmatic", - }, - { - Bid: &openrtb2.Bid{}, - Status: constant.StatusWinningBid, - Seat: "pubmatic", - }, - { - Bid: &openrtb2.Bid{}, - Status: constant.StatusOK, - Seat: "pubmatic", - }, - { - Bid: &openrtb2.Bid{}, - Status: 100, - Seat: "pubmatic", - }, + imp: openrtb2.Imp{ + ID: "imp1", + TagID: "/Test/unit", + Video: &openrtb2.Video{ + MinDuration: 10, + MaxDuration: 30, + Ext: json.RawMessage(`{"offset":20,"adpod":{"minads":2,"maxads":3,"adminduration":30,"admaxduration":40,"excladv":100,"excliabcat":100}}`), }, }, }, - want: want{ - expectedCalls: 2, + want: openrtb_ext.ExtVideoAdPod{ + Offset: ptrutil.ToPtr(20), + AdPod: &openrtb_ext.VideoAdPod{ + MinAds: ptrutil.ToPtr(2), + MaxAds: ptrutil.ToPtr(3), + MinDuration: ptrutil.ToPtr(30), + MaxDuration: ptrutil.ToPtr(40), + AdvertiserExclusionPercent: ptrutil.ToPtr(100), + IABCategoryExclusionPercent: ptrutil.ToPtr(100), + }, }, }, - } - - for _, test := range tests { - me := &metrics.MetricsEngineMock{} - me.On("RecordRejectedBids", mock.Anything, mock.Anything, mock.Anything).Return() - - deps := ctvEndpointDeps{ - endpointDeps: endpointDeps{ - metricsEngine: me, + { + name: "video_extension_contains_values_other_than_adpod", + fields: fields{ + endpointDeps: endpointDeps{}, + request: &openrtb2.BidRequest{}, }, - impData: []*types.ImpData{ - { - Bid: &test.args.bids, + args: args{ + imp: openrtb2.Imp{ + ID: "imp1", + TagID: "/Test/unit", + Video: &openrtb2.Video{ + MinDuration: 10, + MaxDuration: 30, + Ext: json.RawMessage(`{"random":20}`), + }, }, }, - } - - deps.recordRejectedAdPodBids("pub_001") - me.AssertNumberOfCalls(t, "RecordRejectedBids", test.want.expectedCalls) - } -} - -func TestGetAdPodBidCreative(t *testing.T) { - type args struct { - adpod *types.AdPodBid - generatedBidID bool - } - tests := []struct { - name string - args args - want string - }{ + want: openrtb_ext.ExtVideoAdPod{}, + }, { - name: "VAST_element_missing_in_adm", + name: "adpod_configuration_present_in_request_extension", + fields: fields{ + endpointDeps: endpointDeps{}, + request: &openrtb2.BidRequest{}, + reqExt: &openrtb_ext.ExtRequestAdPod{ + VideoAdPod: &openrtb_ext.VideoAdPod{ + MinAds: ptrutil.ToPtr(1), + MaxAds: ptrutil.ToPtr(3), + MinDuration: ptrutil.ToPtr(10), + MaxDuration: ptrutil.ToPtr(30), + AdvertiserExclusionPercent: ptrutil.ToPtr(100), + IABCategoryExclusionPercent: ptrutil.ToPtr(100), + }, + }, + }, args: args{ - adpod: &types.AdPodBid{ - Bids: []*types.Bid{ - { - Bid: &openrtb2.Bid{ - AdM: "any_creative_without_vast", - }, - }, + imp: openrtb2.Imp{ + ID: "imp1", + TagID: "/Test/unit", + Video: &openrtb2.Video{ + MinDuration: 10, + MaxDuration: 30, }, }, - generatedBidID: false, }, - want: "", + want: openrtb_ext.ExtVideoAdPod{ + Offset: ptrutil.ToPtr(0), + AdPod: &openrtb_ext.VideoAdPod{ + MinAds: ptrutil.ToPtr(1), + MaxAds: ptrutil.ToPtr(3), + MinDuration: ptrutil.ToPtr(5), + MaxDuration: ptrutil.ToPtr(15), + AdvertiserExclusionPercent: ptrutil.ToPtr(100), + IABCategoryExclusionPercent: ptrutil.ToPtr(100), + }, + }, }, { - name: "VAST_element_present_in_adm", + name: "adpod_configuration_not_availbale_in_any_location", + fields: fields{ + endpointDeps: endpointDeps{}, + request: &openrtb2.BidRequest{}, + }, args: args{ - adpod: &types.AdPodBid{ - Bids: []*types.Bid{ - { - Bid: &openrtb2.Bid{ - AdM: "url_creative", - }, - }, + imp: openrtb2.Imp{ + ID: "imp1", + TagID: "/Test/unit", + Video: &openrtb2.Video{ + MinDuration: 10, + MaxDuration: 30, }, }, - generatedBidID: false, }, - want: "", + want: openrtb_ext.ExtVideoAdPod{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := &ctvEndpointDeps{ + endpointDeps: tt.fields.endpointDeps, + request: tt.fields.request, + reqExt: tt.fields.reqExt, + videoSeats: tt.fields.videoSeats, + impsExtPrebidBidder: tt.fields.impsExtPrebidBidder, + impPartnerBlockedTagIDMap: tt.fields.impPartnerBlockedTagIDMap, + podCtx: tt.fields.podCtx, + labels: tt.fields.labels, + } + got, err := deps.readVideoAdPodExt(tt.args.imp) + if (err != nil) != tt.wantErr { + t.Errorf("ctvEndpointDeps.getAdpodConfigFromExtension() error = %v, wantErr %v", err, tt.wantErr) + return + } + assert.Equal(t, tt.want, got, "Adpod config does not match") + }) + } +} + +func TestCTVAuctionEndpointAdpod(t *testing.T) { + type args struct { + w http.ResponseWriter + r *http.Request + params httprouter.Params + } + tests := []struct { + name string + directory string + fileName string + args args + modifyResponse func(resp1, resp2 json.RawMessage) (json.RawMessage, error) + }{ + { + name: "dynamic_adpod_request", + args: args{}, + directory: "sample-requests/ctv/valid-requests/", + fileName: "dynamic-adpod.json", + }, + { + name: "structured_adpod_request", + args: args{}, + directory: "sample-requests/ctv/valid-requests/", + fileName: "structured-adpod.json", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := getAdPodBidCreative(tt.args.adpod, tt.args.generatedBidID) - assert.Equalf(t, tt.want, *got, "found incorrect creative") + // Read test case and unmarshal + fileJsonData, err := os.ReadFile(tt.directory + tt.fileName) + assert.NoError(t, err, "Failed to fetch a valid request: %v. Test file: %s", err, tt.fileName) + + test := ctvtestCase{} + assert.NoError(t, json.Unmarshal(fileJsonData, &test), "Failed to unmarshal data from file: %s. Error: %v", tt.fileName, err) + + tt.args.r = httptest.NewRequest("POST", "/video/json", bytes.NewReader(test.BidRequest)) + recorder := httptest.NewRecorder() + + cfg := &config.Configuration{ + MaxRequestSize: maxSize, + GDPR: config.GDPR{Enabled: true}, + } + if test.Config != nil { + cfg.BlacklistedApps = test.Config.BlacklistedApps + cfg.BlacklistedAppMap = test.Config.getBlacklistedAppMap() + cfg.AccountRequired = test.Config.AccountRequired + } + + CTVAuctionEndpoint, _, mockBidServers, mockCurrencyRatesServer, err := ctvTestEndpoint(test, cfg) + assert.NoError(t, err, "Error while calling ctv auction endpoint %v", err) + + CTVAuctionEndpoint(recorder, tt.args.r, tt.args.params) + + // Close servers + for _, mockBidServer := range mockBidServers { + mockBidServer.Close() + } + mockCurrencyRatesServer.Close() + + // if assert.Equal(t, test.ExpectedReturnCode, recorder.Code, "Expected status %d. Got %d. CTV test file: %s", http.StatusOK, recorder.Code, tt.fileName) { + // if test.ExpectedReturnCode == http.StatusOK { + // assert.JSONEq(t, string(test.ExpectedBidResponse), recorder.Body.String(), "Not the expected response. Test file: %s", tt.fileName) + // } else { + // assert.Equal(t, test.ExpectedErrorMessage, recorder.Body.String(), tt.fileName) + // } + // } }) } } diff --git a/endpoints/openrtb2/ctv_test_utils.go b/endpoints/openrtb2/ctv_test_utils.go new file mode 100644 index 00000000000..492f2f728df --- /dev/null +++ b/endpoints/openrtb2/ctv_test_utils.go @@ -0,0 +1,317 @@ +package openrtb2 + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "time" + + "github.com/julienschmidt/httprouter" + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v2/adapters" + analyticsBuild "github.com/prebid/prebid-server/v2/analytics/build" + "github.com/prebid/prebid-server/v2/config" + "github.com/prebid/prebid-server/v2/currency" + "github.com/prebid/prebid-server/v2/errortypes" + "github.com/prebid/prebid-server/v2/exchange" + "github.com/prebid/prebid-server/v2/experiment/adscert" + "github.com/prebid/prebid-server/v2/floors" + "github.com/prebid/prebid-server/v2/hooks" + "github.com/prebid/prebid-server/v2/macros" + "github.com/prebid/prebid-server/v2/metrics" + metricsConfig "github.com/prebid/prebid-server/v2/metrics/config" + "github.com/prebid/prebid-server/v2/openrtb_ext" + "github.com/prebid/prebid-server/v2/stored_requests" + "github.com/prebid/prebid-server/v2/stored_requests/backends/empty_fetcher" +) + +type ctvtestCase struct { + Description string `json:"description"` + Config *ctvtestConfigValues `json:"config"` + BidRequest json.RawMessage `json:"mockBidRequest"` + ExpectedValidatedBidReq json.RawMessage `json:"expectedValidatedBidRequest"` + ExpectedReturnCode int `json:"expectedReturnCode,omitempty"` + ExpectedErrorMessage string `json:"expectedErrorMessage"` + Query string `json:"query"` + planBuilder hooks.ExecutionPlanBuilder + ExpectedBidResponse json.RawMessage `json:"expectedBidResponse"` +} + +type ctvtestConfigValues struct { + AccountRequired bool `json:"accountRequired"` + AliasJSON string `json:"aliases"` + BlacklistedApps []string `json:"blacklistedApps"` + DisabledAdapters []string `json:"disabledAdapters"` + CurrencyRates map[string]map[string]float64 `json:"currencyRates"` + MockBidders []ctvMockBidderHandler `json:"mockBidders"` + RealParamsValidator bool `json:"realParamsValidator"` + AssertBidExt bool `json:"assertbidext"` +} + +func (tc *ctvtestConfigValues) getBlacklistedAppMap() map[string]bool { + var blacklistedAppMap map[string]bool + + if len(tc.BlacklistedApps) > 0 { + blacklistedAppMap = make(map[string]bool, len(tc.BlacklistedApps)) + for _, app := range tc.BlacklistedApps { + blacklistedAppMap[app] = true + } + } + return blacklistedAppMap +} + +type ctvMockBidderHandler struct { + BidderName string `json:"bidderName"` + Currency string `json:"currency"` + Bids []mockBid `json:"bids"` +} + +type mockBid struct { + ImpId string `json:"impid"` + Price float64 `json:"price"` + DealID string `json:"dealid,omitempty"` + Cat []string `json:"cat,omitempty"` + ADomain []string `json:"adomain,omitempty"` +} + +func (b ctvMockBidderHandler) bid(w http.ResponseWriter, req *http.Request) { + // Read request Body + buf := new(bytes.Buffer) + buf.ReadFrom(req.Body) + + // Unmarshal exit if error + var openrtb2Request openrtb2.BidRequest + if err := json.Unmarshal(buf.Bytes(), &openrtb2Request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var openrtb2ImpExt map[string]json.RawMessage + if err := json.Unmarshal(openrtb2Request.Imp[0].Ext, &openrtb2ImpExt); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + _, exists := openrtb2ImpExt["bidder"] + if !exists { + http.Error(w, "This request is not meant for this bidder", http.StatusBadRequest) + return + } + + var bids []openrtb2.Bid + var seq int + for _, bid := range b.Bids { + bids = append(bids, openrtb2.Bid{ + ID: b.BidderName + "-bid-" + strconv.Itoa(seq), + ImpID: bid.ImpId, + Price: bid.Price, + AdM: "", + DealID: bid.DealID, + Cat: bid.Cat, + ADomain: bid.ADomain, + }) + seq++ + + } + + // Create bid service openrtb2.BidResponse with one bid according to JSON test file values + var serverResponseObject = openrtb2.BidResponse{ + ID: openrtb2Request.ID, + Cur: b.Currency, + SeatBid: []openrtb2.SeatBid{ + { + Bid: bids, + Seat: b.BidderName, + }, + }, + } + + // Marshal the response and http write it + serverJsonResponse, err := json.Marshal(&serverResponseObject) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write(serverJsonResponse) + return +} + +func ctvTestEndpoint(test ctvtestCase, cfg *config.Configuration) (httprouter.Handle, *exchangeTestWrapper, []*httptest.Server, *httptest.Server, error) { + if test.Config == nil { + test.Config = &ctvtestConfigValues{} + } + + var paramValidator openrtb_ext.BidderParamValidator + if test.Config.RealParamsValidator { + var err error + paramValidator, err = openrtb_ext.NewBidderParamsValidator("../../static/bidder-params") + if err != nil { + return nil, nil, nil, nil, err + } + } else { + paramValidator = mockBidderParamValidator{} + } + + bidderInfos := getBidderInfos(test.Config.DisabledAdapters, openrtb_ext.CoreBidderNames()) + bidderMap := exchange.GetActiveBidders(bidderInfos) + disabledBidders := exchange.GetDisabledBidderWarningMessages(bidderInfos) + met := &metricsConfig.NilMetricsEngine{} + mockFetcher := empty_fetcher.EmptyFetcher{} + + // Adapter map with mock adapters needed to run JSON test cases + adapterMap := make(map[openrtb_ext.BidderName]exchange.AdaptedBidder, 0) + mockBidServersArray := make([]*httptest.Server, 0, 3) + + // Mock prebid Server's currency converter, instantiate and start + mockCurrencyConversionService := mockCurrencyRatesClient{ + currencyInfo{ + Conversions: test.Config.CurrencyRates, + }, + } + mockCurrencyRatesServer := httptest.NewServer(http.HandlerFunc(mockCurrencyConversionService.handle)) + + testExchange, mockBidServersArray := testCTVExchange(test.Config, adapterMap, mockBidServersArray, mockCurrencyRatesServer, bidderInfos, cfg, met, mockFetcher) + + planBuilder := test.planBuilder + if planBuilder == nil { + planBuilder = hooks.EmptyPlanBuilder{} + } + + endpoint, err := NewCTVEndpoint( + testExchange, + paramValidator, + &mockStoredReqFetcher{}, + &mockStoredReqFetcher{}, + &mockAccountFetcher{}, + cfg, + met, + analyticsBuild.New(&config.Analytics{}), + disabledBidders, + []byte(test.Config.AliasJSON), + bidderMap, + planBuilder, + nil, + ) + + return endpoint, testExchange.(*exchangeTestWrapper), mockBidServersArray, mockCurrencyRatesServer, err +} + +func testCTVExchange(testCfg *ctvtestConfigValues, adapterMap map[openrtb_ext.BidderName]exchange.AdaptedBidder, mockBidServersArray []*httptest.Server, mockCurrencyRatesServer *httptest.Server, bidderInfos config.BidderInfos, cfg *config.Configuration, met metrics.MetricsEngine, mockFetcher stored_requests.CategoryFetcher) (exchange.Exchange, []*httptest.Server) { + if len(testCfg.MockBidders) == 0 { + testCfg.MockBidders = append(testCfg.MockBidders, ctvMockBidderHandler{BidderName: "pubmatic", Currency: "USD", Bids: []mockBid{ + { + ImpId: "imp1", + Price: 0, + }, + }}) + } + for _, mockBidder := range testCfg.MockBidders { + bidServer := httptest.NewServer(http.HandlerFunc(mockBidder.bid)) + bidderAdapter := ctvMockAdapter{mockServerURL: bidServer.URL} + bidderName := openrtb_ext.BidderName(mockBidder.BidderName) + + adapterMap[bidderName] = exchange.AdaptBidder(bidderAdapter, bidServer.Client(), &config.Configuration{}, &metricsConfig.NilMetricsEngine{}, bidderName, nil, "") + mockBidServersArray = append(mockBidServersArray, bidServer) + } + + mockCurrencyConverter := currency.NewRateConverter(mockCurrencyRatesServer.Client(), mockCurrencyRatesServer.URL, time.Second) + mockCurrencyConverter.Run() + + gdprPermsBuilder := fakePermissionsBuilder{ + permissions: &fakePermissions{}, + }.Builder + + testExchange := exchange.NewExchange(adapterMap, + &wellBehavedCache{}, + cfg, + nil, + met, + bidderInfos, + gdprPermsBuilder, + mockCurrencyConverter, + mockFetcher, + &adscert.NilSigner{}, + macros.NewStringIndexBasedReplacer(), + &floors.PriceFloorFetcher{}, + ) + + testExchange = &exchangeTestWrapper{ + ex: testExchange, + } + + return testExchange, mockBidServersArray +} + +type ctvMockAdapter struct { + mockServerURL string + Server config.Server +} + +func CTVBuilder(bidderName openrtb_ext.BidderName, config config.Adapter, server config.Server) (adapters.Bidder, error) { + adapter := &ctvMockAdapter{ + mockServerURL: config.Endpoint, + Server: server, + } + return adapter, nil +} + +func (a ctvMockAdapter) MakeRequests(request *openrtb2.BidRequest, requestInfo *adapters.ExtraRequestInfo) ([]*adapters.RequestData, []error) { + var requests []*adapters.RequestData + var errors []error + + requestJSON, err := json.Marshal(request) + if err != nil { + errors = append(errors, err) + return nil, errors + } + + requestData := &adapters.RequestData{ + Method: "POST", + Uri: a.mockServerURL, + Body: requestJSON, + } + requests = append(requests, requestData) + return requests, errors +} + +func (a ctvMockAdapter) MakeBids(request *openrtb2.BidRequest, requestData *adapters.RequestData, responseData *adapters.ResponseData) (*adapters.BidderResponse, []error) { + if responseData.StatusCode != http.StatusOK { + switch responseData.StatusCode { + case http.StatusNoContent: + return nil, nil + case http.StatusBadRequest: + return nil, []error{&errortypes.BadInput{ + Message: "Unexpected status code: 400. Bad request from publisher. Run with request.debug = 1 for more info.", + }} + default: + return nil, []error{&errortypes.BadServerResponse{ + Message: fmt.Sprintf("Unexpected status code: %d. Run with request.debug = 1 for more info.", responseData.StatusCode), + }} + } + } + + var publisherResponse openrtb2.BidResponse + if err := json.Unmarshal(responseData.Body, &publisherResponse); err != nil { + return nil, []error{err} + } + + rv := adapters.NewBidderResponseWithBidsCapacity(len(request.Imp)) + rv.Currency = publisherResponse.Cur + for _, seatBid := range publisherResponse.SeatBid { + for i, bid := range seatBid.Bid { + for _, imp := range request.Imp { + if imp.ID == bid.ImpID { + b := &adapters.TypedBid{ + Bid: &seatBid.Bid[i], + BidType: openrtb_ext.BidTypeVideo, + } + rv.Bids = append(rv.Bids, b) + } + } + } + } + return rv, nil +} diff --git a/endpoints/openrtb2/sample-requests/ctv/valid-requests/dynamic-adpod.json b/endpoints/openrtb2/sample-requests/ctv/valid-requests/dynamic-adpod.json new file mode 100644 index 00000000000..5955d33cd0e --- /dev/null +++ b/endpoints/openrtb2/sample-requests/ctv/valid-requests/dynamic-adpod.json @@ -0,0 +1,466 @@ +{ + "description": "Dynamic adpod request", + "config": { + "mockBidders": [ + { + "bidderName": "pubmatic", + "currency": "USD", + "bids": [ + { + "impid": "pod::imp_1", + "price": 2, + "duration": 30 + }, + { + "impid": "pod::imp_2", + "price": 3, + "duration": 30 + }, + { + "impid": "pod::imp_3", + "price": 5, + "duration": 30 + } + ] + }, + { + "bidderName": "appnexus", + "currency": "USD", + "bids": [ + { + "impid": "pod::imp_1", + "price": 4, + "duration": 30 + }, + { + "impid": "pod::imp_2", + "price": 2, + "duration": 30 + }, + { + "impid": "pod::imp_3", + "price": 5, + "duration": 30 + } + ] + } + ] + }, + "mockBidRequest": { + "id": "1559039248176", + "cur": [ + "USD" + ], + "imp": [ + { + "id": "pod::imp", + "video": { + "mimes": [ + "video/3gpp", + "video/mp4", + "video/webm" + ], + "startdelay": 0, + "protocols": [ + 2, + 3, + 5, + 6, + 7, + 8 + ], + "placement": 5, + "linearity": 1, + "skip": 1, + "skipmin": 10, + "skipafter": 15, + "battr": [ + 6, + 7 + ], + "maxbitrate": 2000, + "boxingallowed": 1, + "playbackmethod": [ + 1 + ], + "playbackend": 1, + "delivery": [ + 2 + ], + "pos": 7, + "podid": "pod1", + "podseq": 0, + "slotinpod": 0, + "maxseq": 3, + "poddur": 90, + "minduration": 30, + "maxduration": 30 + }, + "tagid": "/15671365/MG_VideoAdUnit", + "secure": 0, + "ext": { + "data": { + "pbadslot": "/15671365/MG_VideoAdUnit" + }, + "prebid": { + "bidder": { + "pubmatic": { + "publisherId": "5890", + "adSlot": "/15671365/MG_VideoAdUnit@0x0" + }, + "appnexus": { + "placementId": 12883451 + } + } + } + } + } + ], + "app": { + "name": "OpenWrapperSample", + "bundle": "com.pubmatic.openbid.app", + "storeurl": "https://itunes.apple.com/us/app/pubmatic-sdk-app/id1175273098?videobid=10", + "ver": "1.0", + "publisher": { + "id": "5890" + } + }, + "device": { + "ua": "PostmanRuntime/7.36.3", + "ip": "::1" + }, + "at": 1, + "tmax": 194999, + "source": { + "tid": "1559039248176" + }, + "ext": { + "prebid": { + "aliases": { + "adg": "adgeneration", + "andbeyond": "adkernel", + "districtm": "appnexus", + "districtmDMX": "dmx", + "mediafuse": "appnexus", + "pubmatic2": "pubmatic" + }, + "bidadjustmentfactors": { + "pubmatic": 0.9 + }, + "bidderparams": { + "appnexus": { + "placementId": 12883451 + }, + "pubmatic": { + "wiid": "34d54ecd-fc14-4a52-a6dd-30dcba780a0d" + } + }, + "debug": true, + "floors": { + "enforcement": { + "enforcepbs": true + }, + "enabled": true + }, + "targeting": { + "pricegranularity": { + "precision": 2, + "ranges": [ + { + "min": 0, + "max": 100, + "increment": 0.5 + } + ] + }, + "mediatypepricegranularity": {}, + "includewinners": true, + "includebidderkeys": true, + "includebrandcategory": { + "primaryadserver": 0, + "publisher": "", + "withcategory": false, + "translatecategories": false, + "skipdedup": true + } + }, + "macros": { + "[PLATFORM]": "3", + "[PROFILE_ID]": "81367", + "[PROFILE_VERSION]": "1", + "[UNIX_TIMESTAMP]": "1711103402", + "[WRAPPER_IMPRESSION_ID]": "34d54ecd-fc14-4a52-a6dd-30dcba780a0d" + }, + "returnallbidstatus": true + } + } + }, + "expectedBidResponse": { + "id": "1559039248176", + "seatbid": [ + { + "bid": [ + { + "id": "ecb93ab7-4b90-47ac-9414-c7f54a49cc69", + "impid": "pod::imp", + "price": 13.5, + "adm": "", + "ext": { + "prebid": { + "type": "video", + "video": { + "duration": 90, + "primary_category": "", + "vasttagid": "" + } + }, + "adpod": { + "refbids": [ + "appnexus-bid-2", + "pubmatic-bid-2", + "appnexus-bid-0" + ] + } + } + } + ], + "seat": "prebid_ctv" + } + ], + "cur": "USD", + "ext": { + "warnings": { + "general": [ + { + "code": 10002, + "message": "debug turned off for account" + } + ] + }, + "responsetimemillis": { + "appnexus": 1, + "pubmatic": 1 + }, + "tmaxrequest": 194999, + "prebid": { + "auctiontimestamp": 1712519355306 + }, + "adpod": { + "bidresponse": { + "id": "1559039248176", + "seatbid": [ + { + "bid": [ + { + "id": "appnexus-bid-0", + "impid": "pod::imp_1", + "price": 4, + "adm": "", + "ext": { + "origbidcpm": 4, + "origbidcur": "USD", + "prebid": { + "meta": { + "adaptercode": "appnexus" + }, + "targeting": { + "hb_bidder": "appnexus", + "hb_bidder_appnexus": "appnexus", + "hb_cache_host": "www.pbcserver.com", + "hb_cache_host_appnex": "www.pbcserver.com", + "hb_cache_path": "/pbcache/endpoint", + "hb_cache_path_appnex": "/pbcache/endpoint", + "hb_env": "mobile-app", + "hb_env_appnexus": "mobile-app", + "hb_pb": "4.00", + "hb_pb_appnexus": "4.00", + "hb_pb_cat_dur": "4.00_30s", + "hb_pb_cat_dur_appnex": "4.00_30s" + }, + "type": "video" + } + } + }, + { + "id": "appnexus-bid-1", + "impid": "pod::imp_2", + "price": 2, + "adm": "", + "ext": { + "origbidcpm": 2, + "origbidcur": "USD", + "prebid": { + "meta": { + "adaptercode": "appnexus" + }, + "targeting": { + "hb_bidder_appnexus": "appnexus", + "hb_cache_host_appnex": "www.pbcserver.com", + "hb_cache_path_appnex": "/pbcache/endpoint", + "hb_env_appnexus": "mobile-app", + "hb_pb_appnexus": "2.00", + "hb_pb_cat_dur_appnex": "2.00_30s" + }, + "type": "video" + } + } + }, + { + "id": "appnexus-bid-2", + "impid": "pod::imp_3", + "price": 5, + "adm": "", + "ext": { + "origbidcpm": 5, + "origbidcur": "USD", + "prebid": { + "meta": { + "adaptercode": "appnexus" + }, + "targeting": { + "hb_bidder": "appnexus", + "hb_bidder_appnexus": "appnexus", + "hb_cache_host": "www.pbcserver.com", + "hb_cache_host_appnex": "www.pbcserver.com", + "hb_cache_path": "/pbcache/endpoint", + "hb_cache_path_appnex": "/pbcache/endpoint", + "hb_env": "mobile-app", + "hb_env_appnexus": "mobile-app", + "hb_pb": "5.00", + "hb_pb_appnexus": "5.00", + "hb_pb_cat_dur": "5.00_30s", + "hb_pb_cat_dur_appnex": "5.00_30s" + }, + "type": "video" + } + } + } + ], + "seat": "appnexus" + }, + { + "bid": [ + { + "id": "pubmatic-bid-0", + "impid": "pod::imp_1", + "price": 1.8, + "adm": "", + "ext": { + "origbidcpm": 2, + "origbidcur": "USD", + "prebid": { + "meta": { + "adaptercode": "pubmatic" + }, + "targeting": { + "hb_bidder_pubmatic": "pubmatic", + "hb_cache_host_pubmat": "www.pbcserver.com", + "hb_cache_path_pubmat": "/pbcache/endpoint", + "hb_env_pubmatic": "mobile-app", + "hb_pb_cat_dur_pubmat": "1.50_30s", + "hb_pb_pubmatic": "1.50" + }, + "type": "video" + } + } + }, + { + "id": "pubmatic-bid-1", + "impid": "pod::imp_2", + "price": 2.7, + "adm": "", + "ext": { + "origbidcpm": 3, + "origbidcur": "USD", + "prebid": { + "meta": { + "adaptercode": "pubmatic" + }, + "targeting": { + "hb_bidder": "pubmatic", + "hb_bidder_pubmatic": "pubmatic", + "hb_cache_host": "www.pbcserver.com", + "hb_cache_host_pubmat": "www.pbcserver.com", + "hb_cache_path": "/pbcache/endpoint", + "hb_cache_path_pubmat": "/pbcache/endpoint", + "hb_env": "mobile-app", + "hb_env_pubmatic": "mobile-app", + "hb_pb": "2.50", + "hb_pb_cat_dur": "2.50_30s", + "hb_pb_cat_dur_pubmat": "2.50_30s", + "hb_pb_pubmatic": "2.50" + }, + "type": "video" + } + } + }, + { + "id": "pubmatic-bid-2", + "impid": "pod::imp_3", + "price": 4.5, + "adm": "", + "ext": { + "origbidcpm": 5, + "origbidcur": "USD", + "prebid": { + "meta": { + "adaptercode": "pubmatic" + }, + "targeting": { + "hb_bidder_pubmatic": "pubmatic", + "hb_cache_host_pubmat": "www.pbcserver.com", + "hb_cache_path_pubmat": "/pbcache/endpoint", + "hb_env_pubmatic": "mobile-app", + "hb_pb_cat_dur_pubmat": "4.50_30s", + "hb_pb_pubmatic": "4.50" + }, + "type": "video" + } + } + } + ], + "seat": "pubmatic" + } + ], + "cur": "USD" + }, + "config": { + "pod1": { + "vidext": { + "offset": 0, + "adpod": { + "minads": 1, + "maxads": 3, + "adminduration": 30, + "admaxduration": 30, + "excladv": 100, + "excliabcat": 100 + } + }, + "imp": [ + { + "id": "pod::imp_1", + "seq": 1, + "minduration": 30, + "maxduration": 30 + }, + { + "id": "pod::imp_2", + "seq": 2, + "minduration": 30, + "maxduration": 30 + }, + { + "id": "pod::imp_3", + "seq": 3, + "minduration": 30, + "maxduration": 30 + } + ] + } + } + } + } + }, + "expectedReturnCode": 200 +} \ No newline at end of file diff --git a/endpoints/openrtb2/sample-requests/ctv/valid-requests/hybid-adpod.json b/endpoints/openrtb2/sample-requests/ctv/valid-requests/hybid-adpod.json new file mode 100644 index 00000000000..5312129768c --- /dev/null +++ b/endpoints/openrtb2/sample-requests/ctv/valid-requests/hybid-adpod.json @@ -0,0 +1,338 @@ +{ + "description": "Hybrid adpod request", + "config": {}, + "mockBidRequest": { + "id": "1559039248176", + "cur": [ + "USD" + ], + "imp": [ + { + "id": "pod1::imp1", + "video": { + "mimes": [ + "video/3gpp", + "video/mp4", + "video/webm" + ], + "startdelay": 0, + "protocols": [ + 2, + 3, + 5, + 6, + 7, + 8 + ], + "placement": 5, + "linearity": 1, + "skip": 1, + "skipmin": 10, + "skipafter": 15, + "battr": [ + 6, + 7 + ], + "maxbitrate": 2000, + "boxingallowed": 1, + "playbackmethod": [ + 1 + ], + "playbackend": 1, + "delivery": [ + 2 + ], + "pos": 7, + "podid": "pod1", + "podseq": 1, + "slotinpod": 1, + "minduration": 15, + "maxduration": 30 + }, + "tagid": "/15671365/MG_VideoAdUnit", + "secure": 0, + "ext": { + "data": { + "pbadslot": "/15671365/MG_VideoAdUnit" + }, + "prebid": { + "bidder": { + "pubmatic": { + "publisherId": "5890", + "adSlot": "/15671365/MG_VideoAdUnit@0x0" + }, + "appnexus": { + "placementId": 12883451 + } + } + } + } + }, + { + "id": "pod1::imp2", + "video": { + "mimes": [ + "video/3gpp", + "video/mp4", + "video/webm" + ], + "startdelay": 0, + "protocols": [ + 2, + 3, + 5, + 6, + 7, + 8 + ], + "placement": 5, + "linearity": 1, + "skip": 1, + "skipmin": 10, + "skipafter": 15, + "battr": [ + 6, + 7 + ], + "maxbitrate": 2000, + "boxingallowed": 1, + "playbackmethod": [ + 1 + ], + "playbackend": 1, + "delivery": [ + 2 + ], + "pos": 7, + "podid": "pod1", + "podseq": 1, + "slotinpod": 0, + "minduration": 10, + "maxduration": 30 + }, + "tagid": "/15671365/MG_VideoAdUnit", + "secure": 0, + "ext": { + "data": { + "pbadslot": "/15671365/MG_VideoAdUnit" + }, + "prebid": { + "bidder": { + "pubmatic": { + "publisherId": "5890", + "adSlot": "/15671365/MG_VideoAdUnit@0x0" + }, + "appnexus": { + "placementId": 12883451 + } + } + } + } + }, + { + "id": "pod1::imp3", + "video": { + "mimes": [ + "video/3gpp", + "video/mp4", + "video/webm" + ], + "startdelay": 0, + "protocols": [ + 2, + 3, + 5, + 6, + 7, + 8 + ], + "placement": 5, + "linearity": 1, + "skip": 1, + "skipmin": 10, + "skipafter": 15, + "battr": [ + 6, + 7 + ], + "maxbitrate": 2000, + "boxingallowed": 1, + "playbackmethod": [ + 1 + ], + "playbackend": 1, + "delivery": [ + 2 + ], + "pos": 7, + "podid": "pod1", + "podseq": 1, + "slotinpod": 0, + "minduration": 30, + "maxduration": 30 + }, + "tagid": "/15671365/MG_VideoAdUnit", + "secure": 0, + "ext": { + "data": { + "pbadslot": "/15671365/MG_VideoAdUnit" + }, + "prebid": { + "bidder": { + "pubmatic": { + "publisherId": "5890", + "adSlot": "/15671365/MG_VideoAdUnit@0x0" + }, + "appnexus": { + "placementId": 12883451 + } + } + } + } + }, + { + "id": "pod2::imp", + "video": { + "mimes": [ + "video/3gpp", + "video/mp4", + "video/webm" + ], + "startdelay": 0, + "protocols": [ + 2, + 3, + 5, + 6, + 7, + 8 + ], + "placement": 5, + "linearity": 1, + "skip": 1, + "skipmin": 10, + "skipafter": 15, + "battr": [ + 6, + 7 + ], + "maxbitrate": 2000, + "boxingallowed": 1, + "playbackmethod": [ + 1 + ], + "playbackend": 1, + "delivery": [ + 2 + ], + "pos": 7, + "podid": "pod1", + "podseq": 0, + "slotinpod": 0, + "maxseq": 3, + "poddur": 90, + "minduration": 30, + "maxduration": 30 + }, + "tagid": "/15671365/MG_VideoAdUnit", + "secure": 0, + "ext": { + "data": { + "pbadslot": "/15671365/MG_VideoAdUnit" + }, + "prebid": { + "bidder": { + "pubmatic": { + "publisherId": "5890", + "adSlot": "/15671365/MG_VideoAdUnit@0x0" + }, + "appnexus": { + "placementId": 12883451 + } + } + } + } + } + ], + "app": { + "name": "OpenWrapperSample", + "bundle": "com.pubmatic.openbid.app", + "storeurl": "https://itunes.apple.com/us/app/pubmatic-sdk-app/id1175273098?videobid=10", + "ver": "1.0", + "publisher": { + "id": "5890" + } + }, + "device": { + "ua": "PostmanRuntime/7.36.3", + "ip": "::1" + }, + "at": 1, + "tmax": 194999, + "source": { + "tid": "1559039248176" + }, + "ext": { + "prebid": { + "aliases": { + "adg": "adgeneration", + "andbeyond": "adkernel", + "districtm": "appnexus", + "districtmDMX": "dmx", + "mediafuse": "appnexus", + "pubmatic2": "pubmatic" + }, + "bidadjustmentfactors": { + "pubmatic": 0.9 + }, + "bidderparams": { + "appnexus": { + "placementId": 12883451 + }, + "pubmatic": { + "wiid": "34d54ecd-fc14-4a52-a6dd-30dcba780a0d" + } + }, + "debug": true, + "floors": { + "enforcement": { + "enforcepbs": true + }, + "enabled": true + }, + "targeting": { + "pricegranularity": { + "precision": 2, + "ranges": [ + { + "min": 0, + "max": 100, + "increment": 0.5 + } + ] + }, + "mediatypepricegranularity": {}, + "includewinners": true, + "includebidderkeys": true, + "includebrandcategory": { + "primaryadserver": 0, + "publisher": "", + "withcategory": false, + "translatecategories": false, + "skipdedup": true + } + }, + "macros": { + "[PLATFORM]": "3", + "[PROFILE_ID]": "81367", + "[PROFILE_VERSION]": "1", + "[UNIX_TIMESTAMP]": "1711103402", + "[WRAPPER_IMPRESSION_ID]": "34d54ecd-fc14-4a52-a6dd-30dcba780a0d" + }, + "returnallbidstatus": true + } + } + }, + "expectedBidResponse": {}, + "expectedReturnCode": 200 +} \ No newline at end of file diff --git a/endpoints/openrtb2/sample-requests/ctv/valid-requests/structured-adpod.json b/endpoints/openrtb2/sample-requests/ctv/valid-requests/structured-adpod.json new file mode 100644 index 00000000000..f26a57f4e95 --- /dev/null +++ b/endpoints/openrtb2/sample-requests/ctv/valid-requests/structured-adpod.json @@ -0,0 +1,620 @@ +{ + "description": "Structured adpod request", + "config": { + "mockBidders": [ + { + "bidderName": "pubmatic", + "currency": "USD", + "bids": [ + { + "impid": "imp1", + "price": 2 + }, + { + "impid": "imp2", + "price": 3 + }, + { + "impid": "imp3", + "price": 5 + } + ] + }, + { + "bidderName": "appnexus", + "currency": "USD", + "bids": [ + { + "impid": "imp1", + "price": 4 + }, + { + "impid": "imp2", + "price": 2 + }, + { + "impid": "imp3", + "price": 5 + } + ] + } + ] + }, + "mockBidRequest": { + "id": "1559039248176", + "cur": [ + "USD" + ], + "imp": [ + { + "id": "imp1", + "video": { + "mimes": [ + "video/3gpp", + "video/mp4", + "video/webm" + ], + "startdelay": 0, + "protocols": [ + 2, + 3, + 5, + 6, + 7, + 8 + ], + "placement": 5, + "linearity": 1, + "skip": 1, + "skipmin": 10, + "skipafter": 15, + "battr": [ + 6, + 7 + ], + "maxbitrate": 2000, + "boxingallowed": 1, + "playbackmethod": [ + 1 + ], + "playbackend": 1, + "delivery": [ + 2 + ], + "pos": 7, + "podid": "pod_1", + "podseq": 1, + "slotinpod": 1, + "minduration": 15, + "maxduration": 30 + }, + "tagid": "/15671365/MG_VideoAdUnit", + "secure": 0, + "ext": { + "data": { + "pbadslot": "/15671365/MG_VideoAdUnit" + }, + "prebid": { + "bidder": { + "pubmatic": { + "publisherId": "5890", + "adSlot": "/15671365/MG_VideoAdUnit@0x0" + }, + "appnexus": { + "placementId": 12883451 + } + } + } + } + }, + { + "id": "imp2", + "video": { + "mimes": [ + "video/3gpp", + "video/mp4", + "video/webm" + ], + "startdelay": 0, + "protocols": [ + 2, + 3, + 5, + 6, + 7, + 8 + ], + "placement": 5, + "linearity": 1, + "skip": 1, + "skipmin": 10, + "skipafter": 15, + "battr": [ + 6, + 7 + ], + "maxbitrate": 2000, + "boxingallowed": 1, + "playbackmethod": [ + 1 + ], + "playbackend": 1, + "delivery": [ + 2 + ], + "pos": 7, + "podid": "pod_1", + "podseq": 1, + "slotinpod": 0, + "minduration": 10, + "maxduration": 30 + }, + "tagid": "/15671365/MG_VideoAdUnit", + "secure": 0, + "ext": { + "data": { + "pbadslot": "/15671365/MG_VideoAdUnit" + }, + "prebid": { + "bidder": { + "pubmatic": { + "publisherId": "5890", + "adSlot": "/15671365/MG_VideoAdUnit@0x0" + }, + "appnexus": { + "placementId": 12883451 + } + } + } + } + }, + { + "id": "imp3", + "video": { + "mimes": [ + "video/3gpp", + "video/mp4", + "video/webm" + ], + "startdelay": 0, + "protocols": [ + 2, + 3, + 5, + 6, + 7, + 8 + ], + "placement": 5, + "linearity": 1, + "skip": 1, + "skipmin": 10, + "skipafter": 15, + "battr": [ + 6, + 7 + ], + "maxbitrate": 2000, + "boxingallowed": 1, + "playbackmethod": [ + 1 + ], + "playbackend": 1, + "delivery": [ + 2 + ], + "pos": 7, + "podid": "pod_1", + "podseq": 1, + "slotinpod": 0, + "minduration": 30, + "maxduration": 30 + }, + "tagid": "/15671365/MG_VideoAdUnit", + "secure": 0, + "ext": { + "data": { + "pbadslot": "/15671365/MG_VideoAdUnit" + }, + "prebid": { + "bidder": { + "pubmatic": { + "publisherId": "5890", + "adSlot": "/15671365/MG_VideoAdUnit@0x0" + }, + "appnexus": { + "placementId": 12883451 + } + } + } + } + } + ], + "app": { + "name": "OpenWrapperSample", + "bundle": "com.pubmatic.openbid.app", + "storeurl": "https://itunes.apple.com/us/app/pubmatic-sdk-app/id1175273098?videobid=10", + "ver": "1.0", + "publisher": { + "id": "5890" + } + }, + "device": { + "ua": "PostmanRuntime/7.36.3", + "ip": "::1" + }, + "at": 1, + "tmax": 194999, + "source": { + "tid": "1559039248176" + }, + "ext": { + "prebid": { + "aliases": { + "adg": "adgeneration", + "andbeyond": "adkernel", + "districtm": "appnexus", + "districtmDMX": "dmx", + "mediafuse": "appnexus", + "pubmatic2": "pubmatic" + }, + "bidadjustmentfactors": { + "pubmatic": 0.9 + }, + "bidderparams": { + "pubmatic": { + "wiid": "34d54ecd-fc14-4a52-a6dd-30dcba780a0d" + }, + "appnexus": { + "placementId": 12883451 + } + }, + "debug": true, + "floors": { + "enforcement": { + "enforcepbs": true + }, + "enabled": true + }, + "targeting": { + "pricegranularity": { + "precision": 2, + "ranges": [ + { + "min": 0, + "max": 100, + "increment": 0.5 + } + ] + }, + "mediatypepricegranularity": {}, + "includewinners": true, + "includebidderkeys": true, + "includebrandcategory": { + "primaryadserver": 0, + "publisher": "", + "withcategory": false, + "translatecategories": false, + "skipdedup": true + } + }, + "macros": { + "[PLATFORM]": "3", + "[PROFILE_ID]": "81367", + "[PROFILE_VERSION]": "1", + "[UNIX_TIMESTAMP]": "1711103402", + "[WRAPPER_IMPRESSION_ID]": "34d54ecd-fc14-4a52-a6dd-30dcba780a0d" + }, + "returnallbidstatus": true + } + } + }, + "expectedBidResponse": { + "id": "1559039248176", + "seatbid": [ + { + "bid": [ + { + "id": "pubmatic-bid-1", + "impid": "imp2", + "price": 2.7, + "adm": "", + "ext": { + "origbidcpm": 3, + "origbidcur": "USD", + "prebid": { + "meta": { + "adaptercode": "pubmatic" + }, + "targeting": { + "hb_bidder": "pubmatic", + "hb_bidder_pubmatic": "pubmatic", + "hb_cache_host": "www.pbcserver.com", + "hb_cache_host_pubmat": "www.pbcserver.com", + "hb_cache_path": "/pbcache/endpoint", + "hb_cache_path_pubmat": "/pbcache/endpoint", + "hb_env": "mobile-app", + "hb_env_pubmatic": "mobile-app", + "hb_pb": "2.50", + "hb_pb_cat_dur": "2.50_30s", + "hb_pb_cat_dur_pubmat": "2.50_30s", + "hb_pb_pubmatic": "2.50" + }, + "type": "video" + } + } + } + ], + "seat": "pubmatic" + }, + { + "bid": [ + { + "id": "appnexus-bid-2", + "impid": "imp3", + "price": 5, + "adm": "", + "ext": { + "origbidcpm": 5, + "origbidcur": "USD", + "prebid": { + "meta": { + "adaptercode": "appnexus" + }, + "targeting": { + "hb_bidder": "appnexus", + "hb_bidder_appnexus": "appnexus", + "hb_cache_host": "www.pbcserver.com", + "hb_cache_host_appnex": "www.pbcserver.com", + "hb_cache_path": "/pbcache/endpoint", + "hb_cache_path_appnex": "/pbcache/endpoint", + "hb_env": "mobile-app", + "hb_env_appnexus": "mobile-app", + "hb_pb": "5.00", + "hb_pb_appnexus": "5.00", + "hb_pb_cat_dur": "5.00_30s", + "hb_pb_cat_dur_appnex": "5.00_30s" + }, + "type": "video" + } + } + }, + { + "id": "appnexus-bid-0", + "impid": "imp1", + "price": 4, + "adm": "", + "ext": { + "origbidcpm": 4, + "origbidcur": "USD", + "prebid": { + "meta": { + "adaptercode": "appnexus" + }, + "targeting": { + "hb_bidder": "appnexus", + "hb_bidder_appnexus": "appnexus", + "hb_cache_host": "www.pbcserver.com", + "hb_cache_host_appnex": "www.pbcserver.com", + "hb_cache_path": "/pbcache/endpoint", + "hb_cache_path_appnex": "/pbcache/endpoint", + "hb_env": "mobile-app", + "hb_env_appnexus": "mobile-app", + "hb_pb": "4.00", + "hb_pb_appnexus": "4.00", + "hb_pb_cat_dur": "4.00_30s", + "hb_pb_cat_dur_appnex": "4.00_30s" + }, + "type": "video" + } + } + } + ], + "seat": "appnexus" + } + ], + "cur": "USD", + "ext": { + "warnings": { + "general": [ + { + "code": 10002, + "message": "debug turned off for account" + } + ] + }, + "responsetimemillis": { + "appnexus": 5920, + "pubmatic": 5920 + }, + "tmaxrequest": 194999, + "prebid": { + "auctiontimestamp": 1712568761543 + }, + "adpod": { + "bidresponse": { + "id": "1559039248176", + "seatbid": [ + { + "bid": [ + { + "id": "appnexus-bid-0", + "impid": "imp1", + "price": 4, + "adm": "", + "ext": { + "origbidcpm": 4, + "origbidcur": "USD", + "prebid": { + "meta": { + "adaptercode": "appnexus" + }, + "targeting": { + "hb_bidder": "appnexus", + "hb_bidder_appnexus": "appnexus", + "hb_cache_host": "www.pbcserver.com", + "hb_cache_host_appnex": "www.pbcserver.com", + "hb_cache_path": "/pbcache/endpoint", + "hb_cache_path_appnex": "/pbcache/endpoint", + "hb_env": "mobile-app", + "hb_env_appnexus": "mobile-app", + "hb_pb": "4.00", + "hb_pb_appnexus": "4.00", + "hb_pb_cat_dur": "4.00_30s", + "hb_pb_cat_dur_appnex": "4.00_30s" + }, + "type": "video" + } + } + }, + { + "id": "appnexus-bid-1", + "impid": "imp2", + "price": 2, + "adm": "", + "ext": { + "origbidcpm": 2, + "origbidcur": "USD", + "prebid": { + "meta": { + "adaptercode": "appnexus" + }, + "targeting": { + "hb_bidder_appnexus": "appnexus", + "hb_cache_host_appnex": "www.pbcserver.com", + "hb_cache_path_appnex": "/pbcache/endpoint", + "hb_env_appnexus": "mobile-app", + "hb_pb_appnexus": "2.00", + "hb_pb_cat_dur_appnex": "2.00_30s" + }, + "type": "video" + } + } + }, + { + "id": "appnexus-bid-2", + "impid": "imp3", + "price": 5, + "adm": "", + "ext": { + "origbidcpm": 5, + "origbidcur": "USD", + "prebid": { + "meta": { + "adaptercode": "appnexus" + }, + "targeting": { + "hb_bidder": "appnexus", + "hb_bidder_appnexus": "appnexus", + "hb_cache_host": "www.pbcserver.com", + "hb_cache_host_appnex": "www.pbcserver.com", + "hb_cache_path": "/pbcache/endpoint", + "hb_cache_path_appnex": "/pbcache/endpoint", + "hb_env": "mobile-app", + "hb_env_appnexus": "mobile-app", + "hb_pb": "5.00", + "hb_pb_appnexus": "5.00", + "hb_pb_cat_dur": "5.00_30s", + "hb_pb_cat_dur_appnex": "5.00_30s" + }, + "type": "video" + } + } + } + ], + "seat": "appnexus" + }, + { + "bid": [ + { + "id": "pubmatic-bid-0", + "impid": "imp1", + "price": 1.8, + "adm": "", + "ext": { + "origbidcpm": 2, + "origbidcur": "USD", + "prebid": { + "meta": { + "adaptercode": "pubmatic" + }, + "targeting": { + "hb_bidder_pubmatic": "pubmatic", + "hb_cache_host_pubmat": "www.pbcserver.com", + "hb_cache_path_pubmat": "/pbcache/endpoint", + "hb_env_pubmatic": "mobile-app", + "hb_pb_cat_dur_pubmat": "1.50_30s", + "hb_pb_pubmatic": "1.50" + }, + "type": "video" + } + } + }, + { + "id": "pubmatic-bid-1", + "impid": "imp2", + "price": 2.7, + "adm": "", + "ext": { + "origbidcpm": 3, + "origbidcur": "USD", + "prebid": { + "meta": { + "adaptercode": "pubmatic" + }, + "targeting": { + "hb_bidder": "pubmatic", + "hb_bidder_pubmatic": "pubmatic", + "hb_cache_host": "www.pbcserver.com", + "hb_cache_host_pubmat": "www.pbcserver.com", + "hb_cache_path": "/pbcache/endpoint", + "hb_cache_path_pubmat": "/pbcache/endpoint", + "hb_env": "mobile-app", + "hb_env_pubmatic": "mobile-app", + "hb_pb": "2.50", + "hb_pb_cat_dur": "2.50_30s", + "hb_pb_cat_dur_pubmat": "2.50_30s", + "hb_pb_pubmatic": "2.50" + }, + "type": "video" + } + } + }, + { + "id": "pubmatic-bid-2", + "impid": "imp3", + "price": 4.5, + "adm": "", + "ext": { + "origbidcpm": 5, + "origbidcur": "USD", + "prebid": { + "meta": { + "adaptercode": "pubmatic" + }, + "targeting": { + "hb_bidder_pubmatic": "pubmatic", + "hb_cache_host_pubmat": "www.pbcserver.com", + "hb_cache_path_pubmat": "/pbcache/endpoint", + "hb_env_pubmatic": "mobile-app", + "hb_pb_cat_dur_pubmat": "4.50_30s", + "hb_pb_pubmatic": "4.50" + }, + "type": "video" + } + } + } + ], + "seat": "pubmatic" + } + ], + "cur": "USD" + }, + "config": { + "pod_1": null + } + } + } + }, + "expectedReturnCode": 200 +} \ No newline at end of file diff --git a/endpoints/openrtb2/sample-requests/ctv/valid-requests/video-request.json b/endpoints/openrtb2/sample-requests/ctv/valid-requests/video-request.json new file mode 100644 index 00000000000..9fef21eb747 --- /dev/null +++ b/endpoints/openrtb2/sample-requests/ctv/valid-requests/video-request.json @@ -0,0 +1,264 @@ +{ + "description": "Pure Video request", + "config": { + }, + "mockBidRequest": { + "id": "1559039248176", + "cur": [ + "USD" + ], + "imp": [ + { + "id": "imp1", + "video": { + "mimes": [ + "video/3gpp", + "video/mp4", + "video/webm" + ], + "startdelay": 0, + "protocols": [ + 2, + 3, + 5, + 6, + 7, + 8 + ], + "placement": 5, + "linearity": 1, + "skip": 1, + "skipmin": 10, + "skipafter": 15, + "battr": [ + 6, + 7 + ], + "maxbitrate": 2000, + "boxingallowed": 1, + "playbackmethod": [ + 1 + ], + "playbackend": 1, + "delivery": [ + 2 + ], + "pos": 7, + "podid": "pod_1", + "podseq": 1, + "slotinpod": 1, + "minduration": 15, + "maxduration": 30 + }, + "tagid": "/15671365/MG_VideoAdUnit", + "secure": 0, + "ext": { + "data": { + "pbadslot": "/15671365/MG_VideoAdUnit" + }, + "prebid": { + "bidder": { + "pubmatic": { + "publisherId": "5890", + "adSlot": "/15671365/MG_VideoAdUnit@0x0" + } + } + } + } + }, + { + "id": "imp2", + "video": { + "mimes": [ + "video/3gpp", + "video/mp4", + "video/webm" + ], + "startdelay": 0, + "protocols": [ + 2, + 3, + 5, + 6, + 7, + 8 + ], + "placement": 5, + "linearity": 1, + "skip": 1, + "skipmin": 10, + "skipafter": 15, + "battr": [ + 6, + 7 + ], + "maxbitrate": 2000, + "boxingallowed": 1, + "playbackmethod": [ + 1 + ], + "playbackend": 1, + "delivery": [ + 2 + ], + "pos": 7, + "podid": "pod_1", + "podseq": 1, + "slotinpod": 0, + "minduration": 10, + "maxduration": 30 + }, + "tagid": "/15671365/MG_VideoAdUnit", + "secure": 0, + "ext": { + "data": { + "pbadslot": "/15671365/MG_VideoAdUnit" + }, + "prebid": { + "bidder": { + "pubmatic": { + "publisherId": "5890", + "adSlot": "/15671365/MG_VideoAdUnit@0x0" + } + } + } + } + }, + { + "id": "imp3", + "video": { + "mimes": [ + "video/3gpp", + "video/mp4", + "video/webm" + ], + "startdelay": 0, + "protocols": [ + 2, + 3, + 5, + 6, + 7, + 8 + ], + "placement": 5, + "linearity": 1, + "skip": 1, + "skipmin": 10, + "skipafter": 15, + "battr": [ + 6, + 7 + ], + "maxbitrate": 2000, + "boxingallowed": 1, + "playbackmethod": [ + 1 + ], + "playbackend": 1, + "delivery": [ + 2 + ], + "pos": 7, + "podid": "pod_1", + "podseq": 1, + "slotinpod": 0, + "minduration": 30, + "maxduration": 30 + }, + "tagid": "/15671365/MG_VideoAdUnit", + "secure": 0, + "ext": { + "data": { + "pbadslot": "/15671365/MG_VideoAdUnit" + }, + "prebid": { + "bidder": { + "pubmatic": { + "publisherId": "5890", + "adSlot": "/15671365/MG_VideoAdUnit@0x0" + } + } + } + } + } + ], + "app": { + "name": "OpenWrapperSample", + "bundle": "com.pubmatic.openbid.app", + "storeurl": "https://itunes.apple.com/us/app/pubmatic-sdk-app/id1175273098?videobid=10", + "ver": "1.0", + "publisher": { + "id": "5890" + } + }, + "device": { + "ua": "PostmanRuntime/7.36.3", + "ip": "::1" + }, + "at": 1, + "tmax": 194999, + "source": { + "tid": "1559039248176" + }, + "ext": { + "prebid": { + "aliases": { + "adg": "adgeneration", + "andbeyond": "adkernel", + "districtm": "appnexus", + "districtmDMX": "dmx", + "mediafuse": "appnexus", + "pubmatic2": "pubmatic" + }, + "bidadjustmentfactors": { + "pubmatic": 0.9 + }, + "bidderparams": { + "pubmatic": { + "wiid": "34d54ecd-fc14-4a52-a6dd-30dcba780a0d" + } + }, + "debug": true, + "floors": { + "enforcement": { + "enforcepbs": true + }, + "enabled": true + }, + "targeting": { + "pricegranularity": { + "precision": 2, + "ranges": [ + { + "min": 0, + "max": 100, + "increment": 0.5 + } + ] + }, + "mediatypepricegranularity": {}, + "includewinners": true, + "includebidderkeys": true, + "includebrandcategory": { + "primaryadserver": 0, + "publisher": "", + "withcategory": false, + "translatecategories": false, + "skipdedup": true + } + }, + "macros": { + "[PLATFORM]": "3", + "[PROFILE_ID]": "81367", + "[PROFILE_VERSION]": "1", + "[UNIX_TIMESTAMP]": "1711103402", + "[WRAPPER_IMPRESSION_ID]": "34d54ecd-fc14-4a52-a6dd-30dcba780a0d" + }, + "returnallbidstatus": true + } + } + }, + "expectedBidResponse": {}, + "expectedReturnCode": 200 +} \ No newline at end of file diff --git a/exchange/adapter_builders.go b/exchange/adapter_builders.go index fe72226cf1e..e3f4d9717ea 100755 --- a/exchange/adapter_builders.go +++ b/exchange/adapter_builders.go @@ -135,6 +135,7 @@ import ( "github.com/prebid/prebid-server/v2/adapters/openx" "github.com/prebid/prebid-server/v2/adapters/operaads" "github.com/prebid/prebid-server/v2/adapters/orbidder" + "github.com/prebid/prebid-server/v2/adapters/ortbbidder" "github.com/prebid/prebid-server/v2/adapters/outbrain" "github.com/prebid/prebid-server/v2/adapters/ownadx" "github.com/prebid/prebid-server/v2/adapters/pangle" @@ -341,6 +342,7 @@ func newAdapterBuilders() map[openrtb_ext.BidderName]adapters.Builder { openrtb_ext.BidderOpenx: openx.Builder, openrtb_ext.BidderOperaads: operaads.Builder, openrtb_ext.BidderOrbidder: orbidder.Builder, + openrtb_ext.BidderORTBTestBidder: ortbbidder.Builder, // OW specific : testbidder (oRTB integration) openrtb_ext.BidderOutbrain: outbrain.Builder, openrtb_ext.BidderOwnAdx: ownadx.Builder, openrtb_ext.BidderPangle: pangle.Builder, diff --git a/exchange/exchange.go b/exchange/exchange.go index 02afe83a620..2e42e685bf2 100644 --- a/exchange/exchange.go +++ b/exchange/exchange.go @@ -759,6 +759,7 @@ func (e *exchange) getAllBids( reqInfo := adapters.NewExtraRequestInfo(conversions) reqInfo.PbsEntryPoint = bidderRequest.BidderLabels.RType reqInfo.GlobalPrivacyControlHeader = globalPrivacyControlHeader + reqInfo.BidderCoreName = bidderRequest.BidderCoreName // OW specific: required for oRTB bidder bidReqOptions := bidRequestOptions{ accountDebugAllowed: accountDebugAllowed, diff --git a/go.mod b/go.mod index 1d04b994a2c..63d2c1b7d29 100644 --- a/go.mod +++ b/go.mod @@ -82,6 +82,7 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect + github.com/yudai/pp v2.0.1+incompatible // indirect golang.org/x/crypto v0.17.0 // indirect golang.org/x/sys v0.15.0 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect diff --git a/go.sum b/go.sum index b0558e61c90..e08f20c6e26 100644 --- a/go.sum +++ b/go.sum @@ -508,6 +508,7 @@ github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FB github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= github.com/yudai/pp v2.0.1+incompatible h1:Q4//iY4pNF6yPLZIigmvcl7k/bPgrcTPIFIcmawg5bI= +github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/main.go b/main.go index 9599783492e..f84c42961fc 100644 --- a/main.go +++ b/main.go @@ -43,6 +43,7 @@ func Main() { if err != nil { glog.Exitf("Configuration could not be loaded or did not pass validation: %v", err) } + main_ow() // Create a soft memory limit on the total amount of memory that PBS uses to tune the behavior // of the Go garbage collector. In summary, `cfg.GarbageCollectorThreshold` serves as a fixed cost diff --git a/main_ow.go b/main_ow.go new file mode 100644 index 00000000000..ae0d3d2c353 --- /dev/null +++ b/main_ow.go @@ -0,0 +1,16 @@ +package main_ow + +import ( + "github.com/golang/glog" + "github.com/prebid/prebid-server/v2/adapters/ortbbidder" +) + +const paramsDirectory = "./static/bidder-params" + +// main_ow will perform the openwrap specific initialisation tasks +func main_ow() { + err := ortbbidder.InitBidderParamsConfig(paramsDirectory) + if err != nil { + glog.Exitf("Unable to initialise bidder-param mapper for oRTB bidders: %v", err) + } +} diff --git a/modules/pubmatic/openwrap/adapters/bidder_alias.go b/modules/pubmatic/openwrap/adapters/bidder_alias.go index 569d3099515..c07e8c31841 100644 --- a/modules/pubmatic/openwrap/adapters/bidder_alias.go +++ b/modules/pubmatic/openwrap/adapters/bidder_alias.go @@ -1,6 +1,8 @@ package adapters import ( + "strings" + "github.com/prebid/prebid-server/v2/modules/pubmatic/openwrap/models" "github.com/prebid/prebid-server/v2/openrtb_ext" ) @@ -21,7 +23,8 @@ func Alias() map[string]string { //ResolveOWBidder it resolves hardcoded bidder alias names func ResolveOWBidder(bidderName string) string { - var coreBidderName string + var coreBidderName = bidderName + bidderName = strings.TrimSuffix(bidderName, "_deprecated") switch bidderName { case models.BidderAdGenerationAlias: @@ -42,8 +45,6 @@ func ResolveOWBidder(bidderName string) string { coreBidderName = string(openrtb_ext.BidderImds) case models.BidderViewDeos: coreBidderName = string(openrtb_ext.BidderAdtelligent) - default: - coreBidderName = bidderName } return coreBidderName } diff --git a/modules/pubmatic/openwrap/adapters/bidder_alias_test.go b/modules/pubmatic/openwrap/adapters/bidder_alias_test.go index 17af57cc804..8605884c216 100644 --- a/modules/pubmatic/openwrap/adapters/bidder_alias_test.go +++ b/modules/pubmatic/openwrap/adapters/bidder_alias_test.go @@ -23,6 +23,8 @@ func TestAlias(t *testing.T) { func TestResolveOWBidder(t *testing.T) { assert.Equal(t, "", ResolveOWBidder("")) assert.Equal(t, models.BidderPubMatic, ResolveOWBidder(models.BidderPubMatic)) + assert.Equal(t, string(openrtb_ext.BidderAdf), ResolveOWBidder("adform_deprecated")) // deprecated custom alias + assert.Equal(t, "tpmn_deprecated", ResolveOWBidder("tpmn_deprecated")) // any other deprecated bidder for alias, coreBidder := range Alias() { assert.Equal(t, coreBidder, ResolveOWBidder(alias)) } diff --git a/modules/pubmatic/openwrap/adapters/default_bidder_parameter_test.go b/modules/pubmatic/openwrap/adapters/default_bidder_parameter_test.go index a1a6bb5968f..89c6e38212f 100644 --- a/modules/pubmatic/openwrap/adapters/default_bidder_parameter_test.go +++ b/modules/pubmatic/openwrap/adapters/default_bidder_parameter_test.go @@ -111,7 +111,7 @@ func TestGetType(t *testing.T) { func TestParseBidderParams(t *testing.T) { parseBidderParams("../../static/bidder-params") - assert.Equal(t, 160, len(adapterParams), "Length of expected entries should match") + assert.Equal(t, 161, len(adapterParams), "Length of expected entries should match") // calculate this number using X-Y // where X is calculated using command - `ls -l | wc -l` (substract 1 from result) // Y is calculated using command `grep -EinR 'oneof|not|anyof|dependenc' static/bidder-params | grep -v "description" | grep -oE './.*.json' | uniq | wc -l` @@ -119,7 +119,7 @@ func TestParseBidderParams(t *testing.T) { func TestParseBidderSchemaDefinitions(t *testing.T) { schemaDefinitions, _ := parseBidderSchemaDefinitions("../../../../static/bidder-params") - assert.Equal(t, 198, len(schemaDefinitions), "Length of expected entries should match") + assert.Equal(t, 199, len(schemaDefinitions), "Length of expected entries should match") // calculate this number using command - `ls -l | wc -l` (substract 1 from result) } diff --git a/modules/pubmatic/openwrap/applovinmax.go b/modules/pubmatic/openwrap/applovinmax.go index 4d90d385c45..751a4753414 100644 --- a/modules/pubmatic/openwrap/applovinmax.go +++ b/modules/pubmatic/openwrap/applovinmax.go @@ -9,14 +9,20 @@ import ( "github.com/prebid/prebid-server/v2/modules/pubmatic/openwrap/models" ) -func getSignalData(requestBody []byte) *openrtb2.BidRequest { +func getSignalData(requestBody []byte, rctx models.RequestCtx) *openrtb2.BidRequest { signal, err := jsonparser.GetString(requestBody, "user", "data", "[0]", "segment", "[0]", "signal") if err != nil { + signalType := models.InvalidSignal + if err == jsonparser.KeyPathNotFoundError { + signalType = models.MissingSignal + } + rctx.MetricsEngine.RecordSignalDataStatus(getAppPublisherID(requestBody), getProfileID(requestBody), signalType) return nil } signalData := &openrtb2.BidRequest{} if err := json.Unmarshal([]byte(signal), signalData); err != nil { + rctx.MetricsEngine.RecordSignalDataStatus(getAppPublisherID(requestBody), getProfileID(requestBody), models.InvalidSignal) return nil } return signalData @@ -218,10 +224,10 @@ func updateRequestWrapper(signalExt json.RawMessage, maxRequest *openrtb2.BidReq } } -func updateAppLovinMaxRequest(requestBody []byte) []byte { - signalData := getSignalData(requestBody) +func updateAppLovinMaxRequest(requestBody []byte, rctx models.RequestCtx) []byte { + signalData := getSignalData(requestBody, rctx) if signalData == nil { - return requestBody + return modifyRequestBody(requestBody) } maxRequest := &openrtb2.BidRequest{} @@ -285,3 +291,30 @@ func applyAppLovinMaxResponse(rctx models.RequestCtx, bidResponse *openrtb2.BidR } return bidResponse } + +func getAppPublisherID(requestBody []byte) string { + if pubId, err := jsonparser.GetString(requestBody, "app", "publisher", "id"); err == nil && len(pubId) > 0 { + return pubId + } + return "" +} + +func getProfileID(requestBody []byte) string { + if profileId, err := jsonparser.GetInt(requestBody, "ext", "prebid", "bidderparams", "pubmatic", "wrapper", "profileid"); err == nil { + a := strconv.Itoa(int(profileId)) + return a + } + return "" +} + +// modifyRequestBody modifies displaymanger and banner object in req if signal is missing/invalid +func modifyRequestBody(requestBody []byte) []byte { + if body, err := jsonparser.Set(requestBody, []byte(strconv.Quote("PubMatic_OpenWrap_SDK")), "imp", "[0]", "displaymanager"); err == nil { + requestBody = jsonparser.Delete(body, "imp", "[0]", "displaymanagerver") + } + + if bannertype, err := jsonparser.GetString(requestBody, "imp", "[0]", "banner", "ext", "bannertype"); err == nil && bannertype == models.TypeRewarded { + requestBody = jsonparser.Delete(requestBody, "imp", "[0]", "banner") + } + return requestBody +} diff --git a/modules/pubmatic/openwrap/applovinmax_test.go b/modules/pubmatic/openwrap/applovinmax_test.go index f33a55a6a52..fb9709d1281 100644 --- a/modules/pubmatic/openwrap/applovinmax_test.go +++ b/modules/pubmatic/openwrap/applovinmax_test.go @@ -4,8 +4,10 @@ import ( "encoding/json" "testing" + "github.com/golang/mock/gomock" "github.com/prebid/openrtb/v20/adcom1" "github.com/prebid/openrtb/v20/openrtb2" + mock_metrics "github.com/prebid/prebid-server/v2/modules/pubmatic/openwrap/metrics/mock" "github.com/prebid/prebid-server/v2/modules/pubmatic/openwrap/models" "github.com/prebid/prebid-server/v2/modules/pubmatic/openwrap/models/nbr" "github.com/prebid/prebid-server/v2/util/ptrutil" @@ -554,32 +556,55 @@ func TestAddSignalDataInRequest(t *testing.T) { } func TestGetSignalData(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockEngine := mock_metrics.NewMockMetricsEngine(ctrl) type args struct { requestBody []byte + rctx models.RequestCtx } tests := []struct { - name string - args args - want *openrtb2.BidRequest + name string + args args + setup func() + want *openrtb2.BidRequest }{ { - name: "incorrect body", + name: "incorrect json body", args: args{ - requestBody: []byte(`{"id":"123","user":Passed","segment":[{"signal":{BIDDING_SIGNA}]}],"ext":{"gdpr":0}}}`), + requestBody: []byte(`{"id":"123","user":Passed","segment":[{"signal":{BIDDING_SIGNA}]}],"ext":{"prebid":{"bidderparams":{"pubmatic":{"wrapper":{"profileid":1234}}}}}}}`), + rctx: models.RequestCtx{ + MetricsEngine: mockEngine, + }, + }, + setup: func() { + mockEngine.EXPECT().RecordSignalDataStatus("", "", models.MissingSignal) }, want: nil, }, { name: "signal parsing fail", args: args{ - requestBody: []byte(`{"id":"123","user":{"data":[{"id":"1","name":"Publisher Passed","segment":[{"signal":"{BIDDING_SIGNA}"]}],"ext":{"gdpr":0}}}`), + requestBody: []byte(`{"id":"123","app":{"publisher":{"id":"5890"}},"user":{"data":[{"id":"1","name":"Publisher Passed","segment":[{"signal":"{BIDDING_SIGNAL}"}]}]},"ext":{"prebid":{"bidderparams":{"pubmatic":{"wrapper":{"profileid":1234}}}}}}`), + rctx: models.RequestCtx{ + MetricsEngine: mockEngine, + }, + }, + setup: func() { + mockEngine.EXPECT().RecordSignalDataStatus("5890", "1234", models.InvalidSignal) }, want: nil, }, { name: "single user.data with signal with incorrect signal", args: args{ - requestBody: []byte(`{"id":"123","user":{"data":[{"id":"1","name":"Publisher Passed","segment":[{"signal":{BIDDING_SIGNA}]}],"ext":{"gdpr":0}}}`), + requestBody: []byte(`{"id":"123","user":{"data":[{"id":"1","name":"Publisher Passed","segment":[{"signal":{}}]}]},"ext":{"prebid":{"bidderparams":{"pubmatic":{"wrapper":{"profileid":1234}}}}}}`), + rctx: models.RequestCtx{ + MetricsEngine: mockEngine, + }, + }, + setup: func() { + mockEngine.EXPECT().RecordSignalDataStatus("", "1234", models.InvalidSignal) }, want: nil, }, @@ -587,6 +612,9 @@ func TestGetSignalData(t *testing.T) { name: "single user.data with signal", args: args{ requestBody: []byte(`{"id":"123","user":{"data":[{"id":"1","name":"Publisher Passed","segment":[{"signal":"{\"device\":{\"devicetype\":4,\"w\":393,\"h\":852}}"}]}],"ext":{"gdpr":0}}}`), + rctx: models.RequestCtx{ + MetricsEngine: mockEngine, + }, }, want: &openrtb2.BidRequest{ Device: &openrtb2.Device{ @@ -599,27 +627,41 @@ func TestGetSignalData(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := getSignalData(tt.args.requestBody) + if tt.setup != nil { + tt.setup() + } + got := getSignalData(tt.args.requestBody, tt.args.rctx) assert.Equal(t, tt.want, got, tt.name) }) } } func TestUpdateMaxAppLovinRequest(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockEngine := mock_metrics.NewMockMetricsEngine(ctrl) type args struct { requestBody []byte + rctx models.RequestCtx } tests := []struct { - name string - args args - want []byte + name string + args args + setup func() + want []byte }{ { name: "signal not present", args: args{ - requestBody: []byte(``), + requestBody: []byte(`{"id":"1","app":{"publisher":{"id":"5890"}},"user":{"data":[{"segment":[{}]}]},"imp":[{"displaymanager":"applovin_mediation","displaymanagerver":"2.3"}],"ext":{"prebid":{"bidderparams":{"pubmatic":{"wrapper":{"profileid":1234}}}}}}`), + rctx: models.RequestCtx{ + MetricsEngine: mockEngine, + }, }, - want: []byte(``), + setup: func() { + mockEngine.EXPECT().RecordSignalDataStatus("5890", "1234", models.MissingSignal) + }, + want: []byte(`{"id":"1","app":{"publisher":{"id":"5890"}},"user":{"data":[{"segment":[{}]}]},"imp":[{"displaymanager":"PubMatic_OpenWrap_SDK"}],"ext":{"prebid":{"bidderparams":{"pubmatic":{"wrapper":{"profileid":1234}}}}}}`), }, { name: "invalid request body", @@ -638,7 +680,10 @@ func TestUpdateMaxAppLovinRequest(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := updateAppLovinMaxRequest(tt.args.requestBody) + if tt.setup != nil { + tt.setup() + } + got := updateAppLovinMaxRequest(tt.args.requestBody, tt.args.rctx) assert.Equal(t, tt.want, got, tt.name) }) } @@ -961,3 +1006,42 @@ func TestApplyMaxAppLovinResponse(t *testing.T) { }) } } + +func TestModifyRequestBody(t *testing.T) { + type args struct { + requestBody []byte + } + tests := []struct { + name string + args args + want []byte + }{ + { + name: "empty requestbody", + args: args{ + requestBody: []byte(``), + }, + want: []byte(``), + }, + { + name: "applovinmax displaymanager", + args: args{ + requestBody: []byte(`{"imp":[{"displaymanager":"applovin_mediation","displaymanagerver":"91.1"}]}`), + }, + want: []byte(`{"imp":[{"displaymanager":"PubMatic_OpenWrap_SDK"}]}`), + }, + { + name: "applovinmax displaymanager and bannertype rewarded", + args: args{ + requestBody: []byte(`{"imp":[{"displaymanager":"applovin_mediation","displaymanagerver":"91.1","banner":{"ext":{"bannertype":"rewarded"},"format":[{"w":728,"h":90},{"w":300,"h":250}],"w":700,"h":900,"api":[5,6,7]}}]}`), + }, + want: []byte(`{"imp":[{"displaymanager":"PubMatic_OpenWrap_SDK"}]}`), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := modifyRequestBody(tt.args.requestBody) + assert.Equal(t, tt.want, got, tt.name) + }) + } +} diff --git a/modules/pubmatic/openwrap/auctionresponsehook_test.go b/modules/pubmatic/openwrap/auctionresponsehook_test.go index c61e8f8dd82..8301a850f85 100644 --- a/modules/pubmatic/openwrap/auctionresponsehook_test.go +++ b/modules/pubmatic/openwrap/auctionresponsehook_test.go @@ -1784,7 +1784,7 @@ func TestOpenWrap_handleAuctionResponseHook(t *testing.T) { want: want{ result: hookstage.HookResult[hookstage.AuctionResponsePayload]{}, err: nil, - bidResponse: json.RawMessage(`{"id":"12345","seatbid":[{"bid":[{"id":"bid-id-1","impid":"Div1","price":5,"adm":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cImpression\u003e\u003c![CDATA[https:?adv=\u0026af=video\u0026aps=0\u0026au=\u0026bc=pubmatic\u0026bidid=bb57a9e3-fdc2-4772-8071-112dd7f50a6a\u0026di=-1\u0026eg=0\u0026en=0\u0026ft=0\u0026iid=\u0026kgpv=\u0026orig=\u0026origbidid=bid-id-1\u0026pdvid=0\u0026pid=0\u0026plt=0\u0026pn=pubmatic\u0026psz=0x0\u0026pubid=5890\u0026purl=\u0026sl=1\u0026slot=\u0026ss=1\u0026tgid=0\u0026tst=0]]\u003e\u003c/Impression\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e","ext":{"prebid":{"meta":{"adaptercode":"pubmatic","advertiserId":4098,"agencyId":4098,"demandSource":"6","mediaType":"banner","networkId":6},"type":"video","bidid":"bb57a9e3-fdc2-4772-8071-112dd7f50a6a"},"refreshInterval":30,"crtype":"video","video":{"minduration":10,"maxduration":20,"skip":1,"skipmin":1,"skipafter":2,"battr":[1],"playbackmethod":[1]},"dspid":6,"netecpm":5,"origbidcpm":8,"origbidcur":"USD"}}],"seat":"pubmatic"}],"ext":{"responsetimemillis":{"pubmatic":8},"matchedimpression":{"pubmatic":0},"loginfo":{"tracker":"?adv=\u0026af=\u0026aps=0\u0026au=%24%7BADUNIT%7D\u0026bc=%24%7BBIDDER_CODE%7D\u0026bidid=%24%7BBID_ID%7D\u0026di=\u0026eg=%24%7BG_ECPM%7D\u0026en=%24%7BN_ECPM%7D\u0026ft=0\u0026iid=\u0026kgpv=%24%7BKGPV%7D\u0026orig=\u0026origbidid=%24%7BORIGBID_ID%7D\u0026pdvid=0\u0026pid=0\u0026plt=0\u0026pn=%24%7BPARTNER_NAME%7D\u0026psz=\u0026pubid=5890\u0026purl=\u0026rwrd=%24%7BREWARDED%7D\u0026sl=1\u0026slot=%24%7BSLOT_ID%7D\u0026ss=0\u0026tgid=0\u0026tst=0"}}}`), + bidResponse: json.RawMessage(`{"id":"12345","seatbid":[{"bid":[{"id":"bid-id-1","impid":"Div1","price":5,"adm":"\u003cVAST version=\"3.0\"\u003e\u003cAd\u003e\u003cWrapper\u003e\u003cImpression\u003e\u003c![CDATA[https:?adv=\u0026af=video\u0026aps=0\u0026au=\u0026bc=pubmatic\u0026bidid=bb57a9e3-fdc2-4772-8071-112dd7f50a6a\u0026di=-1\u0026eg=0\u0026en=0\u0026ft=0\u0026iid=\u0026kgpv=\u0026orig=\u0026origbidid=bid-id-1\u0026pdvid=0\u0026pid=0\u0026plt=0\u0026pn=pubmatic\u0026psz=0x0\u0026pubid=5890\u0026purl=\u0026sl=1\u0026slot=\u0026ss=1\u0026tgid=0\u0026tst=0]]\u003e\u003c/Impression\u003e\u003cExtensions\u003e\u003cExtension\u003e\u003cPricing model=\"CPM\" currency=\"USD\"\u003e\u003c![CDATA[5]]\u003e\u003c/Pricing\u003e\u003c/Extension\u003e\u003c/Extensions\u003e\u003c/Wrapper\u003e\u003c/Ad\u003e\u003c/VAST\u003e","ext":{"prebid":{"meta":{"adaptercode":"pubmatic","advertiserId":4098,"agencyId":4098,"demandSource":"6","mediaType":"banner","networkId":6},"type":"video","bidid":"bb57a9e3-fdc2-4772-8071-112dd7f50a6a"},"refreshInterval":30,"crtype":"video","video":{"minduration":10,"maxduration":20,"skip":1,"skipmin":1,"skipafter":2,"battr":[1],"playbackmethod":[1]},"dspid":6,"netecpm":5,"origbidcpm":8,"origbidcur":"USD"}}],"seat":"pubmatic"}],"ext":{"responsetimemillis":{"pubmatic":8},"matchedimpression":{"pubmatic":0},"loginfo":{"tracker":"?adv=\u0026af=\u0026aps=0\u0026au=%24%7BADUNIT%7D\u0026bc=%24%7BBIDDER_CODE%7D\u0026bidid=%24%7BBID_ID%7D\u0026di=\u0026eg=%24%7BG_ECPM%7D\u0026en=%24%7BN_ECPM%7D\u0026ft=0\u0026iid=\u0026kgpv=%24%7BKGPV%7D\u0026orig=\u0026origbidid=%24%7BORIGBID_ID%7D\u0026pdvid=0\u0026pid=0\u0026plt=0\u0026pn=%24%7BPARTNER_NAME%7D\u0026psz=\u0026pubid=5890\u0026purl=\u0026rwrd=%24%7BREWARDED%7D\u0026sl=1\u0026slot=%24%7BSLOT_ID%7D\u0026ss=0\u0026tgid=0\u0026tst=0"}}}`), }, }, } diff --git a/modules/pubmatic/openwrap/entrypointhook.go b/modules/pubmatic/openwrap/entrypointhook.go index 215289f698d..b9e8d4f6b51 100644 --- a/modules/pubmatic/openwrap/entrypointhook.go +++ b/modules/pubmatic/openwrap/entrypointhook.go @@ -62,8 +62,9 @@ func (m OpenWrap) handleEntrypointHook( } if endpoint == models.EndpointAppLovinMax { + rCtx.MetricsEngine = m.metricEngine // updating body locally to access updated fields from signal - payload.Body = updateAppLovinMaxRequest(payload.Body) + payload.Body = updateAppLovinMaxRequest(payload.Body, rCtx) result.ChangeSet.AddMutation(func(ep hookstage.EntrypointPayload) (hookstage.EntrypointPayload, error) { ep.Body = payload.Body return ep, nil diff --git a/modules/pubmatic/openwrap/metrics/config/multimetrics.go b/modules/pubmatic/openwrap/metrics/config/multimetrics.go index fe6788e28c1..a84ca53047a 100644 --- a/modules/pubmatic/openwrap/metrics/config/multimetrics.go +++ b/modules/pubmatic/openwrap/metrics/config/multimetrics.go @@ -494,3 +494,24 @@ func (me *MultiMetricsEngine) RecordAnalyticsTrackingThrottled(pubid, profileid, thisME.RecordAnalyticsTrackingThrottled(pubid, profileid, analyticsType) } } + +// RecordAdruleEnabled across all engines +func (me *MultiMetricsEngine) RecordAdruleEnabled(publisher, profile string) { + for _, thisME := range *me { + thisME.RecordAdruleEnabled(publisher, profile) + } +} + +// RecordAdruleValidationFailure across all engines +func (me *MultiMetricsEngine) RecordAdruleValidationFailure(publisher, profile string) { + for _, thisME := range *me { + thisME.RecordAdruleValidationFailure(publisher, profile) + } +} + +// RecordSignalDataStatus record signaldata status(invalid,missing) at publisher level +func (me *MultiMetricsEngine) RecordSignalDataStatus(pubid, profileid, signalType string) { + for _, thisME := range *me { + thisME.RecordSignalDataStatus(pubid, profileid, signalType) + } +} diff --git a/modules/pubmatic/openwrap/metrics/config/multimetrics_test.go b/modules/pubmatic/openwrap/metrics/config/multimetrics_test.go index 7a677de5c41..2b206de8715 100644 --- a/modules/pubmatic/openwrap/metrics/config/multimetrics_test.go +++ b/modules/pubmatic/openwrap/metrics/config/multimetrics_test.go @@ -207,7 +207,10 @@ func TestRecordFunctionForMultiMetricsEngine(t *testing.T) { mockEngine.EXPECT().RecordSendLoggerDataTime(sendTime) mockEngine.EXPECT().RecordDBQueryFailure(queryType, publisher, profile) mockEngine.EXPECT().RecordAnalyticsTrackingThrottled(publisher, profile, "logger") + mockEngine.EXPECT().RecordSignalDataStatus(publisher, profile, models.MissingSignal) mockEngine.EXPECT().Shutdown() + mockEngine.EXPECT().RecordAdruleEnabled(publisher, profile) + mockEngine.EXPECT().RecordAdruleValidationFailure(publisher, profile) mockEngine.EXPECT().RecordRequest(metrics.Labels{RType: "video", RequestStatus: "success"}) mockEngine.EXPECT().RecordLurlSent(metrics.LurlStatusLabels{PublisherID: "pubid", Partner: "p", Status: "success"}) @@ -272,7 +275,10 @@ func TestRecordFunctionForMultiMetricsEngine(t *testing.T) { multiMetricEngine.RecordSendLoggerDataTime(sendTime) multiMetricEngine.RecordDBQueryFailure(queryType, publisher, profile) multiMetricEngine.RecordAnalyticsTrackingThrottled(publisher, profile, "logger") + multiMetricEngine.RecordSignalDataStatus(publisher, profile, models.MissingSignal) multiMetricEngine.Shutdown() + multiMetricEngine.RecordAdruleEnabled(publisher, profile) + multiMetricEngine.RecordAdruleValidationFailure(publisher, profile) multiMetricEngine.RecordRequest(metrics.Labels{RType: "video", RequestStatus: "success"}) multiMetricEngine.RecordLurlSent(metrics.LurlStatusLabels{PublisherID: "pubid", Partner: "p", Status: "success"}) diff --git a/modules/pubmatic/openwrap/metrics/metrics.go b/modules/pubmatic/openwrap/metrics/metrics.go index 24c75136846..f08bf76c450 100644 --- a/modules/pubmatic/openwrap/metrics/metrics.go +++ b/modules/pubmatic/openwrap/metrics/metrics.go @@ -75,10 +75,15 @@ type MetricsEngine interface { RecordAmpVideoRequests(pubid, profileid string) RecordAmpVideoResponses(pubid, profileid string) RecordAnalyticsTrackingThrottled(pubid, profileid, analyticsType string) + RecordSignalDataStatus(pubid, profileid, signalType string) // VAST Unwrap metrics RecordUnwrapRequestStatus(accountId, bidder, status string) RecordUnwrapWrapperCount(accountId, bidder string, wrapper_count string) RecordUnwrapRequestTime(accountId, bidder string, respTime time.Duration) RecordUnwrapRespTime(accountId, wraperCnt string, respTime time.Duration) + + //VMAP-adrule + RecordAdruleEnabled(pubId, profId string) + RecordAdruleValidationFailure(pubId, profId string) } diff --git a/modules/pubmatic/openwrap/metrics/mock/mock.go b/modules/pubmatic/openwrap/metrics/mock/mock.go index 1d600c020cd..1f2e5bb6062 100644 --- a/modules/pubmatic/openwrap/metrics/mock/mock.go +++ b/modules/pubmatic/openwrap/metrics/mock/mock.go @@ -94,6 +94,18 @@ func (mr *MockMetricsEngineMockRecorder) RecordAnalyticsTrackingThrottled(arg0, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecordAnalyticsTrackingThrottled", reflect.TypeOf((*MockMetricsEngine)(nil).RecordAnalyticsTrackingThrottled), arg0, arg1, arg2) } +// RecordSignalDataStatus mocks base method. +func (m *MockMetricsEngine) RecordSignalDataStatus(arg0, arg1, arg2 string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "RecordSignalDataStatus", arg0, arg1, arg2) +} + +// RecordSignalDataStatus indicates an expected call of RecordSignalDataStatus. +func (mr *MockMetricsEngineMockRecorder) RecordSignalDataStatus(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecordSignalDataStatus", reflect.TypeOf((*MockMetricsEngine)(nil).RecordSignalDataStatus), arg0, arg1, arg2) +} + // RecordBadRequests mocks base method. func (m *MockMetricsEngine) RecordBadRequests(arg0 string, arg1 int) { m.ctrl.T.Helper() @@ -765,3 +777,27 @@ func (mr *MockMetricsEngineMockRecorder) Shutdown() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Shutdown", reflect.TypeOf((*MockMetricsEngine)(nil).Shutdown)) } + +// RecordAdruleEnabled mocks base method +func (m *MockMetricsEngine) RecordAdruleEnabled(arg0, arg1 string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "RecordAdruleEnabled", arg0, arg1) +} + +// RecordAdruleEnabled indicates an expected call of RecordAdruleEnabled +func (mr *MockMetricsEngineMockRecorder) RecordAdruleEnabled(arg0, arg1 string) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecordAdruleEnabled", reflect.TypeOf((*MockMetricsEngine)(nil).RecordAdruleEnabled), arg0, arg1) +} + +// RecordAdruleValidationFailure mocks base method +func (m *MockMetricsEngine) RecordAdruleValidationFailure(arg0, arg1 string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "RecordAdruleValidationFailure", arg0, arg1) +} + +// RecordAdruleValidationFailure indicates an expected call of RecordAdruleValidationFailure +func (mr *MockMetricsEngineMockRecorder) RecordAdruleValidationFailure(arg0, arg1 string) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecordAdruleValidationFailure", reflect.TypeOf((*MockMetricsEngine)(nil).RecordAdruleValidationFailure), arg0, arg1) +} diff --git a/modules/pubmatic/openwrap/metrics/prometheus/prometheus.go b/modules/pubmatic/openwrap/metrics/prometheus/prometheus.go index 4e68a07102e..4c3f1f6bf94 100644 --- a/modules/pubmatic/openwrap/metrics/prometheus/prometheus.go +++ b/modules/pubmatic/openwrap/metrics/prometheus/prometheus.go @@ -70,12 +70,17 @@ type Metrics struct { ampVideoRequests *prometheus.CounterVec ampVideoResponses *prometheus.CounterVec analyticsThrottle *prometheus.CounterVec + signalStatus *prometheus.CounterVec // VAST Unwrap requests *prometheus.CounterVec wrapperCount *prometheus.CounterVec requestTime *prometheus.HistogramVec unwrapRespTime *prometheus.HistogramVec + + //VMAP adrule + pubProfAdruleEnabled *prometheus.CounterVec + pubProfAdruleValidationfailure *prometheus.CounterVec } const ( @@ -94,6 +99,7 @@ const ( methodLabel = "method" queryTypeLabel = "query_type" analyticsTypeLabel = "an_type" + signalTypeLabel = "signal_status" ) var standardTimeBuckets = []float64{0.1, 0.3, 0.75, 1} @@ -262,6 +268,11 @@ func newMetrics(cfg *config.PrometheusMetrics, promRegistry *prometheus.Registry "Count of throttled analytics logger and tracker requestss", []string{pubIDLabel, profileIDLabel, analyticsTypeLabel}) + metrics.signalStatus = newCounter(cfg, promRegistry, + "signal_status", + "Count signal status for applovinmax requests", + []string{pubIDLabel, profileIDLabel, signalTypeLabel}) + metrics.requests = newCounter(cfg, promRegistry, "vastunwrap_status", "Count of vast unwrap requests labeled by status", @@ -500,6 +511,15 @@ func (m *Metrics) RecordAnalyticsTrackingThrottled(pubid, profileid, analyticsTy }).Inc() } +// RecordSignalDataStatus record signaldata status(invalid,missing) at publisher level +func (m *Metrics) RecordSignalDataStatus(pubid, profileid, signalType string) { + m.signalStatus.With(prometheus.Labels{ + pubIDLabel: pubid, + profileIDLabel: profileid, + signalTypeLabel: signalType, + }).Inc() +} + // TODO - really need ? func (m *Metrics) RecordPBSAuctionRequestsStats() {} diff --git a/modules/pubmatic/openwrap/metrics/prometheus/prometheus_sshb.go b/modules/pubmatic/openwrap/metrics/prometheus/prometheus_sshb.go index d8b9e7506fe..3930ed6867e 100644 --- a/modules/pubmatic/openwrap/metrics/prometheus/prometheus_sshb.go +++ b/modules/pubmatic/openwrap/metrics/prometheus/prometheus_sshb.go @@ -118,6 +118,16 @@ func newSSHBMetrics(metrics *Metrics, cfg *config.PrometheusMetrics, promRegistr "Counts the AMP video responses labeled by pub id and profile id.", []string{pubIDLabel, profileIDLabel}) + metrics.pubProfAdruleEnabled = newCounter(cfg, promRegistry, + "sshb_adrule_enable", + "Count of request where adRule is present", + []string{pubIdLabel, profileLabel}) + + metrics.pubProfAdruleValidationfailure = newCounter(cfg, promRegistry, + "sshb_invalid_adrule", + "Count of request where adRule is invalid", + []string{pubIdLabel, profileLabel}) + preloadLabelValues(metrics) } @@ -252,6 +262,22 @@ func (m *Metrics) RecordUnwrapRespTime(accountId, wraperCnt string, respTime tim }).Observe(float64(respTime.Milliseconds())) } +// RecordAdruleEnabled records count of request in which adrule is present based on pubid and profileid +func (m *Metrics) RecordAdruleEnabled(pubid, profileid string) { + m.pubProfAdruleEnabled.With(prometheus.Labels{ + pubIdLabel: pubid, + profileLabel: profileid, + }).Inc() +} + +// RecordAdruleValidationFailure records count of request in which adrule validation fails based on pubid and profileid +func (m *Metrics) RecordAdruleValidationFailure(pubid, profileid string) { + m.pubProfAdruleValidationfailure.With(prometheus.Labels{ + pubIdLabel: pubid, + profileLabel: profileid, + }).Inc() +} + func preloadLabelValues(m *Metrics) { var ( requestStatusValues = requestStatusesAsString() diff --git a/modules/pubmatic/openwrap/metrics/prometheus/prometheus_test.go b/modules/pubmatic/openwrap/metrics/prometheus/prometheus_test.go index 59e81b9c010..03078309cae 100644 --- a/modules/pubmatic/openwrap/metrics/prometheus/prometheus_test.go +++ b/modules/pubmatic/openwrap/metrics/prometheus/prometheus_test.go @@ -325,6 +325,21 @@ func TestRecordDBQueryFailure(t *testing.T) { }) } +func TestRecordSignalDataStatus(t *testing.T) { + m := createMetricsForTesting() + + m.RecordSignalDataStatus("5890", "1234", models.MissingSignal) + + expectedCount := float64(1) + assertCounterVecValue(t, "", "signal_status", m.signalStatus, + expectedCount, + prometheus.Labels{ + pubIDLabel: "5890", + profileIDLabel: "1234", + signalTypeLabel: models.MissingSignal, + }) +} + func getHistogramFromHistogram(histogram prometheus.Histogram) dto.Histogram { var result dto.Histogram processMetrics(histogram, func(m dto.Metric) { @@ -333,6 +348,32 @@ func getHistogramFromHistogram(histogram prometheus.Histogram) dto.Histogram { return result } +func TestRecordAdruleEnabled(t *testing.T) { + m := createMetricsForTesting() + + m.RecordAdruleEnabled("5890", "123") + + expectedCount := float64(1) + assertCounterVecValue(t, "", "pubProfAdruleEnabled", m.pubProfAdruleEnabled, + expectedCount, prometheus.Labels{ + pubIdLabel: "5890", + profileLabel: "123", + }) +} + +func TestRecordAdruleValidationFailure(t *testing.T) { + m := createMetricsForTesting() + + m.RecordAdruleValidationFailure("5890", "123") + + expectedCount := float64(1) + assertCounterVecValue(t, "", "pubProfAdruleValidationfailure", m.pubProfAdruleValidationfailure, + expectedCount, prometheus.Labels{ + pubIdLabel: "5890", + profileLabel: "123", + }) +} + func getHistogramFromHistogramVec(histogram *prometheus.HistogramVec, labelKey, labelValue string) dto.Histogram { var result dto.Histogram processMetrics(histogram, func(m dto.Metric) { diff --git a/modules/pubmatic/openwrap/metrics/stats/tcp_stats.go b/modules/pubmatic/openwrap/metrics/stats/tcp_stats.go index 0003f7f8c07..e5c88574561 100644 --- a/modules/pubmatic/openwrap/metrics/stats/tcp_stats.go +++ b/modules/pubmatic/openwrap/metrics/stats/tcp_stats.go @@ -346,3 +346,6 @@ func (st *StatsTCP) RecordUnwrapWrapperCount(accountId, bidder, wrapper_count st func (st *StatsTCP) RecordUnwrapRequestTime(accountId, bidder string, respTime time.Duration) {} func (st *StatsTCP) RecordUnwrapRespTime(accountId, wraperCnt string, respTime time.Duration) {} func (st *StatsTCP) RecordAnalyticsTrackingThrottled(pubid, profileid, analyticsType string) {} +func (st *StatsTCP) RecordAdruleEnabled(pubId, profId string) {} +func (st *StatsTCP) RecordAdruleValidationFailure(pubId, profId string) {} +func (st *StatsTCP) RecordSignalDataStatus(pubid, profileid, signalType string) {} diff --git a/modules/pubmatic/openwrap/models/adunitconfig/adunitconfig.go b/modules/pubmatic/openwrap/models/adunitconfig/adunitconfig.go index 9a6ef9c0590..302ce9b316e 100644 --- a/modules/pubmatic/openwrap/models/adunitconfig/adunitconfig.go +++ b/modules/pubmatic/openwrap/models/adunitconfig/adunitconfig.go @@ -73,14 +73,15 @@ type AdConfig struct { BidFloorCur *string `json:"bidfloorcur,omitempty"` Floors *openrtb_ext.PriceFloorRules `json:"floors,omitempty"` - Exp *int `json:"exp,omitempty"` - Banner *Banner `json:"banner,omitempty"` - Native *Native `json:"native,omitempty"` - Video *Video `json:"video,omitempty"` - App *openrtb2.App `json:"app,omitempty"` - Device *openrtb2.Device `json:"device,omitempty"` - Transparency *Transparency `json:"transparency,omitempty"` - Regex *bool `json:"regex,omitempty"` - UniversalPixel []UniversalPixel `json:"universalpixel,omitempty"` - EnableGAMUrlLookup bool `json:"enablegamurllookup,omitempty"` + Exp *int `json:"exp,omitempty"` + Banner *Banner `json:"banner,omitempty"` + Native *Native `json:"native,omitempty"` + Video *Video `json:"video,omitempty"` + App *openrtb2.App `json:"app,omitempty"` + Device *openrtb2.Device `json:"device,omitempty"` + Transparency *Transparency `json:"transparency,omitempty"` + Regex *bool `json:"regex,omitempty"` + UniversalPixel []UniversalPixel `json:"universalpixel,omitempty"` + EnableGAMUrlLookup bool `json:"enablegamurllookup,omitempty"` + Adrule []*openrtb2.Video `json:"adrule,omitempty"` } diff --git a/modules/pubmatic/openwrap/models/constants.go b/modules/pubmatic/openwrap/models/constants.go index 0d0d56ba230..9793f16a5d7 100755 --- a/modules/pubmatic/openwrap/models/constants.go +++ b/modules/pubmatic/openwrap/models/constants.go @@ -541,4 +541,6 @@ const ( TypeRewarded = "rewarded" SignalData = "signaldata" OwSspBurl = "owsspburl" + MissingSignal = "missing" + InvalidSignal = "invalid" ) diff --git a/modules/pubmatic/openwrap/models/video.go b/modules/pubmatic/openwrap/models/video.go index fa87aff6a2d..8689db333fb 100644 --- a/modules/pubmatic/openwrap/models/video.go +++ b/modules/pubmatic/openwrap/models/video.go @@ -7,6 +7,8 @@ const ( VideoVASTVersion = "version" //VideoVASTVersion2_0 video version 2.0 parameter constant VideoVASTVersion2_0 = "2.0" + //VideoVASTVersion3_0 video version 3.0 parameter constant + VideoVASTVersion3_0 = "3.0" //VideoVASTAdWrapperTag video ad/wrapper element constant VideoVASTAdWrapperTag = "./Ad/Wrapper" //VideoVASTAdInLineTag video ad/inline element constant diff --git a/modules/pubmatic/openwrap/tracker/inject_test.go b/modules/pubmatic/openwrap/tracker/inject_test.go index 97c054f5f0c..0eed8b3e37a 100644 --- a/modules/pubmatic/openwrap/tracker/inject_test.go +++ b/modules/pubmatic/openwrap/tracker/inject_test.go @@ -652,6 +652,265 @@ func TestInjectTrackers(t *testing.T) { }, wantErr: false, }, + { + name: "VAST_3_0_Wrapper", + args: args{ + rctx: models.RequestCtx{ + Platform: "", + Trackers: map[string]models.OWTracker{ + "12345": { + BidType: "video", + TrackerURL: `Tracking URL`, + ErrorURL: `Error URL`, + Price: 1.2, + }, + }, + }, + bidResponse: &openrtb2.BidResponse{ + SeatBid: []openrtb2.SeatBid{ + { + Bid: []openrtb2.Bid{ + { + ID: "12345", + AdM: `prebid.org wrapper`, + Price: 1.2, + }, + }, + }, + }, + }, + }, + want: &openrtb2.BidResponse{ + SeatBid: []openrtb2.SeatBid{ + { + Bid: []openrtb2.Bid{ + { + ID: "12345", + AdM: ``, + Price: 1.2, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "VAST_3_0_Inline", + args: args{ + rctx: models.RequestCtx{ + Platform: "", + Trackers: map[string]models.OWTracker{ + "12345": { + BidType: "video", + TrackerURL: `Tracking URL`, + ErrorURL: `Error URL`, + Price: 1.5, + }, + }, + }, + bidResponse: &openrtb2.BidResponse{ + SeatBid: []openrtb2.SeatBid{ + { + Bid: []openrtb2.Bid{ + { + ID: "12345", + AdM: `iabtechlabiabtechlab video adhttp://example.com/errorhttp://example.com/track/impression00:00:16http://example.com/tracking/starthttp://example.com/tracking/firstQuartilehttp://example.com/tracking/midpointhttp://example.com/tracking/thirdQuartilehttp://example.com/tracking/completehttp://example.com/tracking/progress-10http://iabtechlab.com`, + Price: 1.5, + }, + }, + }, + }, + }, + }, + want: &openrtb2.BidResponse{ + SeatBid: []openrtb2.SeatBid{ + { + Bid: []openrtb2.Bid{ + { + ID: "12345", + AdM: ``, + Price: 1.5, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "VAST_2_0_Wrapper", + args: args{ + rctx: models.RequestCtx{ + Platform: "", + Trackers: map[string]models.OWTracker{ + "12345": { + BidType: "video", + TrackerURL: `Tracking URL`, + ErrorURL: `Error URL`, + Price: 1.9, + }, + }, + }, + bidResponse: &openrtb2.BidResponse{ + SeatBid: []openrtb2.SeatBid{ + { + Bid: []openrtb2.Bid{ + { + ID: "12345", + AdM: `TestSystemhttp://demo.test.com/proddev/vast/vast_inline_linear.xml http://test.com/wrapper/errorhttp://test.com/trackingurl/wrapper/impressionhttp://test.com/trackingurl/wrapper/creativeViewhttp://test.com/trackingurl/wrapper/starthttp://test.com/trackingurl/wrapper/midpointhttp://test.com/trackingurl/wrapper/firstQuartilehttp://test.com/trackingurl/wrapper/thirdQuartilehttp://test.com/trackingurl/wrapper/completehttp://test.com/trackingurl/wrapper/mutehttp://test.com/trackingurl/wrapper/unmutehttp://test.com/trackingurl/wrapper/pausehttp://test.com/trackingurl/wrapper/resumehttp://test.com/trackingurl/wrapper/fullscreenhttp://test.com/trackingurl/wrapper/click`, + Price: 1.9, + }, + }, + }, + }, + }, + }, + want: &openrtb2.BidResponse{ + SeatBid: []openrtb2.SeatBid{ + { + Bid: []openrtb2.Bid{ + { + ID: "12345", + AdM: ``, + Price: 1.9, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "VAST_2_0_Inline", + args: args{ + rctx: models.RequestCtx{ + Platform: "", + Trackers: map[string]models.OWTracker{ + "12345": { + BidType: "video", + TrackerURL: `Tracking URL`, + ErrorURL: `Error URL`, + Price: 2.5, + }, + }, + }, + bidResponse: &openrtb2.BidResponse{ + SeatBid: []openrtb2.SeatBid{ + { + Bid: []openrtb2.Bid{ + { + ID: "12345", + AdM: ``, + Price: 2.5, + }, + }, + }, + }, + }, + }, + want: &openrtb2.BidResponse{ + SeatBid: []openrtb2.SeatBid{ + { + Bid: []openrtb2.Bid{ + { + ID: "12345", + AdM: ``, + Price: 2.5, + }, + }, + }, + }, + }, + wantErr: false, + }, + { + name: "VAST_4_0_Wrapper", + args: args{ + rctx: models.RequestCtx{ + Platform: "", + Trackers: map[string]models.OWTracker{ + "12345": { + BidType: "video", + TrackerURL: `Tracking URL`, + ErrorURL: `Error URL`, + Price: 12.5, + }, + }, + }, + bidResponse: &openrtb2.BidResponse{ + SeatBid: []openrtb2.SeatBid{ + { + Bid: []openrtb2.Bid{ + { + ID: "12345", + AdM: `iabtechlabhttp://example.com/errorhttp://example.com/track/impression`, + Price: 12.5, + }, + }, + }, + }, + }, + }, + want: &openrtb2.BidResponse{ + SeatBid: []openrtb2.SeatBid{ + { + Bid: []openrtb2.Bid{ + { + ID: "12345", + AdM: ``, + Price: 12.5, + }, + }, + }, + }, + }, + wantErr: false, + }, + + { + name: "VAST_4_0_Inline", + args: args{ + rctx: models.RequestCtx{ + Platform: "", + Trackers: map[string]models.OWTracker{ + "12345": { + BidType: "video", + TrackerURL: `Tracking URL`, + ErrorURL: `Error URL`, + Price: 15.7, + }, + }, + }, + bidResponse: &openrtb2.BidResponse{ + SeatBid: []openrtb2.SeatBid{ + { + Bid: []openrtb2.Bid{ + { + ID: "12345", + AdM: `iabtechlabhttp://example.com/errorhttp://example.com/track/impressioniabtechlab video adAD CONTENT description category8465http://example.com/tracking/starthttp://example.com/tracking/firstQuartilehttp://example.com/tracking/midpointhttp://example.com/tracking/thirdQuartilehttp://example.com/tracking/completehttp://example.com/tracking/progress-1000:00:16`, + Price: 15.7, + }, + }, + }, + }, + }, + }, + want: &openrtb2.BidResponse{ + SeatBid: []openrtb2.SeatBid{ + { + Bid: []openrtb2.Bid{ + { + ID: "12345", + AdM: ``, + Price: 15.7, + }, + }, + }, + }, + }, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/modules/pubmatic/openwrap/tracker/video.go b/modules/pubmatic/openwrap/tracker/video.go index 09f75afb3a8..f2542f9bf44 100644 --- a/modules/pubmatic/openwrap/tracker/video.go +++ b/modules/pubmatic/openwrap/tracker/video.go @@ -80,11 +80,11 @@ func injectVideoCreativeTrackers(rctx models.RequestCtx, bid openrtb2.Bid, video element.InsertChild(element.SelectElement(models.ErrorElement), newElement) } - if !isWrapper && videoParams[i].Price != 0 { - if models.VideoVASTVersion2_0 == version { - injectPricingNodeVAST20(element, videoParams[i].Price, videoParams[i].PriceModel, videoParams[i].PriceCurrency) + if videoParams[i].Price != 0 { + if (version == models.VideoVASTVersion2_0) || (isWrapper && version == models.VideoVASTVersion3_0) { + InjectPricingNodeInExtension(element, videoParams[i].Price, videoParams[i].PriceModel, videoParams[i].PriceCurrency) } else { - injectPricingNodeVAST3x(element, videoParams[i].Price, videoParams[i].PriceModel, videoParams[i].PriceCurrency) + InjectPricingNodeInVAST(element, videoParams[i].Price, videoParams[i].PriceModel, videoParams[i].PriceCurrency) } } } @@ -99,7 +99,7 @@ func injectVideoCreativeTrackers(rctx models.RequestCtx, bid openrtb2.Bid, video return bid.AdM, bid.BURL, nil } -func injectPricingNodeVAST20(parent *etree.Element, price float64, model string, currency string) { +func InjectPricingNodeInExtension(parent *etree.Element, price float64, model string, currency string) { extensions := parent.FindElement(models.VideoTagLookupStart + models.VideoExtensionsTag) if nil == extensions { extensions = parent.CreateElement(models.VideoExtensionsTag) @@ -115,7 +115,7 @@ func injectPricingNodeVAST20(parent *etree.Element, price float64, model string, } } -func injectPricingNodeVAST3x(parent *etree.Element, price float64, model string, currency string) { +func InjectPricingNodeInVAST(parent *etree.Element, price float64, model string, currency string) { //Insert into Wrapper Elements pricing := parent.FindElement(models.VideoTagLookupStart + models.VideoPricingTag) if nil != pricing { diff --git a/modules/pubmatic/openwrap/tracker/video_test.go b/modules/pubmatic/openwrap/tracker/video_test.go index 1b3c188b3b9..7a4599335d5 100644 --- a/modules/pubmatic/openwrap/tracker/video_test.go +++ b/modules/pubmatic/openwrap/tracker/video_test.go @@ -185,6 +185,42 @@ func TestInjectVideoCreativeTrackers(t *testing.T) { wantAdm: ``, wantErr: false, }, + { + name: "wrapper_vast_4.0", + args: args{ + + bid: openrtb2.Bid{ + AdM: `iabtechlabhttp://example.com/errorhttp://example.com/track/impression`, + }, + videoParams: []models.OWTracker{ + { + TrackerURL: `Tracker URL`, + ErrorURL: `Error URL`, + Price: 1.2, + }, + }, + }, + wantAdm: ``, + wantErr: false, + }, + { + name: "inline_vast_4.0", + args: args{ + + bid: openrtb2.Bid{ + AdM: `iabtechlabhttp://example.com/errorhttp://example.com/track/impressioniabtechlab video adAD CONTENT description category8465http://example.com/tracking/starthttp://example.com/tracking/firstQuartilehttp://example.com/tracking/midpointhttp://example.com/tracking/thirdQuartilehttp://example.com/tracking/completehttp://example.com/tracking/progress-1000:00:16`, + }, + videoParams: []models.OWTracker{ + { + TrackerURL: `Tracker URL`, + ErrorURL: `Error URL`, + Price: 1.2, + }, + }, + }, + wantAdm: ``, + wantErr: false, + }, { name: "wrapper_vast_2.0", args: args{ @@ -200,7 +236,7 @@ func TestInjectVideoCreativeTrackers(t *testing.T) { }, }, }, - wantAdm: ``, + wantAdm: ``, wantErr: false, }, { @@ -236,7 +272,7 @@ func TestInjectVideoCreativeTrackers(t *testing.T) { }, }, }, - wantAdm: ``, + wantAdm: ``, wantErr: false, }, { @@ -345,7 +381,7 @@ func TestInjectVideoCreativeTrackers(t *testing.T) { }, }, wantBurl: "Tracker URL&owsspburl=https://burl.com", - wantAdm: ``, + wantAdm: ``, wantErr: false, }, { @@ -391,7 +427,7 @@ func TestInjectVideoCreativeTrackers(t *testing.T) { }, }, wantBurl: "Tracker URL&owsspburl=https://burl.com", - wantAdm: ``, + wantAdm: ``, wantErr: false, }, { @@ -439,7 +475,7 @@ func getXMLDocument(tag string) *etree.Document { return doc } -func Test_injectPricingNodeVAST20(t *testing.T) { +func Test_injectPricingNodeInExtension0(t *testing.T) { type args struct { doc *etree.Document price float64 @@ -524,14 +560,14 @@ func Test_injectPricingNodeVAST20(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - injectPricingNodeVAST20(&tt.args.doc.Element, tt.args.price, tt.args.model, tt.args.currency) + InjectPricingNodeInExtension(&tt.args.doc.Element, tt.args.price, tt.args.model, tt.args.currency) actual, _ := tt.args.doc.WriteToString() assert.Equal(t, tt.want, actual) }) } } -func Test_injectPricingNodeVAST3x(t *testing.T) { +func Test_injectPricingNodeInVAST(t *testing.T) { type args struct { doc *etree.Document price float64 @@ -596,7 +632,7 @@ func Test_injectPricingNodeVAST3x(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - injectPricingNodeVAST3x(&tt.args.doc.Element, tt.args.price, tt.args.model, tt.args.currency) + InjectPricingNodeInVAST(&tt.args.doc.Element, tt.args.price, tt.args.model, tt.args.currency) actual, _ := tt.args.doc.WriteToString() assert.Equal(t, tt.want, actual) }) diff --git a/openrtb_ext/bidders.go b/openrtb_ext/bidders.go index 163c893415d..f36e7d0e10f 100644 --- a/openrtb_ext/bidders.go +++ b/openrtb_ext/bidders.go @@ -147,6 +147,7 @@ var coreBidderNames []BidderName = []BidderName{ BidderMotorik, BidderNextMillennium, BidderNoBid, + BidderORTBTestBidder, // maintained by OW BidderOms, BidderOneTag, BidderOpenWeb, @@ -466,7 +467,6 @@ const ( BidderSovrn BidderName = "sovrn" BidderSovrnXsp BidderName = "sovrnXsp" BidderSspBC BidderName = "sspBC" - BidderSpotX BidderName = "spotx" BidderStreamkey BidderName = "streamkey" BidderStroeerCore BidderName = "stroeerCore" BidderTaboola BidderName = "taboola" @@ -482,7 +482,6 @@ const ( BidderUnicorn BidderName = "unicorn" BidderUnruly BidderName = "unruly" BidderValueImpression BidderName = "valueimpression" - BidderVASTBidder BidderName = "vastbidder" BidderVideoByte BidderName = "videobyte" BidderVideoHeroes BidderName = "videoheroes" BidderVidoomy BidderName = "vidoomy" diff --git a/openrtb_ext/bidders_ow.go b/openrtb_ext/bidders_ow.go new file mode 100644 index 00000000000..4251ebd8caa --- /dev/null +++ b/openrtb_ext/bidders_ow.go @@ -0,0 +1,8 @@ +package openrtb_ext + +// constant of OW specific bidders +const ( + BidderORTBTestBidder BidderName = "owortb_testbidder" + BidderSpotX BidderName = "spotx" + BidderVASTBidder BidderName = "vastbidder" +) diff --git a/openrtb_ext/openwrap.go b/openrtb_ext/openwrap.go index 47670d30cd8..2b37f513953 100644 --- a/openrtb_ext/openwrap.go +++ b/openrtb_ext/openwrap.go @@ -72,7 +72,7 @@ type ExtVideoAdPod struct { // ExtRequestAdPod holds AdPod specific extension parameters at request level type ExtRequestAdPod struct { - VideoAdPod + *VideoAdPod CrossPodAdvertiserExclusionPercent *int `json:"crosspodexcladv,omitempty"` //Percent Value - Across multiple impression there will be no ads from same advertiser. Note: These cross pod rule % values can not be more restrictive than per pod CrossPodIABCategoryExclusionPercent *int `json:"crosspodexcliabcat,omitempty"` //Percent Value - Across multiple impression there will be no ads from same advertiser IABCategoryExclusionWindow *int `json:"excliabcatwindow,omitempty"` //Duration in minute between pods where exclusive IAB rule needs to be applied @@ -118,35 +118,39 @@ func getIntPtr(v int) *int { // Validate will validate AdPod object func (pod *VideoAdPod) Validate() (err []error) { - if nil != pod.MinAds && *pod.MinAds <= 0 { + if pod == nil { + return + } + + if pod.MinAds != nil && *pod.MinAds <= 0 { err = append(err, errInvalidMinAds) } - if nil != pod.MaxAds && *pod.MaxAds <= 0 { + if pod.MaxAds != nil && *pod.MaxAds <= 0 { err = append(err, errInvalidMaxAds) } - if nil != pod.MinDuration && *pod.MinDuration <= 0 { + if pod.MinDuration != nil && *pod.MinDuration < 0 { err = append(err, errInvalidMinDuration) } - if nil != pod.MaxDuration && *pod.MaxDuration <= 0 { + if pod.MaxDuration != nil && *pod.MaxDuration <= 0 { err = append(err, errInvalidMaxDuration) } - if nil != pod.AdvertiserExclusionPercent && (*pod.AdvertiserExclusionPercent < 0 || *pod.AdvertiserExclusionPercent > 100) { + if pod.AdvertiserExclusionPercent != nil && (*pod.AdvertiserExclusionPercent < 0 || *pod.AdvertiserExclusionPercent > 100) { err = append(err, errInvalidAdvertiserExclusionPercent) } - if nil != pod.IABCategoryExclusionPercent && (*pod.IABCategoryExclusionPercent < 0 || *pod.IABCategoryExclusionPercent > 100) { + if pod.IABCategoryExclusionPercent != nil && (*pod.IABCategoryExclusionPercent < 0 || *pod.IABCategoryExclusionPercent > 100) { err = append(err, errInvalidIABCategoryExclusionPercent) } - if nil != pod.MinAds && nil != pod.MaxAds && *pod.MinAds > *pod.MaxAds { + if pod.MinAds != nil && pod.MaxAds != nil && *pod.MinAds > *pod.MaxAds { err = append(err, errInvalidMinMaxAds) } - if nil != pod.MinDuration && nil != pod.MaxDuration && *pod.MinDuration > *pod.MaxDuration { + if pod.MinDuration != nil && pod.MaxDuration != nil && *pod.MinDuration > *pod.MaxDuration { err = append(err, errInvalidMinMaxDuration) } @@ -192,6 +196,10 @@ func (ext *ExtRequestAdPod) Validate() (err []error) { // Validate will validate video extension object func (ext *ExtVideoAdPod) Validate() (err []error) { + if ext == nil { + return + } + if nil != ext.Offset && *ext.Offset < 0 { err = append(err, errInvalidAdPodOffset) } @@ -209,6 +217,10 @@ func (ext *ExtVideoAdPod) Validate() (err []error) { // SetDefaultValue will set default values if not present func (pod *VideoAdPod) SetDefaultValue() { + if pod == nil { + return + } + //pod.MinAds setting default value if nil == pod.MinAds { pod.MinAds = getIntPtr(1) @@ -272,6 +284,10 @@ func (ext *ExtVideoAdPod) SetDefaultValue() { // SetDefaultAdDuration will set default pod ad slot durations func (pod *VideoAdPod) SetDefaultAdDurations(podMinDuration, podMaxDuration int64) { + if pod == nil { + return + } + //pod.MinDuration setting default adminduration if nil == pod.MinDuration { duration := int(podMinDuration / 2) @@ -287,6 +303,10 @@ func (pod *VideoAdPod) SetDefaultAdDurations(podMinDuration, podMaxDuration int6 // Merge VideoAdPod Values func (pod *VideoAdPod) Merge(parent *VideoAdPod) { + if pod == nil { + return + } + //pod.MinAds setting default value if nil == pod.MinAds { pod.MinAds = parent.MinAds @@ -297,6 +317,8 @@ func (pod *VideoAdPod) Merge(parent *VideoAdPod) { pod.MaxAds = parent.MaxAds } + // Add Min and Max duration from request + //pod.AdvertiserExclusionPercent setting default value if nil == pod.AdvertiserExclusionPercent { pod.AdvertiserExclusionPercent = parent.AdvertiserExclusionPercent diff --git a/openrtb_ext/openwrap_test.go b/openrtb_ext/openwrap_test.go index ecd2ebefee9..dceb053b271 100644 --- a/openrtb_ext/openwrap_test.go +++ b/openrtb_ext/openwrap_test.go @@ -55,13 +55,6 @@ func TestVideoAdPod_Validate(t *testing.T) { }, wantErr: []error{errInvalidMinDuration}, }, - { - name: "ZeroMinDuration", - fields: fields{ - MinDuration: getIntPtr(0), - }, - wantErr: []error{errInvalidMinDuration}, - }, { name: "ErrInvalidMaxDuration", fields: fields{ @@ -246,7 +239,7 @@ func TestExtRequestAdPod_Validate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ext := &ExtRequestAdPod{ - VideoAdPod: tt.fields.VideoAdPod, + VideoAdPod: &tt.fields.VideoAdPod, CrossPodAdvertiserExclusionPercent: tt.fields.CrossPodAdvertiserExclusionPercent, CrossPodIABCategoryExclusionPercent: tt.fields.CrossPodIABCategoryExclusionPercent, IABCategoryExclusionWindow: tt.fields.IABCategoryExclusionWindow, diff --git a/router/router_test.go b/router/router_test.go index 866a8440f3f..d023f4f864b 100644 --- a/router/router_test.go +++ b/router/router_test.go @@ -61,7 +61,8 @@ func TestNewJsonDirectoryServer(t *testing.T) { } for _, adapterFile := range adapterFiles { - if adapterFile.IsDir() && adapterFile.Name() != "adapterstest" { + // OW specific: ignore ortbbidder because we are maintaining single adapterFile for multiple bidders + if adapterFile.IsDir() && adapterFile.Name() != "adapterstest" && adapterFile.Name() != "ortbbidder" { ensureHasKey(t, data, adapterFile.Name()) } } diff --git a/static/bidder-info/owortb_testbidder.yaml b/static/bidder-info/owortb_testbidder.yaml new file mode 100644 index 00000000000..a8175136b10 --- /dev/null +++ b/static/bidder-info/owortb_testbidder.yaml @@ -0,0 +1,11 @@ +# sample bidder-info yaml for testbidder (oRTB Integration) +maintainer: + email: "header-bidding@pubmatic.com" +endpoint: "http://test.endpoint.com" +capabilities: + app: + mediaTypes: + - video + site: + mediaTypes: + - video diff --git a/static/bidder-params/owortb_testbidder.json b/static/bidder-params/owortb_testbidder.json new file mode 100644 index 00000000000..b0ce3c53aed --- /dev/null +++ b/static/bidder-params/owortb_testbidder.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "testbidder (oRTB Integration) Adapter Params", + "description": "A schema which validates params accepted by the testbidder (oRTB Integration)", + "type": "object", + "properties": { + "adunitID": { + "type": "string", + "description": "adunitID param", + "location": "ext.adunit.id" + } + }, + "required": ["adunitID"] +} \ No newline at end of file