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  }