github.com/prebid/prebid-server@v0.275.0/adapters/yieldlab/yieldlab.go (about) 1 package yieldlab 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "net/http" 7 "net/url" 8 "path" 9 "strconv" 10 "strings" 11 12 "golang.org/x/text/currency" 13 14 "github.com/prebid/openrtb/v19/openrtb2" 15 "github.com/prebid/prebid-server/adapters" 16 "github.com/prebid/prebid-server/config" 17 "github.com/prebid/prebid-server/errortypes" 18 "github.com/prebid/prebid-server/openrtb_ext" 19 ) 20 21 // YieldlabAdapter connects the Yieldlab API to prebid server 22 type YieldlabAdapter struct { 23 endpoint string 24 cacheBuster cacheBuster 25 getWeek weekGenerator 26 } 27 28 // Builder builds a new instance of the Yieldlab adapter for the given bidder with the given config. 29 func Builder(bidderName openrtb_ext.BidderName, config config.Adapter, server config.Server) (adapters.Bidder, error) { 30 bidder := &YieldlabAdapter{ 31 endpoint: config.Endpoint, 32 cacheBuster: defaultCacheBuster, 33 getWeek: defaultWeekGenerator, 34 } 35 return bidder, nil 36 } 37 38 // makeEndpointURL builds endpoint url based on adapter-specific pub settings from imp.ext 39 func (a *YieldlabAdapter) makeEndpointURL(req *openrtb2.BidRequest, params *openrtb_ext.ExtImpYieldlab) (string, error) { 40 uri, err := url.Parse(a.endpoint) 41 if err != nil { 42 return "", fmt.Errorf("failed to parse yieldlab endpoint: %v", err) 43 } 44 45 uri.Path = path.Join(uri.Path, params.AdslotID) 46 q := uri.Query() 47 q.Set("content", "json") 48 q.Set("pvid", "true") 49 q.Set("ts", a.cacheBuster()) 50 q.Set("t", a.makeTargetingValues(params)) 51 52 if hasFormats, formats := a.makeFormats(req); hasFormats { 53 q.Set("sizes", formats) 54 } 55 56 if req.User != nil && req.User.BuyerUID != "" { 57 q.Set("ids", "ylid:"+req.User.BuyerUID) 58 } 59 60 if req.Device != nil { 61 q.Set("yl_rtb_ifa", req.Device.IFA) 62 q.Set("yl_rtb_devicetype", fmt.Sprintf("%v", req.Device.DeviceType)) 63 64 if req.Device.ConnectionType != nil { 65 q.Set("yl_rtb_connectiontype", fmt.Sprintf("%v", req.Device.ConnectionType.Val())) 66 } 67 68 if req.Device.Geo != nil { 69 q.Set("lat", fmt.Sprintf("%v", req.Device.Geo.Lat)) 70 q.Set("lon", fmt.Sprintf("%v", req.Device.Geo.Lon)) 71 } 72 } 73 74 if req.App != nil { 75 q.Set("pubappname", req.App.Name) 76 q.Set("pubbundlename", req.App.Bundle) 77 } 78 79 gdpr, consent, err := a.getGDPR(req) 80 if err != nil { 81 return "", err 82 } 83 if gdpr != "" { 84 q.Set("gdpr", gdpr) 85 } 86 if consent != "" { 87 q.Set("consent", consent) 88 } 89 90 if req.Source != nil && req.Source.Ext != nil { 91 if openRtbSchain := unmarshalSupplyChain(req); openRtbSchain != nil { 92 if schainValue := makeSupplyChain(*openRtbSchain); schainValue != "" { 93 q.Set("schain", schainValue) 94 } 95 } 96 } 97 98 uri.RawQuery = q.Encode() 99 100 return uri.String(), nil 101 } 102 103 func (a *YieldlabAdapter) makeFormats(req *openrtb2.BidRequest) (bool, string) { 104 var formats []string 105 const sizesSeparator, adslotSizesSeparator = "|", "," 106 for _, impression := range req.Imp { 107 if !impIsTypeBannerOnly(impression) { 108 continue 109 } 110 111 var formatsPerAdslot []string 112 for _, format := range impression.Banner.Format { 113 formatsPerAdslot = append(formatsPerAdslot, fmt.Sprintf("%dx%d", format.W, format.H)) 114 } 115 adslotID := a.extractAdslotID(impression) 116 sizesForAdslot := strings.Join(formatsPerAdslot, sizesSeparator) 117 formats = append(formats, fmt.Sprintf("%s:%s", adslotID, sizesForAdslot)) 118 } 119 return len(formats) != 0, strings.Join(formats, adslotSizesSeparator) 120 } 121 122 func (a *YieldlabAdapter) getGDPR(request *openrtb2.BidRequest) (string, string, error) { 123 consent := "" 124 if request.User != nil && request.User.Ext != nil { 125 var extUser openrtb_ext.ExtUser 126 if err := json.Unmarshal(request.User.Ext, &extUser); err != nil { 127 return "", "", fmt.Errorf("failed to parse ExtUser in Yieldlab GDPR check: %v", err) 128 } 129 consent = extUser.Consent 130 } 131 132 gdpr := "" 133 var extRegs openrtb_ext.ExtRegs 134 if request.Regs != nil { 135 if err := json.Unmarshal(request.Regs.Ext, &extRegs); err == nil { 136 if extRegs.GDPR != nil && (*extRegs.GDPR == 0 || *extRegs.GDPR == 1) { 137 gdpr = strconv.Itoa(int(*extRegs.GDPR)) 138 } 139 } 140 } 141 142 return gdpr, consent, nil 143 } 144 145 func (a *YieldlabAdapter) makeTargetingValues(params *openrtb_ext.ExtImpYieldlab) string { 146 values := url.Values{} 147 for k, v := range params.Targeting { 148 values.Set(k, v) 149 } 150 return values.Encode() 151 } 152 153 func (a *YieldlabAdapter) MakeRequests(request *openrtb2.BidRequest, _ *adapters.ExtraRequestInfo) ([]*adapters.RequestData, []error) { 154 if len(request.Imp) == 0 { 155 return nil, []error{fmt.Errorf("invalid request %+v, no Impressions given", request)} 156 } 157 158 bidURL, err := a.makeEndpointURL(request, a.mergeParams(a.parseRequest(request))) 159 if err != nil { 160 return nil, []error{err} 161 } 162 163 headers := http.Header{} 164 headers.Add("Accept", "application/json") 165 if request.Site != nil { 166 headers.Add("Referer", request.Site.Page) 167 } 168 if request.Device != nil { 169 headers.Add("User-Agent", request.Device.UA) 170 headers.Add("X-Forwarded-For", request.Device.IP) 171 } 172 if request.User != nil { 173 headers.Add("Cookie", "id="+request.User.BuyerUID) 174 } 175 176 return []*adapters.RequestData{{ 177 Method: "GET", 178 Uri: bidURL, 179 Headers: headers, 180 }}, nil 181 } 182 183 // parseRequest extracts the Yieldlab request information from the request 184 func (a *YieldlabAdapter) parseRequest(request *openrtb2.BidRequest) []*openrtb_ext.ExtImpYieldlab { 185 params := make([]*openrtb_ext.ExtImpYieldlab, 0) 186 187 for i := 0; i < len(request.Imp); i++ { 188 bidderExt := new(adapters.ExtImpBidder) 189 if err := json.Unmarshal(request.Imp[i].Ext, bidderExt); err != nil { 190 continue 191 } 192 193 yieldlabExt := new(openrtb_ext.ExtImpYieldlab) 194 if err := json.Unmarshal(bidderExt.Bidder, yieldlabExt); err != nil { 195 continue 196 } 197 198 params = append(params, yieldlabExt) 199 } 200 201 return params 202 } 203 204 func (a *YieldlabAdapter) mergeParams(params []*openrtb_ext.ExtImpYieldlab) *openrtb_ext.ExtImpYieldlab { 205 var adSlotIds []string 206 targeting := make(map[string]string) 207 208 for _, p := range params { 209 adSlotIds = append(adSlotIds, p.AdslotID) 210 for k, v := range p.Targeting { 211 targeting[k] = v 212 } 213 } 214 215 return &openrtb_ext.ExtImpYieldlab{ 216 AdslotID: strings.Join(adSlotIds, adSlotIdSeparator), 217 Targeting: targeting, 218 } 219 } 220 221 // MakeBids make the bids for the bid response. 222 func (a *YieldlabAdapter) MakeBids(internalRequest *openrtb2.BidRequest, externalRequest *adapters.RequestData, response *adapters.ResponseData) (*adapters.BidderResponse, []error) { 223 if response.StatusCode != 200 { 224 return nil, []error{ 225 &errortypes.BadServerResponse{ 226 Message: fmt.Sprintf("failed to resolve bids from yieldlab response: Unexpected response code %v", response.StatusCode), 227 }, 228 } 229 } 230 231 bids := make([]*bidResponse, 0) 232 if err := json.Unmarshal(response.Body, &bids); err != nil { 233 return nil, []error{ 234 &errortypes.BadServerResponse{ 235 Message: fmt.Sprintf("failed to parse bids response from yieldlab: %v", err), 236 }, 237 } 238 } 239 240 params := a.parseRequest(internalRequest) 241 242 bidderResponse := &adapters.BidderResponse{ 243 Currency: currency.EUR.String(), 244 Bids: []*adapters.TypedBid{}, 245 } 246 247 adslotToImpMap := make(map[string]*openrtb2.Imp) 248 for i := 0; i < len(internalRequest.Imp); i++ { 249 adslotID := a.extractAdslotID(internalRequest.Imp[i]) 250 if internalRequest.Imp[i].Video != nil || internalRequest.Imp[i].Banner != nil { 251 adslotToImpMap[adslotID] = &internalRequest.Imp[i] 252 } 253 } 254 255 for _, bid := range bids { 256 width, height, err := splitSize(bid.Adsize) 257 if err != nil { 258 return nil, []error{err} 259 } 260 261 req := a.findBidReq(bid.ID, params) 262 if req == nil { 263 return nil, []error{ 264 fmt.Errorf("failed to find yieldlab request for adslotID %v. This is most likely a programming issue", bid.ID), 265 } 266 } 267 268 if imp, exists := adslotToImpMap[strconv.FormatUint(bid.ID, 10)]; !exists { 269 continue 270 } else { 271 var bidType openrtb_ext.BidType 272 responseBid := &openrtb2.Bid{ 273 ID: strconv.FormatUint(bid.ID, 10), 274 Price: float64(bid.Price) / 100, 275 ImpID: imp.ID, 276 CrID: a.makeCreativeID(req, bid), 277 DealID: strconv.FormatUint(bid.Pid, 10), 278 W: int64(width), 279 H: int64(height), 280 } 281 282 if imp.Video != nil { 283 bidType = openrtb_ext.BidTypeVideo 284 responseBid.NURL = a.makeAdSourceURL(internalRequest, req, bid) 285 responseBid.AdM = a.makeVast(internalRequest, req, bid) 286 } else if imp.Banner != nil { 287 bidType = openrtb_ext.BidTypeBanner 288 responseBid.AdM = a.makeBannerAdSource(internalRequest, req, bid) 289 } else { 290 // Yieldlab adapter currently doesn't support Audio and Native ads 291 continue 292 } 293 294 bidderResponse.Bids = append(bidderResponse.Bids, &adapters.TypedBid{ 295 BidType: bidType, 296 Bid: responseBid, 297 }) 298 } 299 } 300 301 return bidderResponse, nil 302 } 303 304 func (a *YieldlabAdapter) findBidReq(adslotID uint64, params []*openrtb_ext.ExtImpYieldlab) *openrtb_ext.ExtImpYieldlab { 305 slotIdStr := strconv.FormatUint(adslotID, 10) 306 307 for _, p := range params { 308 if p.AdslotID == slotIdStr { 309 return p 310 } 311 } 312 313 return nil 314 } 315 316 func (a *YieldlabAdapter) extractAdslotID(internalRequestImp openrtb2.Imp) string { 317 bidderExt := new(adapters.ExtImpBidder) 318 json.Unmarshal(internalRequestImp.Ext, bidderExt) 319 yieldlabExt := new(openrtb_ext.ExtImpYieldlab) 320 json.Unmarshal(bidderExt.Bidder, yieldlabExt) 321 return yieldlabExt.AdslotID 322 } 323 324 func (a *YieldlabAdapter) makeBannerAdSource(req *openrtb2.BidRequest, ext *openrtb_ext.ExtImpYieldlab, res *bidResponse) string { 325 return fmt.Sprintf(adSourceBanner, a.makeAdSourceURL(req, ext, res)) 326 } 327 328 func (a *YieldlabAdapter) makeVast(req *openrtb2.BidRequest, ext *openrtb_ext.ExtImpYieldlab, res *bidResponse) string { 329 return fmt.Sprintf(vastMarkup, ext.AdslotID, a.makeAdSourceURL(req, ext, res)) 330 } 331 332 func (a *YieldlabAdapter) makeAdSourceURL(req *openrtb2.BidRequest, ext *openrtb_ext.ExtImpYieldlab, res *bidResponse) string { 333 val := url.Values{} 334 val.Set("ts", a.cacheBuster()) 335 val.Set("id", ext.ExtId) 336 val.Set("pvid", res.Pvid) 337 338 if req.User != nil { 339 val.Set("ids", "ylid:"+req.User.BuyerUID) 340 } 341 342 gdpr, consent, err := a.getGDPR(req) 343 if err == nil && gdpr != "" && consent != "" { 344 val.Set("gdpr", gdpr) 345 val.Set("consent", consent) 346 } 347 348 return fmt.Sprintf(adSourceURL, ext.AdslotID, ext.SupplyID, res.Adsize, val.Encode()) 349 } 350 351 func (a *YieldlabAdapter) makeCreativeID(req *openrtb_ext.ExtImpYieldlab, bid *bidResponse) string { 352 return fmt.Sprintf(creativeID, req.AdslotID, bid.Pid, a.getWeek()) 353 } 354 355 // unmarshalSupplyChain makes the value for the schain URL parameter from the openRTB schain object. 356 func unmarshalSupplyChain(req *openrtb2.BidRequest) *openrtb2.SupplyChain { 357 var extSChain openrtb_ext.ExtRequestPrebidSChain 358 err := json.Unmarshal(req.Source.Ext, &extSChain) 359 if err != nil { 360 // req.Source.Ext could be anything so don't handle any errors 361 return nil 362 } 363 return &extSChain.SChain 364 } 365 366 // makeNodeValue makes the value for the schain URL parameter from the openRTB schain object. 367 func makeSupplyChain(openRtbSchain openrtb2.SupplyChain) string { 368 if len(openRtbSchain.Nodes) == 0 { 369 return "" 370 } 371 372 const schainPrefixFmt = "%s,%d" 373 const schainNodeFmt = "!%s,%s,%s,%s,%s,%s,%s" 374 schainPrefix := fmt.Sprintf(schainPrefixFmt, openRtbSchain.Ver, openRtbSchain.Complete) 375 var sb strings.Builder 376 sb.WriteString(schainPrefix) 377 for _, node := range openRtbSchain.Nodes { 378 // has to be in order: asi,sid,hp,rid,name,domain,ext 379 schainNode := fmt.Sprintf( 380 schainNodeFmt, 381 makeNodeValue(node.ASI), 382 makeNodeValue(node.SID), 383 makeNodeValue(node.HP), 384 makeNodeValue(node.RID), 385 makeNodeValue(node.Name), 386 makeNodeValue(node.Domain), 387 makeNodeValue(node.Ext), 388 ) 389 sb.WriteString(schainNode) 390 } 391 return sb.String() 392 } 393 394 // makeNodeValue converts any known value type from a schain node to a string and does URL encoding if necessary. 395 func makeNodeValue(nodeParam any) string { 396 switch nodeParam.(type) { 397 case string: 398 return url.QueryEscape(nodeParam.(string)) 399 case *int8: 400 pointer := nodeParam.(*int8) 401 if pointer == nil { 402 return "" 403 } 404 return makeNodeValue(int(*pointer)) 405 case int: 406 return strconv.Itoa(nodeParam.(int)) 407 case json.RawMessage: 408 if freeFormData := nodeParam.(json.RawMessage); freeFormData != nil { 409 freeFormJson, err := json.Marshal(freeFormData) 410 if err != nil { 411 return "" 412 } 413 return makeNodeValue(string(freeFormJson)) 414 } 415 return "" 416 default: 417 return "" 418 } 419 } 420 421 func splitSize(size string) (uint64, uint64, error) { 422 sizeParts := strings.Split(size, adsizeSeparator) 423 if len(sizeParts) != 2 { 424 return 0, 0, nil 425 } 426 427 width, err := strconv.ParseUint(sizeParts[0], 10, 64) 428 if err != nil { 429 return 0, 0, fmt.Errorf("failed to parse yieldlab adsize: %v", err) 430 } 431 432 height, err := strconv.ParseUint(sizeParts[1], 10, 64) 433 if err != nil { 434 return 0, 0, fmt.Errorf("failed to parse yieldlab adsize: %v", err) 435 } 436 437 return width, height, nil 438 439 } 440 441 // impIsTypeBannerOnly returns true if impression is only from type banner. Mixed typed with banner would also result in false. 442 func impIsTypeBannerOnly(impression openrtb2.Imp) bool { 443 return impression.Banner != nil && impression.Audio == nil && impression.Video == nil && impression.Native == nil 444 }