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