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  }