diff --git a/adapters/spotx/spotx.go b/adapters/spotx/spotx.go new file mode 100644 index 00000000000..2aa9a912517 --- /dev/null +++ b/adapters/spotx/spotx.go @@ -0,0 +1,451 @@ +package spotx + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "math" + "net/http" + "strconv" + "strings" + + "github.com/mxmCherry/openrtb" + "github.com/prebid/prebid-server/adapters" + "github.com/prebid/prebid-server/errortypes" + "github.com/prebid/prebid-server/openrtb_ext" + "github.com/prebid/prebid-server/pbs" + "golang.org/x/net/context/ctxhttp" +) + +const ( + MINUTE = 60 // Seconds + HOUR = 60 * MINUTE + TimeStringLength = len("9999:99:99.999") + TagName = "duration" + TagNameSkip = len(TagName) + 1 +) + +var ( + ortbVersionMap = map[string]string{ + "": "2.3", + "2.3": "2.3", + "2.5": "2.5", + } +) + +func parseParam(paramsInput json.RawMessage) (config openrtb_ext.ExtImpSpotX, err error) { + if err = json.Unmarshal(paramsInput, &config); err != nil { + return + } + + if config.ChannelID == 0 { + return config, errors.New("invalid channel id") + } + + if v, ok := ortbVersionMap[config.ORTBVersion]; ok { + config.ORTBVersion = v + } else { + return config, errors.New("unsupported Open RTB version") + } + + return +} + +func getVideoImp(bid *openrtb.Bid, imps []openrtb.Imp) *openrtb_ext.ExtBidPrebidVideo { + var ( + duration float64 = 0 + cat string + ) + + for _, imp := range imps { + if imp.ID == bid.ImpID { + if imp.Video != nil { + if len(bid.Cat) > 0 { + cat = bid.Cat[0] + } + + if bid.AdM != "" { + timeCodeComponents := strings.Split(getTimeStringFromVastResponse(bid.AdM), ":") + + if len(timeCodeComponents) == 3 { + if i, err := strconv.Atoi(timeCodeComponents[0]); err == nil { + duration = float64(i * HOUR) + } + if i, err := strconv.Atoi(timeCodeComponents[1]); err == nil { + duration += float64(i * MINUTE) + } + if f, err := strconv.ParseFloat(timeCodeComponents[2], 64); err == nil { + duration += f + } + } + } + + return &openrtb_ext.ExtBidPrebidVideo{ + Duration: int(math.Round(duration)), + PrimaryCategory: cat, + } + } + break + } + } + return nil +} + +func getTimeStringFromVastResponse(vastResponse string) string { + var ( + dur = make([]uint8, 0, TimeStringLength) + idx int + temp string + char uint8 + ) + +vastXmlSearch: + for i := 0; i < len(vastResponse); i++ { // Iterate over every character + char = vastResponse[i] + if char == '<' { // until we find a tag open + temp = strings.ToLower(string(vastResponse[i+1 : i+TagNameSkip])) + if temp == TagName { // and tag named "duration" + i += TagNameSkip + for { // Are there properties on it? We don't care about those right now + char = vastResponse[i] + i++ + if char == '>' { + break + } + } + + for { // we iterate to grab the data + if char = vastResponse[i]; char == '<' { + break vastXmlSearch // and exit when we get to the close tag + } + dur = append(dur, char) // and copy it into our temp holder + i++ + } + } else { // or keep going if it's not + if idx = strings.Index(temp, "<"); idx == -1 { + i += TagNameSkip // we've already looked at those characters so we can advance over them + } else { + i += idx // UNLESS there's an another open tag in that string + } + } + } + } + return string(dur) +} + +func getMediaTypeForImp(impId string, imps []openrtb.Imp) openrtb_ext.BidType { + for _, imp := range imps { + if imp.ID == impId { + if imp.Video != nil { + return openrtb_ext.BidTypeVideo + } else if imp.Banner != nil { + return openrtb_ext.BidTypeBanner + } else if imp.Native != nil { + return openrtb_ext.BidTypeNative + } else if imp.Audio != nil { + return openrtb_ext.BidTypeAudio + } + } + } + return openrtb_ext.BidTypeVideo +} + +type spotxReqExt struct { + Spotx json.RawMessage `json:"spotx,omitempty"` +} + +func kvpToExt(items []openrtb_ext.ExtImpSpotXKeyVal) json.RawMessage { + result := map[string][]string{} + for _, kvp := range items { + result[kvp.Key] = kvp.Values + } + data, err := json.Marshal(map[string]map[string][]string{"custom": result}) + if err != nil { + return nil + } + return data +} + +type SpotxAdapter struct { + http *adapters.HTTPAdapter + URI string +} + +func (a *SpotxAdapter) makeOpenRTBRequest(ctx context.Context, ortbReq *openrtb.BidRequest, param *openrtb_ext.ExtImpSpotX, isDebug bool) (*openrtb.BidResponse, *pbs.BidderDebug, error) { + reqJSON, err := json.Marshal(ortbReq) + if err != nil { + return nil, nil, err + } + + uri := a.getURL(param) + + debug := &pbs.BidderDebug{ + RequestURI: uri, + } + + if isDebug { + debug.RequestBody = string(reqJSON) + } + + httpReq, err := http.NewRequest("POST", uri, bytes.NewBuffer(reqJSON)) + httpReq.Header.Add("Content-Type", "application/json;charset=utf-8") + httpReq.Header.Add("Accept", "application/json") + httpReq.Header.Add("User-Agent", ortbReq.Device.UA) + + resp, err := ctxhttp.Do(ctx, a.http.Client, httpReq) + if err != nil { + return nil, debug, err + } + + debug.StatusCode = resp.StatusCode + + if resp.StatusCode == 204 { + return nil, debug, nil + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, debug, err + } + responseBody := string(body) + + if resp.StatusCode == http.StatusBadRequest { + return nil, debug, &errortypes.BadInput{ + Message: fmt.Sprintf("HTTP status %d; body: %s", resp.StatusCode, responseBody), + } + } + + if resp.StatusCode != http.StatusOK { + return nil, debug, &errortypes.BadServerResponse{ + Message: fmt.Sprintf("HTTP status %d; body: %s", resp.StatusCode, responseBody), + } + } + + if isDebug { + debug.ResponseBody = responseBody + } + + var bidResp openrtb.BidResponse + err = json.Unmarshal(body, &bidResp) + if err != nil { + return nil, debug, err + } + return &bidResp, debug, nil +} + +func (a *SpotxAdapter) getURL(param *openrtb_ext.ExtImpSpotX) string { + return fmt.Sprintf("%s/%s/%d", a.URI, param.ORTBVersion, param.ChannelID) +} + +// Name must be identical to the BidderName. +func (a *SpotxAdapter) Name() string { + return string(openrtb_ext.BidderSpotx) +} + +// Determines whether this adapter should get callouts if there is not a synched user ID. +func (a *SpotxAdapter) SkipNoCookies() bool { + return false +} + +// Call produces bids which should be considered, given the auction params. +// +// In practice, implementations almost always make one call to an external server here. +// However, that is not a requirement for satisfying this interface. +// +// An error here will cause all bids to be ignored. If the error was caused by bad user input, +// this should return a BadInputError. If it was caused by bad server behavior +// (e.g. 500, unexpected response format, etc), this should return a BadServerResponseError. +func (a *SpotxAdapter) Call(ctx context.Context, req *pbs.PBSRequest, bidder *pbs.PBSBidder) (pbs.PBSBidSlice, error) { + supportedMediaTypes := []pbs.MediaType{pbs.MEDIA_TYPE_BANNER, pbs.MEDIA_TYPE_VIDEO} + ortbReq, err := adapters.MakeOpenRTBGeneric(req, bidder, a.Name(), supportedMediaTypes) + + if err != nil { + return nil, err + } + + var param openrtb_ext.ExtImpSpotX + + for i, unit := range bidder.AdUnits { + param, err = parseParam(unit.Params) + if err != nil { + return nil, err + } + + ortbReq.Imp[i].ID = unit.BidID + ortbReq.Imp[i].BidFloor = param.PriceFloor + ortbReq.Imp[i].BidFloorCur = param.Currency + + if param.Boxing.Valid && param.Boxing.Bool { + ortbReq.Imp[1].Video.BoxingAllowed = int8(1) + } + + if len(param.KVP) > 0 { + ortbReq.Imp[i].Video.Ext = kvpToExt(param.KVP) + } + } + + ortbReq.BAdv = param.BlackList.Advertiser + ortbReq.BCat = param.BlackList.Category + + ortbReq.WLang = param.WhiteList.Language + + if len(param.WhiteList.Seat) > 0 { + ortbReq.WSeat = param.WhiteList.Seat + } else if len(param.BlackList.Seat) > 0 { + ortbReq.BSeat = param.BlackList.Seat + } + + bidResp, debug, err := a.makeOpenRTBRequest(ctx, &ortbReq, ¶m, req.IsDebug) + if req.IsDebug { + bidder.Debug = append(bidder.Debug, debug) + } + if err != nil { + return nil, err + } + + bids := make(pbs.PBSBidSlice, 0) + numBids := 0 + for _, sb := range bidResp.SeatBid { + for _, bid := range sb.Bid { + numBids++ + + bidID := bidder.LookupBidID(bid.ImpID) + if bidID == "" { + return nil, &errortypes.BadServerResponse{ + Message: fmt.Sprintf("Unknown ad unit code '%s'", bid.ImpID), + } + } + + pbid := pbs.PBSBid{ + BidID: bidID, + AdUnitCode: bid.ImpID, + BidderCode: bidder.BidderCode, + Price: bid.Price, + Adm: bid.AdM, + Creative_id: bid.CrID, + Width: bid.W, + Height: bid.H, + DealId: bid.DealID, + } + + mediaType := getMediaTypeForImp(bid.ImpID, ortbReq.Imp) + pbid.CreativeMediaType = string(mediaType) + + bids = append(bids, &pbid) + } + } + + return bids, nil +} + +// MakeRequests makes the HTTP requests which should be made to fetch bids. +// +// Bidder implementations can assume that the incoming BidRequest has: +// +// 1. Only {Imp.Type, Platform} combinations which are valid, as defined by the static/bidder-info.{bidder}.yaml file. +// 2. Imp.Ext of the form {"bidder": params}, where "params" has been validated against the static/bidder-params/{bidder}.json JSON Schema. +// +// nil return values are acceptable, but nil elements *inside* those slices are not. +// +// The errors should contain a list of errors which explain why this bidder's bids will be +// "subpar" in some way. For example: the request contained ad types which this bidder doesn't support. +// +// If the error is caused by bad user input, return an errortypes.BadInput. +func (a *SpotxAdapter) MakeRequests(request *openrtb.BidRequest, _ *adapters.ExtraRequestInfo) (result []*adapters.RequestData, errs []error) { + if len(request.Ext) > 0 { + var ext spotxReqExt + var param openrtb_ext.ExtImpSpotX + if err := json.Unmarshal(request.Ext, &ext); err == nil { + + if param, err = parseParam(ext.Spotx); err != nil { + return nil, []error{err} + } + + uri := a.getURL(¶m) + + headers := http.Header{} + headers.Add("Content-Type", "application/json;charset=utf-8") + headers.Add("Accept", "application/json") + + reqJSON, err := json.Marshal(request) + if err != nil { + errs = append(errs, err) + return + } + + result = []*adapters.RequestData{{ + Method: "POST", + Uri: uri, + Body: reqJSON, + Headers: headers, + }} + } else { + errs = append(errs, err) + } + } else { + errs = append(errs, errors.New("no extension data found")) + } + return +} + +// MakeBids unpacks the server's response into Bids. +// +// The bids can be nil (for no bids), but should not contain nil elements. +// +// The errors should contain a list of errors which explain why this bidder's bids will be +// "subpar" in some way. For example: the server response didn't have the expected format. +// +// If the error was caused by bad user input, return a errortypes.BadInput. +// If the error was caused by a bad server response, return a errortypes.BadServerResponse +func (a *SpotxAdapter) MakeBids(internalRequest *openrtb.BidRequest, externalRequest *adapters.RequestData, response *adapters.ResponseData) (*adapters.BidderResponse, []error) { + if response.StatusCode == http.StatusNoContent { + return nil, nil + } + + if response.StatusCode == http.StatusBadRequest { + return nil, []error{&errortypes.BadInput{ + Message: fmt.Sprintf("unexpected status code: %d. Run with request.debug = 1 for more info", response.StatusCode), + }} + } + + if response.StatusCode != http.StatusOK { + return nil, []error{fmt.Errorf("unexpected status code: %d. Run with request.debug = 1 for more info", response.StatusCode)} + } + + var bidResp openrtb.BidResponse + if err := json.Unmarshal(response.Body, &bidResp); err != nil { + return nil, []error{err} + } + + bidResponses := adapters.NewBidderResponseWithBidsCapacity(len(bidResp.SeatBid)) + var errs []error + + for _, sb := range bidResp.SeatBid { + for i := 0; i < len(sb.Bid); i++ { + bid := sb.Bid[i] + bidResponses.Bids = append(bidResponses.Bids, &adapters.TypedBid{ + BidType: getMediaTypeForImp(bid.ImpID, internalRequest.Imp), + Bid: &bid, + BidVideo: getVideoImp(&bid, internalRequest.Imp), + }) + } + } + return bidResponses, errs +} + +func NewAdapter(config *adapters.HTTPAdapterConfig, endpoint string) *SpotxAdapter { + return NewBidder(adapters.NewHTTPAdapter(config).Client, endpoint) +} + +func NewBidder(client *http.Client, endpoint string) *SpotxAdapter { + a := &adapters.HTTPAdapter{Client: client} + + return &SpotxAdapter{ + http: a, + URI: endpoint, + } +} diff --git a/adapters/spotx/spotx_test.go b/adapters/spotx/spotx_test.go new file mode 100644 index 00000000000..9de55f57bde --- /dev/null +++ b/adapters/spotx/spotx_test.go @@ -0,0 +1,12 @@ +package spotx + +import ( + "net/http" + "testing" + + "github.com/prebid/prebid-server/adapters/adapterstest" +) + +func TestJsonSamples(t *testing.T) { + adapterstest.RunJSONBidderTest(t, "spotxtests", NewBidder(new(http.Client), "http://search.spotxchange.com/openrtb")) +} diff --git a/adapters/spotx/spotxtests/exemplary/ortb_2.3_video.json b/adapters/spotx/spotxtests/exemplary/ortb_2.3_video.json new file mode 100644 index 00000000000..d28a5e3bf1d --- /dev/null +++ b/adapters/spotx/spotxtests/exemplary/ortb_2.3_video.json @@ -0,0 +1,202 @@ +{ + "mockBidRequest": { + "id": "1510298791250_2064201275_379330170", + "imp": [ + { + "id": "1510298791250_2064201275_379330170", + "video": { + "mimes": [ + "video/x-flv", + "video/webm", + "application/javascript", + "application/x-shockwave-flash", + "video/flv", + "video/mp4" + ], + "protocols": [ + 2, + 3, + 5, + 6 + ], + "w": 400, + "h": 300, + "boxingallowed": 1, + "api": [ + 1, + 2 + ] + }, + "bidfloor": 1.25, + "bidfloorcur": "USD", + "ext":{ + "spotx":{ + "ortb_version": "2.3", + "channel_id": 79391 + } + } + } + ], + "site": { + "domain":"cnn.com", + "page": "https://www.cnn.com/" + }, + "device": { + "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36", + "ip": "115.114.59.182" + }, + "badv": [ + "shangjiamedia.com", + "trackmytraffic.biz", + "helllll.com", + "evangmedia.com", + "talk915.pw" + ], + "tmax": 9000, + "ext": { + "spotx":{ + "ortb_version": "2.3", + "channel_id": 79391 + } + } + } +, + "httpCalls": [ + { + "expectedRequest": { + "uri": "http://search.spotxchange.com/openrtb/2.3/79391", + "body": { + "id":"1510298791250_2064201275_379330170", + "imp":[ + { + "id":"1510298791250_2064201275_379330170", + "bidfloor": 1.25, + "bidfloorcur": "USD", + "video":{ + "mimes":[ + "video/x-flv", + "video/webm", + "application/javascript", + "application/x-shockwave-flash", + "video/flv", + "video/mp4" + ], + "protocols":[ + 2, + 3, + 5, + 6 + ], + "w":400, + "h":300, + "boxingallowed": 1, + "api": [ + 1, + 2 + ] + }, + "ext":{ + "spotx":{ + "ortb_version": "2.3", + "channel_id": 79391 + } + } + } + ], + "site":{ + "domain":"cnn.com", + "page":"https://www.cnn.com/" + }, + "device":{ + "ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36", + "ip":"115.114.59.182" + }, + "ext": { + "spotx":{ + "ortb_version": "2.3", + "channel_id": 79391 + } + }, + "tmax":9000, + "badv":[ + "shangjiamedia.com", + "trackmytraffic.biz", + "helllll.com", + "evangmedia.com", + "talk915.pw" + ] + } + }, + "mockResponse": { + "status": 200, + "body": { + "id":"", + "cur":"USD", + "seatbid":[ + { + "bid":[ + { + "id":"13426.9a54f.6cd9", + "impid":"1510298791250_2064201275_379330170", + "impression_guid":"17c6d7fe8bc711e98f5619de6f530001", + "price":0.6, + "adm":"\nSpotXchange<\/AdSystem><\/AdTitle><\/Description><\/Impression><\/Error><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression>00:00:15<\/Duration><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/TrackingEvents><\/ClickThrough><\/ClickTracking><\/VideoClicks><\/MediaFile><\/MediaFiles><\/Linear><\/Creative><\/Creatives><\/Price><\/Extension><\/total_available><\/Extension>0<\/GDPR><\/Extension>2<\/consent><\/Extension><\/Extensions><\/InLine><\/Ad><\/VAST>", + "adomain":[ + "spotx.tv" + ], + "crid":"13565a9655c487ce8175e85031ec77e4-13426.9a54f.6cd9", + "cid":null, + "h":540, + "w":960, + "cat":[ + "IAB3", + "IAB19", + "IAB3-1", + "IAB19-18" + ] + } + ] + } + ], + "regs":{ + "ext":{ + "gdpr":0 + } + }, + "user":{ + "ext":{ + "consent":"2" + } + } + } + } + } + ], + "expectedBidResponses": [ + { + "currency": "USD", + "bids": [ + { + + "bid": { + "id": "13426.9a54f.6cd9", + "impid": "1510298791250_2064201275_379330170", + "adm": "\nSpotXchange00:00:1502", + "w": 960, + "h": 540, + "adomain": ["spotx.tv"], + "crid": "13565a9655c487ce8175e85031ec77e4-13426.9a54f.6cd9", + "price": 0.6, + "cat": [ + "IAB3", + "IAB19", + "IAB3-1", + "IAB19-18" + ] + }, + "type": "video" + } + ] + } + ] +} \ No newline at end of file diff --git a/adapters/spotx/spotxtests/exemplary/ortb_2.5_video.json b/adapters/spotx/spotxtests/exemplary/ortb_2.5_video.json new file mode 100644 index 00000000000..e8b6ded6b8c --- /dev/null +++ b/adapters/spotx/spotxtests/exemplary/ortb_2.5_video.json @@ -0,0 +1,202 @@ +{ + "mockBidRequest": { + "id": "1510298791250_2064201275_379330170", + "imp": [ + { + "id": "1510298791250_2064201275_379330170", + "video": { + "mimes": [ + "video/x-flv", + "video/webm", + "application/javascript", + "application/x-shockwave-flash", + "video/flv", + "video/mp4" + ], + "protocols": [ + 2, + 3, + 5, + 6 + ], + "w": 400, + "h": 300, + "boxingallowed": 1, + "api": [ + 1, + 2 + ] + }, + "bidfloor": 1.25, + "bidfloorcur": "USD", + "ext":{ + "spotx":{ + "ortb_version": "2.5", + "channel_id": 79391 + } + } + } + ], + "site": { + "domain":"cnn.com", + "page": "https://www.cnn.com/" + }, + "device": { + "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36", + "ip": "115.114.59.182" + }, + "badv": [ + "shangjiamedia.com", + "trackmytraffic.biz", + "helllll.com", + "evangmedia.com", + "talk915.pw" + ], + "tmax": 9000, + "ext": { + "spotx":{ + "ortb_version": "2.5", + "channel_id": 79391 + } + } + } +, + "httpCalls": [ + { + "expectedRequest": { + "uri": "http://search.spotxchange.com/openrtb/2.5/79391", + "body": { + "id":"1510298791250_2064201275_379330170", + "imp":[ + { + "id":"1510298791250_2064201275_379330170", + "bidfloor": 1.25, + "bidfloorcur": "USD", + "video":{ + "mimes":[ + "video/x-flv", + "video/webm", + "application/javascript", + "application/x-shockwave-flash", + "video/flv", + "video/mp4" + ], + "protocols":[ + 2, + 3, + 5, + 6 + ], + "w":400, + "h":300, + "boxingallowed": 1, + "api": [ + 1, + 2 + ] + }, + "ext":{ + "spotx":{ + "ortb_version": "2.5", + "channel_id": 79391 + } + } + } + ], + "site":{ + "domain":"cnn.com", + "page":"https://www.cnn.com/" + }, + "device":{ + "ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36", + "ip":"115.114.59.182" + }, + "ext": { + "spotx":{ + "ortb_version": "2.5", + "channel_id": 79391 + } + }, + "tmax":9000, + "badv":[ + "shangjiamedia.com", + "trackmytraffic.biz", + "helllll.com", + "evangmedia.com", + "talk915.pw" + ] + } + }, + "mockResponse": { + "status": 200, + "body": { + "id":"", + "cur":"USD", + "seatbid":[ + { + "bid":[ + { + "id":"13426.9a54f.6cd9", + "impid":"1510298791250_2064201275_379330170", + "impression_guid":"17c6d7fe8bc711e98f5619de6f530001", + "price":0.6, + "adm":"\nSpotXchange<\/AdSystem><\/AdTitle><\/Description><\/Impression><\/Error><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression>00:00:15<\/Duration><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/TrackingEvents><\/ClickThrough><\/ClickTracking><\/VideoClicks><\/MediaFile><\/MediaFiles><\/Linear><\/Creative><\/Creatives><\/Price><\/Extension><\/total_available><\/Extension>0<\/GDPR><\/Extension>2<\/consent><\/Extension><\/Extensions><\/InLine><\/Ad><\/VAST>", + "adomain":[ + "spotx.tv" + ], + "crid":"13565a9655c487ce8175e85031ec77e4-13426.9a54f.6cd9", + "cid":null, + "h":540, + "w":960, + "cat":[ + "IAB3", + "IAB19", + "IAB3-1", + "IAB19-18" + ] + } + ] + } + ], + "regs":{ + "ext":{ + "gdpr":0 + } + }, + "user":{ + "ext":{ + "consent":"2" + } + } + } + } + } + ], + "expectedBidResponses": [ + { + "currency": "USD", + "bids": [ + { + + "bid": { + "id": "13426.9a54f.6cd9", + "impid": "1510298791250_2064201275_379330170", + "adm": "\nSpotXchange00:00:1502", + "w": 960, + "h": 540, + "adomain": ["spotx.tv"], + "crid": "13565a9655c487ce8175e85031ec77e4-13426.9a54f.6cd9", + "price": 0.6, + "cat": [ + "IAB3", + "IAB19", + "IAB3-1", + "IAB19-18" + ] + }, + "type": "video" + } + ] + } + ] +} \ No newline at end of file diff --git a/adapters/spotx/spotxtests/exemplary/ortb_default_video.json b/adapters/spotx/spotxtests/exemplary/ortb_default_video.json new file mode 100644 index 00000000000..b78d7d473ca --- /dev/null +++ b/adapters/spotx/spotxtests/exemplary/ortb_default_video.json @@ -0,0 +1,198 @@ +{ + "mockBidRequest": { + "id": "1510298791250_2064201275_379330170", + "imp": [ + { + "id": "1510298791250_2064201275_379330170", + "video": { + "mimes": [ + "video/x-flv", + "video/webm", + "application/javascript", + "application/x-shockwave-flash", + "video/flv", + "video/mp4" + ], + "protocols": [ + 2, + 3, + 5, + 6 + ], + "w": 400, + "h": 300, + "boxingallowed": 1, + "api": [ + 1, + 2 + ] + }, + "bidfloor": 1.25, + "bidfloorcur": "USD", + "ext":{ + "spotx":{ + "channel_id": 79391 + } + } + } + ], + "site": { + "domain":"cnn.com", + "page": "https://www.cnn.com/" + }, + "device": { + "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36", + "ip": "115.114.59.182" + }, + "badv": [ + "shangjiamedia.com", + "trackmytraffic.biz", + "helllll.com", + "evangmedia.com", + "talk915.pw" + ], + "tmax": 9000, + "ext": { + "spotx":{ + "channel_id": 79391 + } + } + } +, + "httpCalls": [ + { + "expectedRequest": { + "uri": "http://search.spotxchange.com/openrtb/2.3/79391", + "body": { + "id":"1510298791250_2064201275_379330170", + "imp":[ + { + "id":"1510298791250_2064201275_379330170", + "bidfloor": 1.25, + "bidfloorcur": "USD", + "video":{ + "mimes":[ + "video/x-flv", + "video/webm", + "application/javascript", + "application/x-shockwave-flash", + "video/flv", + "video/mp4" + ], + "protocols":[ + 2, + 3, + 5, + 6 + ], + "w":400, + "h":300, + "boxingallowed": 1, + "api": [ + 1, + 2 + ] + }, + "ext":{ + "spotx":{ + "channel_id": 79391 + } + } + } + ], + "site":{ + "domain":"cnn.com", + "page":"https://www.cnn.com/" + }, + "device":{ + "ua":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36", + "ip":"115.114.59.182" + }, + "ext": { + "spotx":{ + "channel_id": 79391 + } + }, + "tmax":9000, + "badv":[ + "shangjiamedia.com", + "trackmytraffic.biz", + "helllll.com", + "evangmedia.com", + "talk915.pw" + ] + } + }, + "mockResponse": { + "status": 200, + "body": { + "id":"", + "cur":"USD", + "seatbid":[ + { + "bid":[ + { + "id":"13426.9a54f.6cd9", + "impid":"1510298791250_2064201275_379330170", + "impression_guid":"17c6d7fe8bc711e98f5619de6f530001", + "price":0.6, + "adm":"\nSpotXchange<\/AdSystem><\/AdTitle><\/Description><\/Impression><\/Error><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression><\/Impression>00:00:15<\/Duration><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/Tracking><\/TrackingEvents><\/ClickThrough><\/ClickTracking><\/VideoClicks><\/MediaFile><\/MediaFiles><\/Linear><\/Creative><\/Creatives><\/Price><\/Extension><\/total_available><\/Extension>0<\/GDPR><\/Extension>2<\/consent><\/Extension><\/Extensions><\/InLine><\/Ad><\/VAST>", + "adomain":[ + "spotx.tv" + ], + "crid":"13565a9655c487ce8175e85031ec77e4-13426.9a54f.6cd9", + "cid":null, + "h":540, + "w":960, + "cat":[ + "IAB3", + "IAB19", + "IAB3-1", + "IAB19-18" + ] + } + ] + } + ], + "regs":{ + "ext":{ + "gdpr":0 + } + }, + "user":{ + "ext":{ + "consent":"2" + } + } + } + } + } + ], + "expectedBidResponses": [ + { + "currency": "USD", + "bids": [ + { + + "bid": { + "id": "13426.9a54f.6cd9", + "impid": "1510298791250_2064201275_379330170", + "adm": "\nSpotXchange00:00:1502", + "w": 960, + "h": 540, + "adomain": ["spotx.tv"], + "crid": "13565a9655c487ce8175e85031ec77e4-13426.9a54f.6cd9", + "price": 0.6, + "cat": [ + "IAB3", + "IAB19", + "IAB3-1", + "IAB19-18" + ] + }, + "type": "video" + } + ] + } + ] +} \ No newline at end of file diff --git a/adapters/spotx/spotxtests/template/tmplt.json b/adapters/spotx/spotxtests/template/tmplt.json new file mode 100644 index 00000000000..8e782b952ce --- /dev/null +++ b/adapters/spotx/spotxtests/template/tmplt.json @@ -0,0 +1,20 @@ +{ + "mockBidRequest": { + + }, + "httpCalls": [ + { + "expectedRequest": { + + }, + "mockResponse": { + + } + } + ], + "expectedBidResponses": [ + { + + } + ] +} \ No newline at end of file diff --git a/config/config.go b/config/config.go index 6fd4fe061a8..0bdf642b2d8 100644 --- a/config/config.go +++ b/config/config.go @@ -639,6 +639,7 @@ func SetupViper(v *viper.Viper, filename string) { v.SetDefault("adapters.sonobi.endpoint", "https://apex.go.sonobi.com/prebid?partnerid=71d9d3d8af") v.SetDefault("adapters.unruly.endpoint", "http://targeting.unrulymedia.com/openrtb/2.2") v.SetDefault("adapters.vrtcal.endpoint", "http://rtb.vrtcal.com/bidder_prebid.vap?ssp=1804") + v.SetDefault("adapters.spotx.endpoint", "http://search.spotxchange.com/openrtb") v.SetDefault("adapters.yieldmo.endpoint", "http://ads.yieldmo.com/exchange/prebid-server") v.SetDefault("adapters.gamoshi.endpoint", "https://rtb.gamoshi.io") v.SetDefault("adapters.mgid.endpoint", "https://prebid.mgid.com/prebid/") diff --git a/endpoints/cookie_sync_test.go b/endpoints/cookie_sync_test.go index 9e7056e7de6..22ad7940ad2 100644 --- a/endpoints/cookie_sync_test.go +++ b/endpoints/cookie_sync_test.go @@ -91,7 +91,7 @@ func TestCookieSyncNoBidders(t *testing.T) { rr := doPost("{}", nil, true, syncersForTest()) assert.Equal(t, rr.Header().Get("Content-Type"), "application/json; charset=utf-8") assert.Equal(t, http.StatusOK, rr.Code) - assert.ElementsMatch(t, []string{"appnexus", "audienceNetwork", "lifestreet", "pubmatic"}, parseSyncs(t, rr.Body.Bytes())) + assert.ElementsMatch(t, []string{"appnexus", "audienceNetwork", "lifestreet", "pubmatic", "spotx"}, parseSyncs(t, rr.Body.Bytes())) assert.Equal(t, "no_cookie", parseStatus(t, rr.Body.Bytes())) } diff --git a/exchange/adapter_map.go b/exchange/adapter_map.go index ba41890b9cc..69a88d0d6ca 100644 --- a/exchange/adapter_map.go +++ b/exchange/adapter_map.go @@ -37,6 +37,7 @@ import ( "github.com/prebid/prebid-server/adapters/somoaudience" "github.com/prebid/prebid-server/adapters/sonobi" "github.com/prebid/prebid-server/adapters/sovrn" + "github.com/prebid/prebid-server/adapters/spotx" "github.com/prebid/prebid-server/adapters/tappx" "github.com/prebid/prebid-server/adapters/triplelift" "github.com/prebid/prebid-server/adapters/unruly" @@ -82,6 +83,7 @@ func newAdapterMap(client *http.Client, cfg *config.Configuration, infos adapter openrtb_ext.Bidder33Across: ttx.New33AcrossBidder(cfg.Adapters[string(openrtb_ext.Bidder33Across)].Endpoint), openrtb_ext.BidderGrid: grid.NewGridBidder(cfg.Adapters[string(openrtb_ext.BidderGrid)].Endpoint), openrtb_ext.BidderSonobi: sonobi.NewSonobiBidder(client, cfg.Adapters[string(openrtb_ext.BidderSonobi)].Endpoint), + openrtb_ext.BidderSpotx: spotx.NewBidder(client, cfg.Adapters[string(openrtb_ext.BidderSpotx)].Endpoint), openrtb_ext.BidderTriplelift: triplelift.NewTripleliftBidder(client, cfg.Adapters[string(openrtb_ext.BidderTriplelift)].Endpoint), openrtb_ext.BidderUnruly: unruly.NewUnrulyBidder(client, cfg.Adapters[string(openrtb_ext.BidderUnruly)].Endpoint), openrtb_ext.BidderVrtcal: vrtcal.NewVrtcalBidder(cfg.Adapters[string(openrtb_ext.BidderVrtcal)].Endpoint), diff --git a/openrtb_ext/bidders.go b/openrtb_ext/bidders.go index bf582b84bd3..11f8ab528f0 100644 --- a/openrtb_ext/bidders.go +++ b/openrtb_ext/bidders.go @@ -51,15 +51,11 @@ const ( BidderSomoaudience BidderName = "somoaudience" BidderSovrn BidderName = "sovrn" BidderSonobi BidderName = "sonobi" -<<<<<<< HEAD + BidderSpotx BidderName = "spotx" BidderTriplelift BidderName = "triplelift" BidderUnruly BidderName = "unruly" BidderVerizonMedia BidderName = "verizonmedia" BidderVrtcal BidderName = "vrtcal" - BidderSpotx BidderName = "spotxchange" -======= - BidderSpotx BidderName = "spotx" ->>>>>>> Updated package and branding name BidderYieldmo BidderName = "yieldmo" BidderVisx BidderName = "visx" BidderTappx BidderName = "tappx" @@ -98,6 +94,7 @@ var BidderMap = map[string]BidderName{ "somoaudience": BidderSomoaudience, "sovrn": BidderSovrn, "sonobi": BidderSonobi, + "spotx": BidderSpotx, "triplelift": BidderTriplelift, "unruly": BidderUnruly, "verizonmedia": BidderVerizonMedia, diff --git a/openrtb_ext/imp_spotx.go b/openrtb_ext/imp_spotx.go new file mode 100644 index 00000000000..87f82222304 --- /dev/null +++ b/openrtb_ext/imp_spotx.go @@ -0,0 +1,78 @@ +package openrtb_ext + +import ( + "encoding/json" + "fmt" + "reflect" +) + +// ExtImpSpotX defines the contract for bidrequest.imp[i].ext.spotx +// ORTBVersion refers the Open RTB contract version and is optional, but will default to 2.3 +// ChannelID refers to the publisher channel and is required +type ExtImpSpotX struct { + Boxing ExtImpSpotXNullBool `json:"boxing"` + ORTBVersion string `json:"ortb_version"` + ChannelID uint32 `json:"channel_id"` + WhiteList ExtImpSpotXWhiteList `json:"white_list,omitempty"` + BlackList ExtImpSpotXBlackList `json:"black_list,omitempty"` + PriceFloor float64 `json:"price_floor"` + Currency string `json:"currency"` + KVP []ExtImpSpotXKeyVal `json:"kvp,omitempty"` +} + +type ExtImpSpotXWhiteList struct { + Language []string `json:"lang,omitempty"` + Seat []string `json:"seat,omitempty"` +} + +type ExtImpSpotXBlackList struct { + Advertiser []string `json:"advertiser,omitempty"` + Category []string `json:"cat,omitempty"` + Seat []string `json:"seat,omitempty"` +} + +// ExtImpAppnexusKeyVal defines the contract for bidrequest.imp[i].ext.appnexus.keywords[i] +type ExtImpSpotXKeyVal struct { + Key string `json:"key,omitempty"` + Values []string `json:"value,omitempty"` +} + +// NullBool represents a bool that may be null. +// NullBool implements the Scanner interface so +// it can be used as a scan destination, similar to NullString. +type ExtImpSpotXNullBool struct { + Bool bool + Valid bool // Valid is true if Bool is not NULL +} + +// Scan implements the Scanner interface. +func (b *ExtImpSpotXNullBool) UnmarshalJSON(data []byte) error { + var err error + var v interface{} + if err = json.Unmarshal(data, &v); err != nil { + return err + } + switch x := v.(type) { + case bool: + b.Bool = x + case map[string]interface{}: + err = json.Unmarshal(data, &b.Bool) + case nil: + b.Valid = false + return nil + default: + err = fmt.Errorf("json: cannot unmarshal %v into Go value of type null.Bool", reflect.TypeOf(v).Name()) + } + b.Valid = err == nil + return err +} + +func (b ExtImpSpotXNullBool) MarshalJSON() ([]byte, error) { + if !b.Valid { + return []byte("null"), nil + } + if !b.Bool { + return []byte("false"), nil + } + return []byte("true"), nil +} diff --git a/router/router.go b/router/router.go index f0ac364e8fa..5e565dbaf26 100644 --- a/router/router.go +++ b/router/router.go @@ -23,6 +23,7 @@ import ( "github.com/prebid/prebid-server/adapters/pulsepoint" "github.com/prebid/prebid-server/adapters/rubicon" "github.com/prebid/prebid-server/adapters/sovrn" + "github.com/prebid/prebid-server/adapters/spotx" analyticsConf "github.com/prebid/prebid-server/analytics/config" "github.com/prebid/prebid-server/cache" "github.com/prebid/prebid-server/cache/dummycache" @@ -158,6 +159,7 @@ func newExchangeMap(cfg *config.Configuration) map[string]adapters.Adapter { "conversant": conversant.NewConversantAdapter(adapters.DefaultHTTPAdapterConfig, cfg.Adapters[string(openrtb_ext.BidderConversant)].Endpoint), "adform": adform.NewAdformAdapter(adapters.DefaultHTTPAdapterConfig, cfg.Adapters[string(openrtb_ext.BidderAdform)].Endpoint), "sovrn": sovrn.NewSovrnAdapter(adapters.DefaultHTTPAdapterConfig, cfg.Adapters[string(openrtb_ext.BidderSovrn)].Endpoint), + "spotx": spotx.NewAdapter(adapters.DefaultHTTPAdapterConfig, cfg.Adapters[string(openrtb_ext.BidderSpotx)].Endpoint), } } diff --git a/static/bidder-info/spotx.yaml b/static/bidder-info/spotx.yaml new file mode 100644 index 00000000000..3e44bd6eb08 --- /dev/null +++ b/static/bidder-info/spotx.yaml @@ -0,0 +1,14 @@ +# prebid-server//spotx +maintainer: + email: "info@spotx.tv" +capabilities: + app: + mediaTypes: + - banner + - video + - native + site: + mediaTypes: + - banner + - video + - native diff --git a/static/bidder-params/spotx.json b/static/bidder-params/spotx.json new file mode 100644 index 00000000000..93edfd2758a --- /dev/null +++ b/static/bidder-params/spotx.json @@ -0,0 +1,92 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Spotx Adapter Params", + "description": "A schema which validates params accepted by the SpotX adapter", + "type": "object", + "properties": { + "boxing": { + "type": "boolean", + "description": "Allow boxing" + }, + "channel_id": { + "type": "integer", + "description": "Identifies the publisher channel to retrieve ads from" + }, + "ortb_version": { + "type": "string", + "enum": ["2.3", "2.5"], + "description": "Identifies the schema version of Open RTB used" + }, + "price_floor": { + "type": "number", + "description": "Indicates the price floor" + }, + "currency": { + "type": "string", + "description": "Identifies the currency of the price floor" + }, + "white_list": { + "type": "object", + "description": "Item lists to force", + "properties": { + "lang": { + "type": "array", + "items": { + "type": "string" + } + }, + "seat": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "black_list": { + "type": "object", + "description": "Item lists to not use", + "properties": { + "advertiser": { + "type": "array", + "items": { + "type": "string" + } + }, + "cat": { + "type": "array", + "items": { + "type": "string" + } + }, + "seat": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "kvp": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "description": "A key with one or more values associated with it. These are used in buy-side segment targeting.", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + }, + "required": ["key"] + } + } + } +} \ No newline at end of file diff --git a/usersync/usersyncers/syncer.go b/usersync/usersyncers/syncer.go index e873c2c9bc1..568ce642b09 100644 --- a/usersync/usersyncers/syncer.go +++ b/usersync/usersyncers/syncer.go @@ -1,12 +1,6 @@ package usersyncers import ( -<<<<<<< HEAD -======= - "github.com/prebid/prebid-server/adapters/gamoshi" - "github.com/prebid/prebid-server/adapters/spotx" - ->>>>>>> Updated package and branding name "strings" "text/template" @@ -42,7 +36,7 @@ import ( "github.com/prebid/prebid-server/adapters/somoaudience" "github.com/prebid/prebid-server/adapters/sonobi" "github.com/prebid/prebid-server/adapters/sovrn" - "github.com/prebid/prebid-server/adapters/spotxchange" + "github.com/prebid/prebid-server/adapters/spotx" "github.com/prebid/prebid-server/adapters/triplelift" "github.com/prebid/prebid-server/adapters/unruly" "github.com/prebid/prebid-server/adapters/verizonmedia" @@ -89,16 +83,9 @@ func NewSyncerMap(cfg *config.Configuration) map[openrtb_ext.BidderName]usersync insertIntoMap(cfg, syncers, openrtb_ext.BidderSomoaudience, somoaudience.NewSomoaudienceSyncer) insertIntoMap(cfg, syncers, openrtb_ext.BidderSovrn, sovrn.NewSovrnSyncer) insertIntoMap(cfg, syncers, openrtb_ext.BidderSonobi, sonobi.NewSonobiSyncer) -<<<<<<< HEAD -<<<<<<< HEAD + insertIntoMap(cfg, syncers, openrtb_ext.BidderSpotx, spotx.NewSpotxSyncer) insertIntoMap(cfg, syncers, openrtb_ext.BidderTriplelift, triplelift.NewTripleliftSyncer) insertIntoMap(cfg, syncers, openrtb_ext.BidderVrtcal, vrtcal.NewVrtcalSyncer) -======= - insertIntoMap(cfg, syncers, openrtb_ext.BidderSpotx, spotxchange.NewSpotxSyncer) ->>>>>>> Add new user syncer -======= - insertIntoMap(cfg, syncers, openrtb_ext.BidderSpotx, spotx.NewSpotxSyncer) ->>>>>>> Updated package and branding name insertIntoMap(cfg, syncers, openrtb_ext.BidderYieldmo, yieldmo.NewYieldmoSyncer) insertIntoMap(cfg, syncers, openrtb_ext.BidderVisx, visx.NewVisxSyncer) insertIntoMap(cfg, syncers, openrtb_ext.BidderGamoshi, gamoshi.NewGamoshiSyncer) diff --git a/usersync/usersyncers/syncer_test.go b/usersync/usersyncers/syncer_test.go index 7f12d5265ce..c8ac47f4d89 100644 --- a/usersync/usersyncers/syncer_test.go +++ b/usersync/usersyncers/syncer_test.go @@ -45,6 +45,7 @@ func TestNewSyncerMap(t *testing.T) { string(openrtb_ext.BidderTriplelift): syncConfig, string(openrtb_ext.Bidder33Across): syncConfig, string(openrtb_ext.BidderSonobi): syncConfig, + string(openrtb_ext.BidderSpotx): syncConfig, string(openrtb_ext.BidderVrtcal): syncConfig, string(openrtb_ext.BidderYieldmo): syncConfig, string(openrtb_ext.BidderVisx): syncConfig,