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