github.com/prebid/prebid-server/v2@v2.18.0/floors/rule.go (about)

     1  package floors
     2  
     3  import (
     4  	"fmt"
     5  	"math/bits"
     6  	"regexp"
     7  	"sort"
     8  	"strings"
     9  
    10  	"github.com/golang/glog"
    11  	"github.com/prebid/openrtb/v20/openrtb2"
    12  	"github.com/prebid/prebid-server/v2/currency"
    13  	"github.com/prebid/prebid-server/v2/openrtb_ext"
    14  	"github.com/prebid/prebid-server/v2/util/ptrutil"
    15  )
    16  
    17  const (
    18  	SiteDomain          string = "siteDomain"
    19  	PubDomain           string = "pubDomain"
    20  	Domain              string = "domain"
    21  	Bundle              string = "bundle"
    22  	Channel             string = "channel"
    23  	MediaType           string = "mediaType"
    24  	Size                string = "size"
    25  	GptSlot             string = "gptSlot"
    26  	AdUnitCode          string = "adUnitCode"
    27  	Country             string = "country"
    28  	DeviceType          string = "deviceType"
    29  	Tablet              string = "tablet"
    30  	Desktop             string = "desktop"
    31  	Phone               string = "phone"
    32  	BannerMedia         string = "banner"
    33  	VideoMedia          string = "video"
    34  	VideoOutstreamMedia string = "video-outstream"
    35  	AudioMedia          string = "audio"
    36  	NativeMedia         string = "native"
    37  )
    38  
    39  // getFloorCurrency returns floors currency provided in floors JSON,
    40  // if currency is not provided then defaults to USD
    41  func getFloorCurrency(floorExt *openrtb_ext.PriceFloorRules) string {
    42  	floorCur := defaultCurrency
    43  	if floorExt != nil && floorExt.Data != nil {
    44  		if floorExt.Data.Currency != "" {
    45  			floorCur = floorExt.Data.Currency
    46  		}
    47  
    48  		if len(floorExt.Data.ModelGroups) > 0 && floorExt.Data.ModelGroups[0].Currency != "" {
    49  			floorCur = floorExt.Data.ModelGroups[0].Currency
    50  		}
    51  	}
    52  
    53  	return floorCur
    54  }
    55  
    56  // getMinFloorValue returns floorMin and floorMinCur,
    57  // values provided in impression extension are considered over floors JSON.
    58  func getMinFloorValue(floorExt *openrtb_ext.PriceFloorRules, imp *openrtb_ext.ImpWrapper, conversions currency.Conversions) (float64, string, error) {
    59  	var err error
    60  	var rate float64
    61  	var floorCur string
    62  	floorMin := roundToFourDecimals(floorExt.FloorMin)
    63  	floorMinCur := floorExt.FloorMinCur
    64  
    65  	impFloorMin, impFloorCur, err := getFloorMinAndCurFromImp(imp)
    66  	if err == nil {
    67  		if impFloorMin > 0.0 {
    68  			floorMin = impFloorMin
    69  		}
    70  		if impFloorCur != "" {
    71  			floorMinCur = impFloorCur
    72  		}
    73  
    74  		floorCur = getFloorCurrency(floorExt)
    75  		if floorMin > 0.0 && floorMinCur != "" {
    76  			if floorExt.FloorMinCur != "" && impFloorCur != "" && floorExt.FloorMinCur != impFloorCur {
    77  				glog.Warning("FloorMinCur are different in floorExt and ImpExt")
    78  			}
    79  			if floorCur != "" && floorMinCur != floorCur {
    80  				rate, err = conversions.GetRate(floorMinCur, floorCur)
    81  				floorMin = rate * floorMin
    82  			}
    83  		}
    84  		floorMin = roundToFourDecimals(floorMin)
    85  	}
    86  	if err != nil {
    87  		return floorMin, floorCur, fmt.Errorf("Error in getting FloorMin value : '%v'", err.Error())
    88  	} else {
    89  		return floorMin, floorCur, err
    90  	}
    91  }
    92  
    93  // getFloorMinAndCurFromImp returns floorMin and floorMinCur from impression extension
    94  func getFloorMinAndCurFromImp(imp *openrtb_ext.ImpWrapper) (float64, string, error) {
    95  	var floorMin float64
    96  	var floorMinCur string
    97  
    98  	impExt, err := imp.GetImpExt()
    99  	if impExt != nil {
   100  		impExtPrebid := impExt.GetPrebid()
   101  		if impExtPrebid != nil && impExtPrebid.Floors != nil {
   102  			if impExtPrebid.Floors.FloorMin > 0.0 {
   103  				floorMin = impExtPrebid.Floors.FloorMin
   104  			}
   105  
   106  			if impExtPrebid.Floors.FloorMinCur != "" {
   107  				floorMinCur = impExtPrebid.Floors.FloorMinCur
   108  			}
   109  		}
   110  	}
   111  	return floorMin, floorMinCur, err
   112  }
   113  
   114  // updateImpExtWithFloorDetails updates floors related details into imp.ext.prebid.floors
   115  func updateImpExtWithFloorDetails(imp *openrtb_ext.ImpWrapper, matchedRule string, floorRuleVal, floorVal float64) error {
   116  	impExt, err := imp.GetImpExt()
   117  	if err != nil {
   118  		return err
   119  	}
   120  	extImpPrebid := impExt.GetPrebid()
   121  	if extImpPrebid == nil {
   122  		extImpPrebid = &openrtb_ext.ExtImpPrebid{}
   123  	}
   124  	extImpPrebid.Floors = &openrtb_ext.ExtImpPrebidFloors{
   125  		FloorRule:      matchedRule,
   126  		FloorRuleValue: floorRuleVal,
   127  		FloorValue:     floorVal,
   128  	}
   129  	impExt.SetPrebid(extImpPrebid)
   130  	return err
   131  }
   132  
   133  // selectFloorModelGroup selects one modelgroup based on modelweight out of multiple modelgroups, if provided into floors JSON.
   134  func selectFloorModelGroup(modelGroups []openrtb_ext.PriceFloorModelGroup, f func(int) int) []openrtb_ext.PriceFloorModelGroup {
   135  	totalModelWeight := 0
   136  	for i := 0; i < len(modelGroups); i++ {
   137  		if modelGroups[i].ModelWeight == nil {
   138  			modelGroups[i].ModelWeight = new(int)
   139  			*modelGroups[i].ModelWeight = 1
   140  		}
   141  		totalModelWeight += *modelGroups[i].ModelWeight
   142  
   143  	}
   144  
   145  	sort.SliceStable(modelGroups, func(i, j int) bool {
   146  		if modelGroups[i].ModelWeight != nil && modelGroups[j].ModelWeight != nil {
   147  			return *modelGroups[i].ModelWeight < *modelGroups[j].ModelWeight
   148  		}
   149  		return false
   150  	})
   151  
   152  	winWeight := f(totalModelWeight + 1)
   153  	for i, modelGroup := range modelGroups {
   154  		winWeight -= *modelGroup.ModelWeight
   155  		if winWeight <= 0 {
   156  			modelGroups[0], modelGroups[i] = modelGroups[i], modelGroups[0]
   157  			return modelGroups[:1]
   158  		}
   159  
   160  	}
   161  	return modelGroups[:1]
   162  }
   163  
   164  // shouldSkipFloors returns flag to decide skipping of floors singalling based on skipRate provided
   165  func shouldSkipFloors(ModelGroupsSkipRate, DataSkipRate, RootSkipRate int, f func(int) int) bool {
   166  	skipRate := 0
   167  
   168  	if ModelGroupsSkipRate > 0 {
   169  		skipRate = ModelGroupsSkipRate
   170  	} else if DataSkipRate > 0 {
   171  		skipRate = DataSkipRate
   172  	} else {
   173  		skipRate = RootSkipRate
   174  	}
   175  
   176  	if skipRate == 0 {
   177  		return false
   178  	}
   179  	return skipRate >= f(skipRateMax+1)
   180  }
   181  
   182  // findRule prepares rule combinations based on schema dimensions provided in floors data, request values associated with these fields and
   183  // does matching with rules provided in floors data and returns matched rule
   184  func findRule(ruleValues map[string]float64, delimiter string, desiredRuleKey []string) (string, bool) {
   185  	ruleKeys := prepareRuleCombinations(desiredRuleKey, delimiter)
   186  	for i := 0; i < len(ruleKeys); i++ {
   187  		if _, ok := ruleValues[ruleKeys[i]]; ok {
   188  			return ruleKeys[i], true
   189  		}
   190  	}
   191  	return "", false
   192  }
   193  
   194  // createRuleKey prepares rule keys based on schema dimension and values present in request
   195  func createRuleKey(floorSchema openrtb_ext.PriceFloorSchema, request *openrtb_ext.RequestWrapper, imp *openrtb_ext.ImpWrapper) []string {
   196  	var ruleKeys []string
   197  
   198  	for _, field := range floorSchema.Fields {
   199  		value := catchAll
   200  		switch field {
   201  		case MediaType:
   202  			value = getMediaType(imp.Imp)
   203  		case Size:
   204  			value = getSizeValue(imp.Imp)
   205  		case Domain:
   206  			value = getDomain(request)
   207  		case SiteDomain:
   208  			value = getSiteDomain(request)
   209  		case Bundle:
   210  			value = getBundle(request)
   211  		case PubDomain:
   212  			value = getPublisherDomain(request)
   213  		case Country:
   214  			value = getDeviceCountry(request)
   215  		case DeviceType:
   216  			value = getDeviceType(request)
   217  		case Channel:
   218  			value = getChannelName(request)
   219  		case GptSlot:
   220  			value = getGptSlot(imp)
   221  		case AdUnitCode:
   222  			value = getAdUnitCode(imp)
   223  		}
   224  		ruleKeys = append(ruleKeys, value)
   225  	}
   226  	return ruleKeys
   227  }
   228  
   229  // getDeviceType returns device type provided into request
   230  func getDeviceType(request *openrtb_ext.RequestWrapper) string {
   231  	value := catchAll
   232  	if request.Device == nil || len(request.Device.UA) == 0 {
   233  		return value
   234  	}
   235  	if isMobileDevice(request.Device.UA) {
   236  		value = Phone
   237  	} else if isTabletDevice(request.Device.UA) {
   238  		value = Tablet
   239  	} else {
   240  		value = Desktop
   241  	}
   242  	return value
   243  }
   244  
   245  // getDeviceCountry returns device country provided into request
   246  func getDeviceCountry(request *openrtb_ext.RequestWrapper) string {
   247  	value := catchAll
   248  	if request.Device != nil && request.Device.Geo != nil {
   249  		value = request.Device.Geo.Country
   250  	}
   251  	return value
   252  }
   253  
   254  // getMediaType returns media type for give impression
   255  func getMediaType(imp *openrtb2.Imp) string {
   256  	value := catchAll
   257  	formatCount := 0
   258  
   259  	if imp.Banner != nil {
   260  		formatCount++
   261  		value = BannerMedia
   262  	}
   263  	if imp.Video != nil && imp.Video.Placement != 1 {
   264  		formatCount++
   265  		value = VideoOutstreamMedia
   266  	}
   267  	if imp.Video != nil && imp.Video.Placement == 1 {
   268  		formatCount++
   269  		value = VideoMedia
   270  	}
   271  	if imp.Audio != nil {
   272  		formatCount++
   273  		value = AudioMedia
   274  	}
   275  	if imp.Native != nil {
   276  		formatCount++
   277  		value = NativeMedia
   278  	}
   279  
   280  	if formatCount > 1 {
   281  		return catchAll
   282  	}
   283  	return value
   284  }
   285  
   286  // getSizeValue returns size for given media type in WxH format
   287  func getSizeValue(imp *openrtb2.Imp) string {
   288  	size := catchAll
   289  	width := int64(0)
   290  	height := int64(0)
   291  
   292  	if imp.Banner != nil {
   293  		width, height = getBannerSize(imp)
   294  	} else if imp.Video != nil {
   295  		width = ptrutil.ValueOrDefault(imp.Video.W)
   296  		height = ptrutil.ValueOrDefault(imp.Video.H)
   297  	}
   298  
   299  	if width != 0 && height != 0 {
   300  		size = fmt.Sprintf("%dx%d", width, height)
   301  	}
   302  	return size
   303  }
   304  
   305  // getBannerSize returns width and height for given banner impression
   306  func getBannerSize(imp *openrtb2.Imp) (int64, int64) {
   307  	width := int64(0)
   308  	height := int64(0)
   309  
   310  	if len(imp.Banner.Format) == 1 {
   311  		return imp.Banner.Format[0].W, imp.Banner.Format[0].H
   312  	} else if len(imp.Banner.Format) > 1 {
   313  		return width, height
   314  	} else if imp.Banner.W != nil && imp.Banner.H != nil {
   315  		width = *imp.Banner.W
   316  		height = *imp.Banner.H
   317  	}
   318  	return width, height
   319  }
   320  
   321  // getDomain returns domain provided into site or app object
   322  func getDomain(request *openrtb_ext.RequestWrapper) string {
   323  	value := catchAll
   324  	if request.Site != nil {
   325  		if len(request.Site.Domain) > 0 {
   326  			value = request.Site.Domain
   327  		} else if request.Site.Publisher != nil && len(request.Site.Publisher.Domain) > 0 {
   328  			value = request.Site.Publisher.Domain
   329  		}
   330  	} else if request.App != nil {
   331  		if len(request.App.Domain) > 0 {
   332  			value = request.App.Domain
   333  		} else if request.App.Publisher != nil && len(request.App.Publisher.Domain) > 0 {
   334  			value = request.App.Publisher.Domain
   335  		}
   336  	}
   337  	return value
   338  }
   339  
   340  // getSiteDomain  returns domain provided into site object
   341  func getSiteDomain(request *openrtb_ext.RequestWrapper) string {
   342  	value := catchAll
   343  	if request.Site != nil && len(request.Site.Domain) > 0 {
   344  		value = request.Site.Domain
   345  	} else if request.App != nil && len(request.App.Domain) > 0 {
   346  		value = request.App.Domain
   347  	}
   348  	return value
   349  }
   350  
   351  // getPublisherDomain returns publisher domain provided into site or app object
   352  func getPublisherDomain(request *openrtb_ext.RequestWrapper) string {
   353  	value := catchAll
   354  	if request.Site != nil && request.Site.Publisher != nil && len(request.Site.Publisher.Domain) > 0 {
   355  		value = request.Site.Publisher.Domain
   356  	} else if request.App != nil && request.App.Publisher != nil && len(request.App.Publisher.Domain) > 0 {
   357  		value = request.App.Publisher.Domain
   358  	}
   359  	return value
   360  }
   361  
   362  // getBundle returns app bundle type
   363  func getBundle(request *openrtb_ext.RequestWrapper) string {
   364  	value := catchAll
   365  	if request.App != nil && len(request.App.Bundle) > 0 {
   366  		value = request.App.Bundle
   367  	}
   368  	return value
   369  }
   370  
   371  // getGptSlot returns gptSlot
   372  func getGptSlot(imp *openrtb_ext.ImpWrapper) string {
   373  	value := catchAll
   374  
   375  	impExt, err := imp.GetImpExt()
   376  	if err == nil {
   377  		extData := impExt.GetData()
   378  		if extData != nil {
   379  			if extData.AdServer != nil && extData.AdServer.Name == "gam" {
   380  				gptSlot := extData.AdServer.AdSlot
   381  				if gptSlot != "" {
   382  					value = gptSlot
   383  				}
   384  			} else if extData.PbAdslot != "" {
   385  				value = extData.PbAdslot
   386  			}
   387  		}
   388  	}
   389  	return value
   390  }
   391  
   392  // getChannelName returns channel name
   393  func getChannelName(bidRequest *openrtb_ext.RequestWrapper) string {
   394  	reqExt, err := bidRequest.GetRequestExt()
   395  	if err == nil && reqExt != nil {
   396  		prebidExt := reqExt.GetPrebid()
   397  		if prebidExt != nil && prebidExt.Channel != nil {
   398  			return prebidExt.Channel.Name
   399  		}
   400  	}
   401  	return catchAll
   402  }
   403  
   404  // getAdUnitCode returns adUnit code
   405  func getAdUnitCode(imp *openrtb_ext.ImpWrapper) string {
   406  	adUnitCode := catchAll
   407  
   408  	impExt, err := imp.GetImpExt()
   409  	if err == nil && impExt != nil && impExt.GetGpId() != "" {
   410  		return impExt.GetGpId()
   411  	}
   412  
   413  	if imp.TagID != "" {
   414  		return imp.TagID
   415  	}
   416  
   417  	if impExt != nil {
   418  		impExtData := impExt.GetData()
   419  		if impExtData != nil && impExtData.PbAdslot != "" {
   420  			return impExtData.PbAdslot
   421  		}
   422  
   423  		prebidExt := impExt.GetPrebid()
   424  		if prebidExt != nil && prebidExt.StoredRequest.ID != "" {
   425  			return prebidExt.StoredRequest.ID
   426  		}
   427  	}
   428  
   429  	return adUnitCode
   430  }
   431  
   432  // isMobileDevice returns true if device is mobile
   433  func isMobileDevice(userAgent string) bool {
   434  	isMobile, err := regexp.MatchString("(?i)Phone|iPhone|Android.*Mobile|Mobile.*Android", userAgent)
   435  	if err != nil {
   436  		return false
   437  	}
   438  	return isMobile
   439  }
   440  
   441  // isTabletDevice returns true if device is tablet
   442  func isTabletDevice(userAgent string) bool {
   443  	isTablet, err := regexp.MatchString("(?i)tablet|iPad|touch.*Windows NT|Windows NT.*touch|Android", userAgent)
   444  	if err != nil {
   445  		return false
   446  	}
   447  	return isTablet
   448  }
   449  
   450  // prepareRuleCombinations prepares rule combinations based on schema dimensions and request fields
   451  func prepareRuleCombinations(keys []string, delimiter string) []string {
   452  	var schemaFields []string
   453  
   454  	numSchemaFields := len(keys)
   455  	ruleKey := newFloorRuleKeys(delimiter)
   456  	for i := 0; i < numSchemaFields; i++ {
   457  		schemaFields = append(schemaFields, strings.ToLower(keys[i]))
   458  	}
   459  	ruleKey.appendRuleKey(schemaFields)
   460  
   461  	for numWildCard := 1; numWildCard <= numSchemaFields; numWildCard++ {
   462  		newComb := generateCombinations(numSchemaFields, numWildCard)
   463  		sortCombinations(newComb, numSchemaFields)
   464  
   465  		for i := 0; i < len(newComb); i++ {
   466  			eachSet := make([]string, numSchemaFields)
   467  			copy(eachSet, schemaFields)
   468  			for j := 0; j < len(newComb[i]); j++ {
   469  				eachSet[newComb[i][j]] = catchAll
   470  			}
   471  			ruleKey.appendRuleKey(eachSet)
   472  		}
   473  	}
   474  	return ruleKey.getAllRuleKeys()
   475  }
   476  
   477  // generateCombinations generates every permutation for the given number of fields with the specified number of
   478  // wildcards. Permutations are returned as a list of integer lists where each integer list represents a single
   479  // permutation with each integer indicating the position of the fields that are wildcards
   480  // source: https://docs.prebid.org/dev-docs/modules/floors.html#rule-selection-process
   481  func generateCombinations(numSchemaFields int, numWildCard int) (comb [][]int) {
   482  
   483  	for subsetBits := 1; subsetBits < (1 << numSchemaFields); subsetBits++ {
   484  		if bits.OnesCount(uint(subsetBits)) != numWildCard {
   485  			continue
   486  		}
   487  		var subset []int
   488  		for object := 0; object < numSchemaFields; object++ {
   489  			if (subsetBits>>object)&1 == 1 {
   490  				subset = append(subset, object)
   491  			}
   492  		}
   493  		comb = append(comb, subset)
   494  	}
   495  	return comb
   496  }
   497  
   498  // sortCombinations sorts the list of combinations from most specific to least specific. A combination is considered more specific than
   499  // another combination if it has more exact values (less wildcards). If two combinations have the same number of wildcards, a combination
   500  // is considered more specific than another if its left-most fields are more exact.
   501  func sortCombinations(comb [][]int, numSchemaFields int) {
   502  	totalComb := 1 << numSchemaFields
   503  
   504  	sort.SliceStable(comb, func(i, j int) bool {
   505  		wt1 := 0
   506  		for k := 0; k < len(comb[i]); k++ {
   507  			wt1 += 1 << (totalComb - comb[i][k])
   508  		}
   509  
   510  		wt2 := 0
   511  		for k := 0; k < len(comb[j]); k++ {
   512  			wt2 += 1 << (totalComb - comb[j][k])
   513  		}
   514  		return wt1 < wt2
   515  	})
   516  }
   517  
   518  // ruleKeys defines struct used for maintaining rule combinations generated from schema fields and reqeust values.
   519  type ruleKeys struct {
   520  	keyMap    map[string]bool
   521  	keys      []string
   522  	delimiter string
   523  }
   524  
   525  // newFloorRuleKeys allocates and initialise ruleKeys
   526  func newFloorRuleKeys(delimiter string) *ruleKeys {
   527  	rulekey := new(ruleKeys)
   528  	rulekey.delimiter = delimiter
   529  	rulekey.keyMap = map[string]bool{}
   530  	return rulekey
   531  }
   532  
   533  // appendRuleKey appends unique rules keys into ruleKeys array
   534  func (r *ruleKeys) appendRuleKey(rawKey []string) {
   535  	var key string
   536  	key = rawKey[0]
   537  	for j := 1; j < len(rawKey); j++ {
   538  		key += r.delimiter + rawKey[j]
   539  	}
   540  
   541  	if _, found := r.keyMap[key]; !found {
   542  		r.keyMap[key] = true
   543  		r.keys = append(r.keys, key)
   544  	}
   545  }
   546  
   547  // getAllRuleKeys returns all the rules prepared
   548  func (r *ruleKeys) getAllRuleKeys() []string {
   549  	return r.keys
   550  }