Skip to content

Commit

Permalink
Accommodate Apple iOS LMT bug (#1718)
Browse files Browse the repository at this point in the history
  • Loading branch information
SyntaxNode authored Feb 25, 2021
1 parent bc808a4 commit b74d7c0
Show file tree
Hide file tree
Showing 9 changed files with 785 additions and 5 deletions.
3 changes: 3 additions & 0 deletions endpoints/openrtb2/auction.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/prebid/prebid-server/openrtb_ext"
"github.com/prebid/prebid-server/prebid_cache_client"
"github.com/prebid/prebid-server/privacy/ccpa"
"github.com/prebid/prebid-server/privacy/lmt"
"github.com/prebid/prebid-server/stored_requests"
"github.com/prebid/prebid-server/stored_requests/backends/empty_fetcher"
"github.com/prebid/prebid-server/usersync"
Expand Down Expand Up @@ -264,6 +265,8 @@ func (deps *endpointDeps) parseRequest(httpRequest *http.Request) (req *openrtb.
return
}

lmt.ModifyForIOS(req)

errL := deps.validateRequest(req)
if len(errL) > 0 {
errs = append(errs, errL...)
Expand Down
29 changes: 29 additions & 0 deletions endpoints/openrtb2/auction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,7 @@ func TestImplicitDNTEndToEnd(t *testing.T) {
assert.Equal(t, test.expectedDNT, result.Device.DNT, test.description+":dnt")
}
}

func TestImplicitSecure(t *testing.T) {
httpReq := httptest.NewRequest("POST", "/openrtb2/auction", strings.NewReader(validRequest(t, "site.json")))
httpReq.Header.Set(http.CanonicalHeaderKey("X-Forwarded-Proto"), "https")
Expand Down Expand Up @@ -2075,6 +2076,34 @@ func TestValidateBidders(t *testing.T) {
}
}

func TestIOS14EndToEnd(t *testing.T) {
exchange := &nobidExchange{}

endpoint, _ := NewEndpoint(
exchange,
newParamsValidator(t),
&mockStoredReqFetcher{},
empty_fetcher.EmptyFetcher{},
&config.Configuration{MaxRequestSize: maxSize},
newTestMetrics(),
analyticsConf.NewPBSAnalytics(&config.Analytics{}),
map[string]string{},
[]byte{},
openrtb_ext.BuildBidderMap())

httpReq := httptest.NewRequest("POST", "/openrtb2/auction", strings.NewReader(validRequest(t, "app-ios140-no-ifa.json")))

endpoint(httptest.NewRecorder(), httpReq, nil)

result := exchange.gotRequest
if !assert.NotEmpty(t, result, "request received by the exchange.") {
t.FailNow()
}

var lmtOne int8 = 1
assert.Equal(t, &lmtOne, result.Device.Lmt)
}

// nobidExchange is a well-behaved exchange which always bids "no bid".
type nobidExchange struct {
gotRequest *openrtb.BidRequest
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"description": "Well Formed iOS 14.0 App Request With Unspecified LMT + Empty IFA",
"mockBidRequest": {
"id": "some-request-id",
"app": {
"id": "some-app-id"
},
"device": {
"os": "iOS",
"osv": "14.0",
"ifa": ""
},
"imp": [{
"id": "my-imp-id",
"banner": {
"format": [{
"w": 300,
"h": 600
}]
},
"ext": {
"appnexus": {
"placementId": 12883451
}
}
}]
},
"expectedBidResponse": {
"id": "some-request-id",
"bidid": "test bid id",
"nbr": 0
},
"expectedReturnCode": 200
}
71 changes: 70 additions & 1 deletion openrtb_ext/device.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package openrtb_ext

import (
"encoding/json"
"errors"
"strconv"

"github.com/buger/jsonparser"
Expand All @@ -12,14 +14,58 @@ const PrebidExtKey = "prebid"

// ExtDevice defines the contract for bidrequest.device.ext
type ExtDevice struct {
// Attribute:
// atts
// Type:
// integer; optional - iOS Only
// Description:
// iOS app tracking authorization status.
// Extension Spec:
// https://github.com/InteractiveAdvertisingBureau/openrtb/blob/master/extensions/community_extensions/skadnetwork.md
ATTS *IOSAppTrackingStatus `json:"atts"`

// Attribute:
// prebid
// Type:
// object; optional
// Description:
// Prebid extensions for the Device object.
Prebid ExtDevicePrebid `json:"prebid"`
}

// Pointer to interstitial so we do not force it to exist
// IOSAppTrackingStatus describes the values for iOS app tracking authorization status.
type IOSAppTrackingStatus int

// Values of the IOSAppTrackingStatus enumeration.
const (
IOSAppTrackingStatusNotDetermined IOSAppTrackingStatus = 0
IOSAppTrackingStatusRestricted IOSAppTrackingStatus = 1
IOSAppTrackingStatusDenied IOSAppTrackingStatus = 2
IOSAppTrackingStatusAuthorized IOSAppTrackingStatus = 3
)

// IsKnownIOSAppTrackingStatus returns true if the value is a known iOS app tracking authorization status.
func IsKnownIOSAppTrackingStatus(v int64) bool {
switch IOSAppTrackingStatus(v) {
case IOSAppTrackingStatusNotDetermined:
return true
case IOSAppTrackingStatusRestricted:
return true
case IOSAppTrackingStatusDenied:
return true
case IOSAppTrackingStatusAuthorized:
return true
default:
return false
}
}

// ExtDevicePrebid defines the contract for bidrequest.device.ext.prebid
type ExtDevicePrebid struct {
Interstitial *ExtDeviceInt `json:"interstitial"`
}

// ExtDeviceInt defines the contract for bidrequest.device.ext.prebid.interstitial
type ExtDeviceInt struct {
MinWidthPerc uint64 `json:"minwidtheperc"`
MinHeightPerc uint64 `json:"minheightperc"`
Expand Down Expand Up @@ -49,3 +95,26 @@ func (edi *ExtDeviceInt) UnmarshalJSON(b []byte) error {
}
return nil
}

// ParseDeviceExtATTS parses the ATTS value from the request.device.ext OpenRTB field.
func ParseDeviceExtATTS(deviceExt json.RawMessage) (*IOSAppTrackingStatus, error) {
v, err := jsonparser.GetInt(deviceExt, "atts")

// node not found error
if err == jsonparser.KeyPathNotFoundError {
return nil, nil
}

// unexpected parse error
if err != nil {
return nil, err
}

// invalid value error
if !IsKnownIOSAppTrackingStatus(v) {
return nil, errors.New("invalid status")
}

status := IOSAppTrackingStatus(v)
return &status, nil
}
83 changes: 79 additions & 4 deletions openrtb_ext/device_test.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
package openrtb_ext_test
package openrtb_ext

import (
"encoding/json"
"testing"

"github.com/prebid/prebid-server/openrtb_ext"
"github.com/stretchr/testify/assert"
)

func TestInvalidDeviceExt(t *testing.T) {
var s openrtb_ext.ExtDevice
var s ExtDevice
assert.EqualError(t, json.Unmarshal([]byte(`{"prebid":{"interstitial":{"minheightperc":0}}}`), &s), "request.device.ext.prebid.interstitial.minwidthperc must be a number between 0 and 100")
assert.EqualError(t, json.Unmarshal([]byte(`{"prebid":{"interstitial":{"minwidthperc":105}}}`), &s), "request.device.ext.prebid.interstitial.minwidthperc must be a number between 0 and 100")
assert.EqualError(t, json.Unmarshal([]byte(`{"prebid":{"interstitial":{"minwidthperc":true,"minheightperc":0}}}`), &s), "request.device.ext.prebid.interstitial.minwidthperc must be a number between 0 and 100")
Expand All @@ -23,7 +22,7 @@ func TestInvalidDeviceExt(t *testing.T) {
}

func TestValidDeviceExt(t *testing.T) {
var s openrtb_ext.ExtDevice
var s ExtDevice
assert.NoError(t, json.Unmarshal([]byte(`{"prebid":{}}`), &s))
assert.Nil(t, s.Prebid.Interstitial)
assert.NoError(t, json.Unmarshal([]byte(`{}`), &s))
Expand All @@ -32,3 +31,79 @@ func TestValidDeviceExt(t *testing.T) {
assert.EqualValues(t, 75, s.Prebid.Interstitial.MinWidthPerc)
assert.EqualValues(t, 60, s.Prebid.Interstitial.MinHeightPerc)
}

func TestIsKnownIOSAppTrackingStatus(t *testing.T) {
valid := []int64{0, 1, 2, 3}
invalid := []int64{-1, 4}

for _, v := range valid {
assert.True(t, IsKnownIOSAppTrackingStatus(v))
}

for _, v := range invalid {
assert.False(t, IsKnownIOSAppTrackingStatus(v))
}
}

func TestParseDeviceExtATTS(t *testing.T) {
authorized := IOSAppTrackingStatusAuthorized

tests := []struct {
description string
givenExt json.RawMessage
expectedStatus *IOSAppTrackingStatus
expectedError string
}{
{
description: "Nil",
givenExt: nil,
expectedStatus: nil,
},
{
description: "Empty",
givenExt: json.RawMessage(``),
expectedStatus: nil,
},
{
description: "Empty Object",
givenExt: json.RawMessage(`{}`),
expectedStatus: nil,
},
{
description: "Valid",
givenExt: json.RawMessage(`{"atts":3}`),
expectedStatus: &authorized,
},
{
description: "Invalid Value",
givenExt: json.RawMessage(`{"atts":5}`),
expectedStatus: nil,
expectedError: "invalid status",
},
{
// This test case produces an error with the standard Go library, but jsonparser doesn't
// return an error for malformed JSON. It treats this case the same as not being found.
description: "Malformed - Standard Test Case",
givenExt: json.RawMessage(`malformed`),
expectedStatus: nil,
},
{
description: "Malformed - Wrong Type",
givenExt: json.RawMessage(`{"atts":"1"}`),
expectedStatus: nil,
expectedError: "Value is not a number: 1",
},
}

for _, test := range tests {
status, err := ParseDeviceExtATTS(test.givenExt)

if test.expectedError == "" {
assert.NoError(t, err, test.description+":err")
} else {
assert.EqualError(t, err, test.expectedError, test.description+":err")
}

assert.Equal(t, test.expectedStatus, status, test.description+":status")
}
}
67 changes: 67 additions & 0 deletions privacy/lmt/ios.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package lmt

import (
"strings"

"github.com/mxmCherry/openrtb"
"github.com/prebid/prebid-server/openrtb_ext"
"github.com/prebid/prebid-server/util/iosutil"
)

var (
int8Zero int8 = 0
int8One int8 = 1
)

// ModifyForIOS modifies the request's LMT flag based on iOS version and identity.
func ModifyForIOS(req *openrtb.BidRequest) {
modifiers := map[iosutil.VersionClassification]modifier{
iosutil.Version140: modifyForIOS14X,
iosutil.Version141: modifyForIOS14X,
iosutil.Version142OrGreater: modifyForIOS142OrGreater,
}
modifyForIOS(req, modifiers)
}

func modifyForIOS(req *openrtb.BidRequest, modifiers map[iosutil.VersionClassification]modifier) {
if !isRequestForIOS(req) {
return
}

versionClassification := iosutil.DetectVersionClassification(req.Device.OSV)
if modifier, ok := modifiers[versionClassification]; ok {
modifier(req)
}
}

func isRequestForIOS(req *openrtb.BidRequest) bool {
return req != nil && req.App != nil && req.Device != nil && strings.EqualFold(req.Device.OS, "ios")
}

type modifier func(req *openrtb.BidRequest)

func modifyForIOS14X(req *openrtb.BidRequest) {
if req.Device.IFA == "" || req.Device.IFA == "00000000-0000-0000-0000-000000000000" {
req.Device.Lmt = &int8One
} else {
req.Device.Lmt = &int8Zero
}
}

func modifyForIOS142OrGreater(req *openrtb.BidRequest) {
atts, err := openrtb_ext.ParseDeviceExtATTS(req.Device.Ext)
if err != nil || atts == nil {
return
}

switch *atts {
case openrtb_ext.IOSAppTrackingStatusNotDetermined:
req.Device.Lmt = &int8Zero
case openrtb_ext.IOSAppTrackingStatusRestricted:
req.Device.Lmt = &int8One
case openrtb_ext.IOSAppTrackingStatusDenied:
req.Device.Lmt = &int8One
case openrtb_ext.IOSAppTrackingStatusAuthorized:
req.Device.Lmt = &int8Zero
}
}
Loading

0 comments on commit b74d7c0

Please sign in to comment.