diff --git a/firstpartydata/first_party_data.go b/firstpartydata/first_party_data.go index 5234a693fc7..019730f23ef 100644 --- a/firstpartydata/first_party_data.go +++ b/firstpartydata/first_party_data.go @@ -4,18 +4,20 @@ import ( "encoding/json" "errors" "fmt" + "strings" "github.com/prebid/openrtb/v20/openrtb2" jsonpatch "gopkg.in/evanphx/json-patch.v4" "github.com/prebid/prebid-server/v2/errortypes" "github.com/prebid/prebid-server/v2/openrtb_ext" - "github.com/prebid/prebid-server/v2/ortb/merge" + "github.com/prebid/prebid-server/v2/util/jsonutil" "github.com/prebid/prebid-server/v2/util/ptrutil" ) var ( - ErrBadFPD = errors.New("invalid first party data ext") + ErrBadRequest = errors.New("invalid request ext") + ErrBadFPD = errors.New("invalid first party data ext") ) const ( @@ -189,7 +191,7 @@ func resolveUser(fpdConfig *openrtb_ext.ORTB2, bidRequestUser *openrtb2.User, gl var err error newUser.Ext, err = jsonpatch.MergePatch(newUser.Ext, extData) if err != nil { - return nil, err + return nil, formatMergePatchError(err) } } else { newUser.Ext = extData @@ -199,12 +201,8 @@ func resolveUser(fpdConfig *openrtb_ext.ORTB2, bidRequestUser *openrtb2.User, gl newUser.Data = openRtbGlobalFPD[userDataKey] } if fpdConfigUser != nil { - var err error - if newUser, err = merge.User(newUser, fpdConfigUser); err != nil { - if err == merge.ErrBadOverride { - return nil, ErrBadFPD - } - return nil, err + if err := jsonutil.MergeClone(newUser, fpdConfigUser); err != nil { + return nil, formatMergeCloneError(err) } } @@ -241,7 +239,7 @@ func resolveSite(fpdConfig *openrtb_ext.ORTB2, bidRequestSite *openrtb2.Site, gl var err error newSite.Ext, err = jsonpatch.MergePatch(newSite.Ext, extData) if err != nil { - return nil, err + return nil, formatMergePatchError(err) } } else { newSite.Ext = extData @@ -258,12 +256,8 @@ func resolveSite(fpdConfig *openrtb_ext.ORTB2, bidRequestSite *openrtb2.Site, gl newSite.Content.Data = openRtbGlobalFPD[siteContentDataKey] } if fpdConfigSite != nil { - var err error - if newSite, err = merge.Site(newSite, fpdConfigSite, bidderName); err != nil { - if err == merge.ErrBadOverride { - return nil, ErrBadFPD - } - return nil, err + if err := jsonutil.MergeClone(newSite, fpdConfigSite); err != nil { + return nil, formatMergeCloneError(err) } // Re-Validate Site @@ -276,6 +270,25 @@ func resolveSite(fpdConfig *openrtb_ext.ORTB2, bidRequestSite *openrtb2.Site, gl return newSite, nil } +func formatMergePatchError(err error) error { + if errors.Is(err, jsonpatch.ErrBadJSONDoc) { + return ErrBadRequest + } + + if errors.Is(err, jsonpatch.ErrBadJSONPatch) { + return ErrBadFPD + } + + return err +} + +func formatMergeCloneError(err error) error { + if strings.Contains(err.Error(), "invalid json on existing object") { + return ErrBadRequest + } + return ErrBadFPD +} + func resolveApp(fpdConfig *openrtb_ext.ORTB2, bidRequestApp *openrtb2.App, globalFPD map[string][]byte, openRtbGlobalFPD map[string][]openrtb2.Data, bidderName string) (*openrtb2.App, error) { var fpdConfigApp json.RawMessage @@ -307,7 +320,7 @@ func resolveApp(fpdConfig *openrtb_ext.ORTB2, bidRequestApp *openrtb2.App, globa var err error newApp.Ext, err = jsonpatch.MergePatch(newApp.Ext, extData) if err != nil { - return nil, err + return nil, formatMergePatchError(err) } } else { newApp.Ext = extData @@ -326,12 +339,8 @@ func resolveApp(fpdConfig *openrtb_ext.ORTB2, bidRequestApp *openrtb2.App, globa } if fpdConfigApp != nil { - var err error - if newApp, err = merge.App(newApp, fpdConfigApp); err != nil { - if err == merge.ErrBadOverride { - return nil, ErrBadFPD - } - return nil, err + if err := jsonutil.MergeClone(newApp, fpdConfigApp); err != nil { + return nil, formatMergeCloneError(err) } } diff --git a/firstpartydata/first_party_data_test.go b/firstpartydata/first_party_data_test.go index 7bc52b5527f..44c9405f1bc 100644 --- a/firstpartydata/first_party_data_test.go +++ b/firstpartydata/first_party_data_test.go @@ -32,17 +32,17 @@ func TestExtractGlobalFPD(t *testing.T) { Publisher: &openrtb2.Publisher{ ID: "1", }, - Ext: json.RawMessage(`{"data": {"somesitefpd": "sitefpdDataTest"}}`), + Ext: json.RawMessage(`{"data":{"somesitefpd":"sitefpdDataTest"}}`), }, User: &openrtb2.User{ ID: "reqUserID", Yob: 1982, Gender: "M", - Ext: json.RawMessage(`{"data": {"someuserfpd": "userfpdDataTest"}}`), + Ext: json.RawMessage(`{"data":{"someuserfpd":"userfpdDataTest"}}`), }, App: &openrtb2.App{ ID: "appId", - Ext: json.RawMessage(`{"data": {"someappfpd": "appfpdDataTest"}}`), + Ext: json.RawMessage(`{"data":{"someappfpd":"appfpdDataTest"}}`), }, }, }, @@ -65,9 +65,9 @@ func TestExtractGlobalFPD(t *testing.T) { }, }}, expectedFpd: map[string][]byte{ - "site": []byte(`{"somesitefpd": "sitefpdDataTest"}`), - "user": []byte(`{"someuserfpd": "userfpdDataTest"}`), - "app": []byte(`{"someappfpd": "appfpdDataTest"}`), + "site": []byte(`{"somesitefpd":"sitefpdDataTest"}`), + "user": []byte(`{"someuserfpd":"userfpdDataTest"}`), + "app": []byte(`{"someappfpd":"appfpdDataTest"}`), }, }, { @@ -84,7 +84,7 @@ func TestExtractGlobalFPD(t *testing.T) { }, App: &openrtb2.App{ ID: "appId", - Ext: json.RawMessage(`{"data": {"someappfpd": "appfpdDataTest"}}`), + Ext: json.RawMessage(`{"data":{"someappfpd":"appfpdDataTest"}}`), }, }, }, @@ -104,7 +104,7 @@ func TestExtractGlobalFPD(t *testing.T) { }, }, expectedFpd: map[string][]byte{ - "app": []byte(`{"someappfpd": "appfpdDataTest"}`), + "app": []byte(`{"someappfpd":"appfpdDataTest"}`), "user": nil, "site": nil, }, @@ -125,7 +125,7 @@ func TestExtractGlobalFPD(t *testing.T) { ID: "reqUserID", Yob: 1982, Gender: "M", - Ext: json.RawMessage(`{"data": {"someuserfpd": "userfpdDataTest"}}`), + Ext: json.RawMessage(`{"data":{"someuserfpd":"userfpdDataTest"}}`), }, }, }, @@ -148,7 +148,7 @@ func TestExtractGlobalFPD(t *testing.T) { }, expectedFpd: map[string][]byte{ "app": nil, - "user": []byte(`{"someuserfpd": "userfpdDataTest"}`), + "user": []byte(`{"someuserfpd":"userfpdDataTest"}`), "site": nil, }, }, @@ -211,7 +211,7 @@ func TestExtractGlobalFPD(t *testing.T) { Publisher: &openrtb2.Publisher{ ID: "1", }, - Ext: json.RawMessage(`{"data": {"someappfpd": true}}`), + Ext: json.RawMessage(`{"data":{"someappfpd":true}}`), }, App: &openrtb2.App{ ID: "appId", @@ -236,7 +236,7 @@ func TestExtractGlobalFPD(t *testing.T) { expectedFpd: map[string][]byte{ "app": nil, "user": nil, - "site": []byte(`{"someappfpd": true}`), + "site": []byte(`{"someappfpd":true}`), }, }, } @@ -727,7 +727,7 @@ func TestResolveUser(t *testing.T) { globalFPD map[string][]byte openRtbGlobalFPD map[string][]openrtb2.Data expectedUser *openrtb2.User - expectError bool + expectError string }{ { description: "FPD config and bid request user are not specified", @@ -735,42 +735,42 @@ func TestResolveUser(t *testing.T) { }, { description: "FPD config user only is specified", - fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test"}`)}, + fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id":"test"}`)}, expectedUser: &openrtb2.User{ID: "test"}, }, { description: "FPD config and bid request user are specified", - fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1"}`)}, + fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id":"test1"}`)}, bidRequestUser: &openrtb2.User{ID: "test2"}, expectedUser: &openrtb2.User{ID: "test1"}, }, { description: "FPD config, bid request and global fpd user are specified, no input user ext", - fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1"}`)}, + fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id":"test1"}`)}, bidRequestUser: &openrtb2.User{ID: "test2"}, - globalFPD: map[string][]byte{userKey: []byte(`{"globalFPDUserData": "globalFPDUserDataValue"}`)}, + globalFPD: map[string][]byte{userKey: []byte(`{"globalFPDUserData":"globalFPDUserDataValue"}`)}, expectedUser: &openrtb2.User{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDUserData":"globalFPDUserDataValue"}}`)}, }, { description: "FPD config, bid request user with ext and global fpd user are specified, no input user ext", - fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1"}`)}, + fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id":"test1"}`)}, bidRequestUser: &openrtb2.User{ID: "test2", Ext: json.RawMessage(`{"test":{"inputFPDUserData":"inputFPDUserDataValue"}}`)}, - globalFPD: map[string][]byte{userKey: []byte(`{"globalFPDUserData": "globalFPDUserDataValue"}`)}, + globalFPD: map[string][]byte{userKey: []byte(`{"globalFPDUserData":"globalFPDUserDataValue"}`)}, expectedUser: &openrtb2.User{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDUserData":"globalFPDUserDataValue"},"test":{"inputFPDUserData":"inputFPDUserDataValue"}}`)}, }, { description: "FPD config, bid request and global fpd user are specified, with input user ext.data", fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1"}`)}, bidRequestUser: &openrtb2.User{ID: "test2", Ext: json.RawMessage(`{"data":{"inputFPDUserData":"inputFPDUserDataValue"}}`)}, - globalFPD: map[string][]byte{userKey: []byte(`{"globalFPDUserData": "globalFPDUserDataValue"}`)}, + globalFPD: map[string][]byte{userKey: []byte(`{"globalFPDUserData":"globalFPDUserDataValue"}`)}, expectedUser: &openrtb2.User{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDUserData":"globalFPDUserDataValue","inputFPDUserData":"inputFPDUserDataValue"}}`)}, }, { description: "FPD config, bid request and global fpd user are specified, with input user ext.data malformed", - fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1"}`)}, + fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id":"test1"}`)}, bidRequestUser: &openrtb2.User{ID: "test2", Ext: json.RawMessage(`{"data":{"inputFPDUserData":"inputFPDUserDataValue"}}`)}, globalFPD: map[string][]byte{userKey: []byte(`malformed`)}, - expectError: true, + expectError: "invalid first party data ext", }, { description: "bid request and openrtb global fpd user are specified, no input user ext", @@ -786,7 +786,7 @@ func TestResolveUser(t *testing.T) { }, { description: "fpd config user, bid request and openrtb global fpd user are specified, no input user ext", - fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1"}`)}, + fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id":"test1"}`)}, bidRequestUser: &openrtb2.User{ID: "test2"}, openRtbGlobalFPD: map[string][]openrtb2.Data{userDataKey: { {ID: "DataId1", Name: "Name1"}, @@ -799,7 +799,7 @@ func TestResolveUser(t *testing.T) { }, { description: "fpd config user with ext, bid request and openrtb global fpd user are specified, no input user ext", - fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1", "ext":{"test":1}}`)}, + fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id":"test1","ext":{"test":1}}`)}, bidRequestUser: &openrtb2.User{ID: "test2"}, openRtbGlobalFPD: map[string][]openrtb2.Data{userDataKey: { {ID: "DataId1", Name: "Name1"}, @@ -813,8 +813,8 @@ func TestResolveUser(t *testing.T) { }, { description: "fpd config user with ext, bid requestuser with ext and openrtb global fpd user are specified, no input user ext", - fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1", "ext":{"test":1}}`)}, - bidRequestUser: &openrtb2.User{ID: "test2", Ext: json.RawMessage(`{"test":2, "key": "value"}`)}, + fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id":"test1","ext":{"test":1}}`)}, + bidRequestUser: &openrtb2.User{ID: "test2", Ext: json.RawMessage(`{"test":2,"key":"value"}`)}, openRtbGlobalFPD: map[string][]openrtb2.Data{userDataKey: { {ID: "DataId1", Name: "Name1"}, {ID: "DataId2", Name: "Name2"}, @@ -827,8 +827,8 @@ func TestResolveUser(t *testing.T) { }, { description: "fpd config user with malformed ext, bid requestuser with ext and openrtb global fpd user are specified, no input user ext", - fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1", "ext":{malformed}}`)}, - bidRequestUser: &openrtb2.User{ID: "test2", Ext: json.RawMessage(`{"test":2, "key": "value"}`)}, + fpdConfig: &openrtb_ext.ORTB2{User: json.RawMessage(`{"id": "test1","ext":{malformed}}`)}, + bidRequestUser: &openrtb2.User{ID: "test2", Ext: json.RawMessage(`{"test":2,"key":"value"}`)}, openRtbGlobalFPD: map[string][]openrtb2.Data{userDataKey: { {ID: "DataId1", Name: "Name1"}, {ID: "DataId2", Name: "Name2"}, @@ -839,15 +839,15 @@ func TestResolveUser(t *testing.T) { }, Ext: json.RawMessage(`{"key":"value","test":1}`), }, - expectError: true, + expectError: "invalid first party data ext", }, } for _, test := range testCases { t.Run(test.description, func(t *testing.T) { resultUser, err := resolveUser(test.fpdConfig, test.bidRequestUser, test.globalFPD, test.openRtbGlobalFPD, "bidderA") - if test.expectError { - assert.Error(t, err, "expected error incorrect") + if len(test.expectError) > 0 { + assert.EqualError(t, err, test.expectError) } else { assert.NoError(t, err, "unexpected error returned") assert.Equal(t, test.expectedUser, resultUser, "Result user is incorrect") @@ -864,7 +864,7 @@ func TestResolveSite(t *testing.T) { globalFPD map[string][]byte openRtbGlobalFPD map[string][]openrtb2.Data expectedSite *openrtb2.Site - expectError bool + expectError string }{ { description: "FPD config and bid request site are not specified", @@ -872,42 +872,42 @@ func TestResolveSite(t *testing.T) { }, { description: "FPD config site only is specified", - fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test"}`)}, - expectError: true, + fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id":"test"}`)}, + expectError: "incorrect First Party Data for bidder bidderA: Site object is not defined in request, but defined in FPD config", }, { description: "FPD config and bid request site are specified", - fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1"}`)}, + fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id":"test1"}`)}, bidRequestSite: &openrtb2.Site{ID: "test2"}, expectedSite: &openrtb2.Site{ID: "test1"}, }, { description: "FPD config, bid request and global fpd site are specified, no input site ext", - fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1"}`)}, + fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id":"test1"}`)}, bidRequestSite: &openrtb2.Site{ID: "test2"}, - globalFPD: map[string][]byte{siteKey: []byte(`{"globalFPDSiteData": "globalFPDSiteDataValue"}`)}, + globalFPD: map[string][]byte{siteKey: []byte(`{"globalFPDSiteData":"globalFPDSiteDataValue"}`)}, expectedSite: &openrtb2.Site{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDSiteData":"globalFPDSiteDataValue"}}`)}, }, { description: "FPD config, bid request site with ext and global fpd site are specified, no input site ext", - fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1"}`)}, + fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id":"test1"}`)}, bidRequestSite: &openrtb2.Site{ID: "test2", Ext: json.RawMessage(`{"test":{"inputFPDSiteData":"inputFPDSiteDataValue"}}`)}, - globalFPD: map[string][]byte{siteKey: []byte(`{"globalFPDSiteData": "globalFPDSiteDataValue"}`)}, + globalFPD: map[string][]byte{siteKey: []byte(`{"globalFPDSiteData":"globalFPDSiteDataValue"}`)}, expectedSite: &openrtb2.Site{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDSiteData":"globalFPDSiteDataValue"},"test":{"inputFPDSiteData":"inputFPDSiteDataValue"}}`)}, }, { description: "FPD config, bid request and global fpd site are specified, with input site ext.data", - fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1"}`)}, + fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id":"test1"}`)}, bidRequestSite: &openrtb2.Site{ID: "test2", Ext: json.RawMessage(`{"data":{"inputFPDSiteData":"inputFPDSiteDataValue"}}`)}, - globalFPD: map[string][]byte{siteKey: []byte(`{"globalFPDSiteData": "globalFPDSiteDataValue"}`)}, + globalFPD: map[string][]byte{siteKey: []byte(`{"globalFPDSiteData":"globalFPDSiteDataValue"}`)}, expectedSite: &openrtb2.Site{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDSiteData":"globalFPDSiteDataValue","inputFPDSiteData":"inputFPDSiteDataValue"}}`)}, }, { description: "FPD config, bid request and global fpd site are specified, with input site ext.data malformed", - fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1"}`)}, + fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id":"test1"}`)}, bidRequestSite: &openrtb2.Site{ID: "test2", Ext: json.RawMessage(`{"data":{"inputFPDSiteData":"inputFPDSiteDataValue"}}`)}, globalFPD: map[string][]byte{siteKey: []byte(`malformed`)}, - expectError: true, + expectError: "invalid first party data ext", }, { description: "bid request and openrtb global fpd site are specified, no input site ext", @@ -946,7 +946,7 @@ func TestResolveSite(t *testing.T) { }, { description: "fpd config site, bid request and openrtb global fpd site are specified, no input site ext", - fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1"}`)}, + fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id":"test1"}`)}, bidRequestSite: &openrtb2.Site{ID: "test2"}, openRtbGlobalFPD: map[string][]openrtb2.Data{siteContentDataKey: { {ID: "DataId1", Name: "Name1"}, @@ -959,7 +959,7 @@ func TestResolveSite(t *testing.T) { }, { description: "fpd config site with ext, bid request and openrtb global fpd site are specified, no input site ext", - fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1", "ext":{"test":1}}`)}, + fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id":"test1","ext":{"test":1}}`)}, bidRequestSite: &openrtb2.Site{ID: "test2"}, openRtbGlobalFPD: map[string][]openrtb2.Data{siteContentDataKey: { {ID: "DataId1", Name: "Name1"}, @@ -973,8 +973,8 @@ func TestResolveSite(t *testing.T) { }, { description: "fpd config site with ext, bid request site with ext and openrtb global fpd site are specified, no input site ext", - fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1", "ext":{"test":1}}`)}, - bidRequestSite: &openrtb2.Site{ID: "test2", Ext: json.RawMessage(`{"test":2, "key": "value"}`)}, + fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id":"test1","ext":{"test":1}}`)}, + bidRequestSite: &openrtb2.Site{ID: "test2", Ext: json.RawMessage(`{"test":2,"key":"value"}`)}, openRtbGlobalFPD: map[string][]openrtb2.Data{siteContentDataKey: { {ID: "DataId1", Name: "Name1"}, {ID: "DataId2", Name: "Name2"}, @@ -987,8 +987,8 @@ func TestResolveSite(t *testing.T) { }, { description: "fpd config site with malformed ext, bid request site with ext and openrtb global fpd site are specified, no input site ext", - fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id": "test1", "ext":{malformed}}`)}, - bidRequestSite: &openrtb2.Site{ID: "test2", Ext: json.RawMessage(`{"test":2, "key": "value"}`)}, + fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id":"test1","ext":{malformed}}`)}, + bidRequestSite: &openrtb2.Site{ID: "test2", Ext: json.RawMessage(`{"test":2,"key":"value"}`)}, openRtbGlobalFPD: map[string][]openrtb2.Data{siteContentDataKey: { {ID: "DataId1", Name: "Name1"}, {ID: "DataId2", Name: "Name2"}, @@ -999,17 +999,53 @@ func TestResolveSite(t *testing.T) { }}, Ext: json.RawMessage(`{"key":"value","test":1}`), }, - expectError: true, + expectError: "invalid first party data ext", + }, + { + description: "valid-id", + bidRequestSite: &openrtb2.Site{ID: "1"}, + fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id":"2"}`)}, + expectedSite: &openrtb2.Site{ID: "2"}, + }, + { + description: "valid-page", + bidRequestSite: &openrtb2.Site{Page: "1"}, + fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"page":"2"}`)}, + expectedSite: &openrtb2.Site{Page: "2"}, + }, + { + description: "invalid-id", + bidRequestSite: &openrtb2.Site{ID: "1"}, + fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"id":null}`)}, + expectError: "incorrect First Party Data for bidder bidderA: Site object cannot set empty page if req.site.id is empty", + }, + { + description: "invalid-page", + bidRequestSite: &openrtb2.Site{Page: "1"}, + fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"page":null}`)}, + expectError: "incorrect First Party Data for bidder bidderA: Site object cannot set empty page if req.site.id is empty", + }, + { + description: "existing-err", + bidRequestSite: &openrtb2.Site{ID: "1", Ext: []byte(`malformed`)}, + fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`{"ext":{"a":1}}`)}, + expectError: "invalid request ext", + }, + { + description: "fpd-err", + bidRequestSite: &openrtb2.Site{ID: "1", Ext: []byte(`{"a":1}`)}, + fpdConfig: &openrtb_ext.ORTB2{Site: json.RawMessage(`malformed`)}, + expectError: "invalid first party data ext", }, } for _, test := range testCases { t.Run(test.description, func(t *testing.T) { resultSite, err := resolveSite(test.fpdConfig, test.bidRequestSite, test.globalFPD, test.openRtbGlobalFPD, "bidderA") - if test.expectError { - assert.Error(t, err) + if len(test.expectError) > 0 { + assert.EqualError(t, err, test.expectError) } else { - assert.NoError(t, err, "unexpected error returned") + require.NoError(t, err, "unexpected error returned") assert.Equal(t, test.expectedSite, resultSite, "Result site is incorrect") } }) @@ -1024,7 +1060,7 @@ func TestResolveApp(t *testing.T) { globalFPD map[string][]byte openRtbGlobalFPD map[string][]openrtb2.Data expectedApp *openrtb2.App - expectError bool + expectError string }{ { description: "FPD config and bid request app are not specified", @@ -1032,42 +1068,42 @@ func TestResolveApp(t *testing.T) { }, { description: "FPD config app only is specified", - fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test"}`)}, - expectError: true, + fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id":"test"}`)}, + expectError: "incorrect First Party Data for bidder bidderA: App object is not defined in request, but defined in FPD config", }, { description: "FPD config and bid request app are specified", - fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1"}`)}, + fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id":"test1"}`)}, bidRequestApp: &openrtb2.App{ID: "test2"}, expectedApp: &openrtb2.App{ID: "test1"}, }, { description: "FPD config, bid request and global fpd app are specified, no input app ext", - fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1"}`)}, + fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id":"test1"}`)}, bidRequestApp: &openrtb2.App{ID: "test2"}, - globalFPD: map[string][]byte{appKey: []byte(`{"globalFPDAppData": "globalFPDAppDataValue"}`)}, + globalFPD: map[string][]byte{appKey: []byte(`{"globalFPDAppData":"globalFPDAppDataValue"}`)}, expectedApp: &openrtb2.App{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDAppData":"globalFPDAppDataValue"}}`)}, }, { description: "FPD config, bid request app with ext and global fpd app are specified, no input app ext", - fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1"}`)}, + fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id":"test1"}`)}, bidRequestApp: &openrtb2.App{ID: "test2", Ext: json.RawMessage(`{"test":{"inputFPDAppData":"inputFPDAppDataValue"}}`)}, - globalFPD: map[string][]byte{appKey: []byte(`{"globalFPDAppData": "globalFPDAppDataValue"}`)}, + globalFPD: map[string][]byte{appKey: []byte(`{"globalFPDAppData":"globalFPDAppDataValue"}`)}, expectedApp: &openrtb2.App{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDAppData":"globalFPDAppDataValue"},"test":{"inputFPDAppData":"inputFPDAppDataValue"}}`)}, }, { description: "FPD config, bid request and global fpd app are specified, with input app ext.data", - fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1"}`)}, + fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id":"test1"}`)}, bidRequestApp: &openrtb2.App{ID: "test2", Ext: json.RawMessage(`{"data":{"inputFPDAppData":"inputFPDAppDataValue"}}`)}, - globalFPD: map[string][]byte{appKey: []byte(`{"globalFPDAppData": "globalFPDAppDataValue"}`)}, + globalFPD: map[string][]byte{appKey: []byte(`{"globalFPDAppData":"globalFPDAppDataValue"}`)}, expectedApp: &openrtb2.App{ID: "test1", Ext: json.RawMessage(`{"data":{"globalFPDAppData":"globalFPDAppDataValue","inputFPDAppData":"inputFPDAppDataValue"}}`)}, }, { description: "FPD config, bid request and global fpd app are specified, with input app ext.data malformed", - fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1"}`)}, + fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id":"test1"}`)}, bidRequestApp: &openrtb2.App{ID: "test2", Ext: json.RawMessage(`{"data":{"inputFPDAppData":"inputFPDAppDataValue"}}`)}, globalFPD: map[string][]byte{appKey: []byte(`malformed`)}, - expectError: true, + expectError: "invalid first party data ext", }, { description: "bid request and openrtb global fpd app are specified, no input app ext", @@ -1106,7 +1142,7 @@ func TestResolveApp(t *testing.T) { }, { description: "fpd config app, bid request and openrtb global fpd app are specified, no input app ext", - fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1"}`)}, + fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id":"test1"}`)}, bidRequestApp: &openrtb2.App{ID: "test2"}, openRtbGlobalFPD: map[string][]openrtb2.Data{appContentDataKey: { {ID: "DataId1", Name: "Name1"}, @@ -1119,7 +1155,7 @@ func TestResolveApp(t *testing.T) { }, { description: "fpd config app with ext, bid request and openrtb global fpd app are specified, no input app ext", - fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1", "ext":{"test":1}}`)}, + fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id":"test1","ext":{"test":1}}`)}, bidRequestApp: &openrtb2.App{ID: "test2"}, openRtbGlobalFPD: map[string][]openrtb2.Data{appContentDataKey: { {ID: "DataId1", Name: "Name1"}, @@ -1133,8 +1169,8 @@ func TestResolveApp(t *testing.T) { }, { description: "fpd config app with ext, bid request app with ext and openrtb global fpd app are specified, no input app ext", - fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1", "ext":{"test":1}}`)}, - bidRequestApp: &openrtb2.App{ID: "test2", Ext: json.RawMessage(`{"test":2, "key": "value"}`)}, + fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id":"test1","ext":{"test":1}}`)}, + bidRequestApp: &openrtb2.App{ID: "test2", Ext: json.RawMessage(`{"test":2,"key":"value"}`)}, openRtbGlobalFPD: map[string][]openrtb2.Data{appContentDataKey: { {ID: "DataId1", Name: "Name1"}, {ID: "DataId2", Name: "Name2"}, @@ -1147,8 +1183,8 @@ func TestResolveApp(t *testing.T) { }, { description: "fpd config app with malformed ext, bid request app with ext and openrtb global fpd app are specified, no input app ext", - fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id": "test1", "ext":{malformed}}`)}, - bidRequestApp: &openrtb2.App{ID: "test2", Ext: json.RawMessage(`{"test":2, "key": "value"}`)}, + fpdConfig: &openrtb_ext.ORTB2{App: json.RawMessage(`{"id":"test1","ext":{malformed}}`)}, + bidRequestApp: &openrtb2.App{ID: "test2", Ext: json.RawMessage(`{"test":2,"key":"value"}`)}, openRtbGlobalFPD: map[string][]openrtb2.Data{appContentDataKey: { {ID: "DataId1", Name: "Name1"}, {ID: "DataId2", Name: "Name2"}, @@ -1159,15 +1195,15 @@ func TestResolveApp(t *testing.T) { }}, Ext: json.RawMessage(`{"key":"value","test":1}`), }, - expectError: true, + expectError: "invalid first party data ext", }, } for _, test := range testCases { t.Run(test.description, func(t *testing.T) { resultApp, err := resolveApp(test.fpdConfig, test.bidRequestApp, test.globalFPD, test.openRtbGlobalFPD, "bidderA") - if test.expectError { - assert.Error(t, err) + if len(test.expectError) > 0 { + assert.EqualError(t, err, test.expectError) } else { assert.NoError(t, err) assert.Equal(t, test.expectedApp, resultApp, "Result app is incorrect") @@ -1184,28 +1220,28 @@ func TestBuildExtData(t *testing.T) { }{ { description: "Input object with int value", - input: []byte(`{"someData": 123}`), - expectedRes: `{"data": {"someData": 123}}`, + input: []byte(`{"someData":123}`), + expectedRes: `{"data":{"someData":123}}`, }, { description: "Input object with bool value", - input: []byte(`{"someData": true}`), - expectedRes: `{"data": {"someData": true}}`, + input: []byte(`{"someData":true}`), + expectedRes: `{"data":{"someData":true}}`, }, { description: "Input object with string value", - input: []byte(`{"someData": "true"}`), - expectedRes: `{"data": {"someData": "true"}}`, + input: []byte(`{"someData":"true"}`), + expectedRes: `{"data":{"someData":"true"}}`, }, { description: "No input object", input: []byte(`{}`), - expectedRes: `{"data": {}}`, + expectedRes: `{"data":{}}`, }, { description: "Input object with object value", - input: []byte(`{"someData": {"moreFpdData": "fpddata"}}`), - expectedRes: `{"data": {"someData": {"moreFpdData": "fpddata"}}}`, + input: []byte(`{"someData":{"moreFpdData":"fpddata"}}`), + expectedRes: `{"data":{"someData":{"moreFpdData":"fpddata"}}}`, }, } diff --git a/ortb/clone.go b/ortb/clone.go index 3023169bc8c..fa55cbe124f 100644 --- a/ortb/clone.go +++ b/ortb/clone.go @@ -6,83 +6,6 @@ import ( "github.com/prebid/prebid-server/v2/util/sliceutil" ) -func CloneApp(s *openrtb2.App) *openrtb2.App { - if s == nil { - return nil - } - - // Shallow Copy (Value Fields) - c := *s - - // Deep Copy (Pointers) - c.Cat = sliceutil.Clone(s.Cat) - c.SectionCat = sliceutil.Clone(s.SectionCat) - c.PageCat = sliceutil.Clone(s.PageCat) - c.PrivacyPolicy = ptrutil.Clone(s.PrivacyPolicy) - c.Paid = ptrutil.Clone(s.Paid) - c.Publisher = ClonePublisher(s.Publisher) - c.Content = CloneContent(s.Content) - c.KwArray = sliceutil.Clone(s.KwArray) - c.Ext = sliceutil.Clone(s.Ext) - - return &c -} - -func ClonePublisher(s *openrtb2.Publisher) *openrtb2.Publisher { - if s == nil { - return nil - } - - // Shallow Copy (Value Fields) - c := *s - - // Deep Copy (Pointers) - c.Cat = sliceutil.Clone(s.Cat) - c.Ext = sliceutil.Clone(s.Ext) - - return &c -} - -func CloneContent(s *openrtb2.Content) *openrtb2.Content { - if s == nil { - return nil - } - - // Shallow Copy (Value Fields) - c := *s - - // Deep Copy (Pointers) - c.Producer = CloneProducer(s.Producer) - c.Cat = sliceutil.Clone(s.Cat) - c.ProdQ = ptrutil.Clone(s.ProdQ) - c.VideoQuality = ptrutil.Clone(s.VideoQuality) - c.KwArray = sliceutil.Clone(s.KwArray) - c.LiveStream = ptrutil.Clone(s.LiveStream) - c.SourceRelationship = ptrutil.Clone(s.SourceRelationship) - c.Embeddable = ptrutil.Clone(s.Embeddable) - c.Data = CloneDataSlice(s.Data) - c.Network = CloneNetwork(s.Network) - c.Channel = CloneChannel(s.Channel) - c.Ext = sliceutil.Clone(s.Ext) - - return &c -} - -func CloneProducer(s *openrtb2.Producer) *openrtb2.Producer { - if s == nil { - return nil - } - - // Shallow Copy (Value Fields) - c := *s - - // Deep Copy (Pointers) - c.Cat = sliceutil.Clone(s.Cat) - c.Ext = sliceutil.Clone(s.Ext) - - return &c -} - func CloneDataSlice(s []openrtb2.Data) []openrtb2.Data { if s == nil { return nil @@ -130,56 +53,6 @@ func CloneSegment(s openrtb2.Segment) openrtb2.Segment { return s } -func CloneNetwork(s *openrtb2.Network) *openrtb2.Network { - if s == nil { - return nil - } - - // Shallow Copy (Value Fields) - c := *s - - // Deep Copy (Pointers) - c.Ext = sliceutil.Clone(s.Ext) - - return &c -} - -func CloneChannel(s *openrtb2.Channel) *openrtb2.Channel { - if s == nil { - return nil - } - - // Shallow Copy (Value Fields) - c := *s - - // Deep Copy (Pointers) - c.Ext = sliceutil.Clone(s.Ext) - - return &c -} - -func CloneSite(s *openrtb2.Site) *openrtb2.Site { - if s == nil { - return nil - } - - // Shallow Copy (Value Fields) - c := *s - - // Deep Copy (Pointers) - c.Cat = sliceutil.Clone(s.Cat) - c.SectionCat = sliceutil.Clone(s.SectionCat) - c.PageCat = sliceutil.Clone(s.PageCat) - c.Mobile = ptrutil.Clone(s.Mobile) - c.PrivacyPolicy = ptrutil.Clone(s.PrivacyPolicy) - c.Publisher = ClonePublisher(s.Publisher) - c.Content = CloneContent(s.Content) - c.KwArray = sliceutil.Clone(s.KwArray) - c.Ext = sliceutil.Clone(s.Ext) - - return &c -} - func CloneUser(s *openrtb2.User) *openrtb2.User { if s == nil { return nil @@ -387,24 +260,6 @@ func CloneUID(s openrtb2.UID) openrtb2.UID { return s } -func CloneDOOH(s *openrtb2.DOOH) *openrtb2.DOOH { - if s == nil { - return nil - } - - // Shallow Copy (Value Fields) - c := *s - - // Deep Copy (Pointers) - c.VenueType = sliceutil.Clone(s.VenueType) - c.VenueTypeTax = ptrutil.Clone(s.VenueTypeTax) - c.Publisher = ClonePublisher(s.Publisher) - c.Content = CloneContent(s.Content) - c.Ext = sliceutil.Clone(s.Ext) - - return &c -} - // CloneBidRequestPartial performs a deep clone of just the bid request device, user, and source fields. func CloneBidRequestPartial(s *openrtb2.BidRequest) *openrtb2.BidRequest { if s == nil { diff --git a/ortb/clone_test.go b/ortb/clone_test.go index 73d03614db4..21c19f170c6 100644 --- a/ortb/clone_test.go +++ b/ortb/clone_test.go @@ -11,236 +11,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestCloneApp(t *testing.T) { - t.Run("nil", func(t *testing.T) { - result := CloneApp(nil) - assert.Nil(t, result) - }) - - t.Run("empty", func(t *testing.T) { - given := &openrtb2.App{} - result := CloneApp(given) - assert.Equal(t, given, result) - assert.NotSame(t, given, result) - }) - - t.Run("populated", func(t *testing.T) { - given := &openrtb2.App{ - ID: "anyID", - Name: "anyName", - Bundle: "anyBundle", - Domain: "anyDomain", - StoreURL: "anyStoreURL", - CatTax: adcom1.CatTaxIABContent10, - Cat: []string{"cat1"}, - SectionCat: []string{"sectionCat1"}, - PageCat: []string{"pageCat1"}, - Ver: "anyVer", - PrivacyPolicy: ptrutil.ToPtr[int8](1), - Paid: ptrutil.ToPtr[int8](2), - Publisher: &openrtb2.Publisher{ID: "anyPublisher", Ext: json.RawMessage(`{"publisher":1}`)}, - Content: &openrtb2.Content{ID: "anyContent", Ext: json.RawMessage(`{"content":1}`)}, - Keywords: "anyKeywords", - KwArray: []string{"key1"}, - InventoryPartnerDomain: "anyInventoryPartnerDomain", - Ext: json.RawMessage(`{"anyField":1}`), - } - result := CloneApp(given) - assert.Equal(t, given, result, "equality") - assert.NotSame(t, given, result, "pointer") - assert.NotSame(t, given.Cat, result.Cat, "cat") - assert.NotSame(t, given.SectionCat, result.SectionCat, "sectioncat") - assert.NotSame(t, given.PageCat, result.PageCat, "pagecat") - assert.NotSame(t, given.PrivacyPolicy, result.PrivacyPolicy, "privacypolicy") - assert.NotSame(t, given.Paid, result.Paid, "paid") - assert.NotSame(t, given.Publisher, result.Publisher, "publisher") - assert.NotSame(t, given.Publisher.Ext, result.Publisher.Ext, "publisher-ext") - assert.NotSame(t, given.Content, result.Content, "content") - assert.NotSame(t, given.Content.Ext, result.Content.Ext, "content-ext") - assert.NotSame(t, given.KwArray, result.KwArray, "kwarray") - assert.NotSame(t, given.Ext, result.Ext, "ext") - }) - - t.Run("assumptions", func(t *testing.T) { - assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.App{})), - []string{ - "Cat", - "SectionCat", - "PageCat", - "PrivacyPolicy", - "Paid", - "Publisher", - "Content", - "KwArray", - "Ext", - }) - }) -} - -func TestClonePublisher(t *testing.T) { - t.Run("nil", func(t *testing.T) { - result := ClonePublisher(nil) - assert.Nil(t, result) - }) - - t.Run("empty", func(t *testing.T) { - given := &openrtb2.Publisher{} - result := ClonePublisher(given) - assert.Equal(t, given, result) - assert.NotSame(t, given, result) - }) - - t.Run("populated", func(t *testing.T) { - given := &openrtb2.Publisher{ - ID: "anyID", - Name: "anyName", - CatTax: adcom1.CatTaxIABContent20, - Cat: []string{"cat1"}, - Domain: "anyDomain", - Ext: json.RawMessage(`{"anyField":1}`), - } - result := ClonePublisher(given) - assert.Equal(t, given, result, "equality") - assert.NotSame(t, given, result, "pointer") - assert.NotSame(t, given.Cat, result.Cat, "cat") - assert.NotSame(t, given.Ext, result.Ext, "ext") - }) - - t.Run("assumptions", func(t *testing.T) { - assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.Publisher{})), - []string{ - "Cat", - "Ext", - }) - }) -} - -func TestCloneContent(t *testing.T) { - t.Run("nil", func(t *testing.T) { - result := CloneContent(nil) - assert.Nil(t, result) - }) - - t.Run("empty", func(t *testing.T) { - given := &openrtb2.Content{} - result := CloneContent(given) - assert.Equal(t, given, result) - assert.NotSame(t, given, result) - }) - - t.Run("populated", func(t *testing.T) { - given := &openrtb2.Content{ - ID: "anyID", - Episode: 1, - Title: "anyTitle", - Series: "anySeries", - Season: "anySeason", - Artist: "anyArtist", - Genre: "anyGenre", - Album: "anyAlbum", - ISRC: "anyIsrc", - Producer: &openrtb2.Producer{ID: "anyID", Cat: []string{"anyCat"}}, - URL: "anyUrl", - CatTax: adcom1.CatTaxIABContent10, - Cat: []string{"cat1"}, - ProdQ: ptrutil.ToPtr(adcom1.ProductionProsumer), - VideoQuality: ptrutil.ToPtr(adcom1.ProductionProfessional), - Context: adcom1.ContentApp, - ContentRating: "anyContentRating", - UserRating: "anyUserRating", - QAGMediaRating: adcom1.MediaRatingAll, - Keywords: "anyKeywords", - KwArray: []string{"key1"}, - LiveStream: ptrutil.ToPtr[int8](2), - SourceRelationship: ptrutil.ToPtr[int8](3), - Len: 4, - Language: "anyLanguage", - LangB: "anyLangB", - Embeddable: ptrutil.ToPtr[int8](5), - Data: []openrtb2.Data{{ID: "1", Ext: json.RawMessage(`{"data":1}`)}}, - Network: &openrtb2.Network{ID: "anyNetwork", Ext: json.RawMessage(`{"network":1}`)}, - Channel: &openrtb2.Channel{ID: "anyChannel", Ext: json.RawMessage(`{"channel":1}`)}, - Ext: json.RawMessage(`{"anyField":1}`), - } - result := CloneContent(given) - assert.Equal(t, given, result, "equality") - assert.NotSame(t, given, result, "pointer") - assert.NotSame(t, given.Producer, result.Producer, "producer") - assert.NotSame(t, given.Producer.Cat, result.Producer.Cat, "producer-cat") - assert.NotSame(t, given.Cat, result.Cat, "cat") - assert.NotSame(t, given.ProdQ, result.ProdQ, "prodq") - assert.NotSame(t, given.VideoQuality, result.VideoQuality, "videoquality") - assert.NotSame(t, given.KwArray, result.KwArray, "kwarray") - assert.NotSame(t, given.LiveStream, result.LiveStream, "livestream") - assert.NotSame(t, given.SourceRelationship, result.SourceRelationship, "sourcerelationship") - assert.NotSame(t, given.Embeddable, result.Embeddable, "embeddable") - assert.NotSame(t, given.Data, result.Data, "data") - assert.NotSame(t, given.Data[0], result.Data[0], "data-item") - assert.NotSame(t, given.Data[0].Ext, result.Data[0].Ext, "data-item-ext") - assert.NotSame(t, given.Network, result.Network, "network") - assert.NotSame(t, given.Network.Ext, result.Network.Ext, "network-ext") - assert.NotSame(t, given.Channel, result.Channel, "channel") - assert.NotSame(t, given.Channel.Ext, result.Channel.Ext, "channel-ext") - assert.NotSame(t, given.Ext, result.Ext, "ext") - }) - - t.Run("assumptions", func(t *testing.T) { - assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.Content{})), - []string{ - "Producer", - "Cat", - "ProdQ", - "VideoQuality", - "KwArray", - "LiveStream", - "SourceRelationship", - "Embeddable", - "Data", - "Network", - "Channel", - "Ext", - }) - }) -} - -func TestCloneProducer(t *testing.T) { - t.Run("nil", func(t *testing.T) { - result := CloneProducer(nil) - assert.Nil(t, result) - }) - - t.Run("empty", func(t *testing.T) { - given := &openrtb2.Producer{} - result := CloneProducer(given) - assert.Equal(t, given, result) - assert.NotSame(t, given, result) - }) - - t.Run("populated", func(t *testing.T) { - given := &openrtb2.Producer{ - ID: "anyID", - Name: "anyName", - CatTax: adcom1.CatTaxIABContent20, - Cat: []string{"cat1"}, - Domain: "anyDomain", - Ext: json.RawMessage(`{"anyField":1}`), - } - result := CloneProducer(given) - assert.Equal(t, given, result, "equality") - assert.NotSame(t, given, result, "pointer") - assert.NotSame(t, given.Cat, result.Cat, "cat") - assert.NotSame(t, given.Ext, result.Ext, "ext") - }) - - t.Run("assumptions", func(t *testing.T) { - assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.Producer{})), - []string{ - "Cat", - "Ext", - }) - }) -} - func TestCloneDataSlice(t *testing.T) { t.Run("nil", func(t *testing.T) { result := CloneDataSlice(nil) @@ -363,140 +133,6 @@ func TestCloneSegment(t *testing.T) { }) } -func TestCloneNetwork(t *testing.T) { - t.Run("nil", func(t *testing.T) { - result := CloneNetwork(nil) - assert.Nil(t, result) - }) - - t.Run("empty", func(t *testing.T) { - given := &openrtb2.Network{} - result := CloneNetwork(given) - assert.Empty(t, result) - assert.NotSame(t, given, result) - }) - - t.Run("populated", func(t *testing.T) { - given := &openrtb2.Network{ - ID: "anyID", - Name: "anyName", - Domain: "anyDomain", - Ext: json.RawMessage(`{"anyField":1}`), - } - result := CloneNetwork(given) - assert.Equal(t, given, result, "equality") - assert.NotSame(t, given, result, "pointer") - assert.NotSame(t, given.Ext, result.Ext, "ext") - }) - - t.Run("assumptions", func(t *testing.T) { - assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.Network{})), - []string{ - "Ext", - }) - }) -} - -func TestCloneChannel(t *testing.T) { - t.Run("nil", func(t *testing.T) { - result := CloneChannel(nil) - assert.Nil(t, result) - }) - - t.Run("empty", func(t *testing.T) { - given := &openrtb2.Channel{} - result := CloneChannel(given) - assert.Empty(t, result) - assert.NotSame(t, given, result) - }) - - t.Run("populated", func(t *testing.T) { - given := &openrtb2.Channel{ - ID: "anyID", - Name: "anyName", - Domain: "anyDomain", - Ext: json.RawMessage(`{"anyField":1}`), - } - result := CloneChannel(given) - assert.Equal(t, given, result, "equality") - assert.NotSame(t, given, result, "pointer") - assert.NotSame(t, given.Ext, result.Ext, "ext") - }) - - t.Run("assumptions", func(t *testing.T) { - assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.Channel{})), - []string{ - "Ext", - }) - }) -} - -func TestCloneSite(t *testing.T) { - t.Run("nil", func(t *testing.T) { - result := CloneSite(nil) - assert.Nil(t, result) - }) - - t.Run("empty", func(t *testing.T) { - given := &openrtb2.Site{} - result := CloneSite(given) - assert.Empty(t, result) - assert.NotSame(t, given, result) - }) - - t.Run("populated", func(t *testing.T) { - given := &openrtb2.Site{ - ID: "anyID", - Name: "anyName", - Domain: "anyDomain", - CatTax: adcom1.CatTaxIABContent10, - Cat: []string{"cat1"}, - SectionCat: []string{"sectionCat1"}, - PageCat: []string{"pageCat1"}, - Page: "anyPage", - Ref: "anyRef", - Search: "anySearch", - Mobile: ptrutil.ToPtr[int8](1), - PrivacyPolicy: ptrutil.ToPtr[int8](2), - Publisher: &openrtb2.Publisher{ID: "anyPublisher", Ext: json.RawMessage(`{"publisher":1}`)}, - Content: &openrtb2.Content{ID: "anyContent", Ext: json.RawMessage(`{"content":1}`)}, - Keywords: "anyKeywords", - KwArray: []string{"key1"}, - InventoryPartnerDomain: "anyInventoryPartnerDomain", - Ext: json.RawMessage(`{"anyField":1}`), - } - result := CloneSite(given) - assert.Equal(t, given, result, "equality") - assert.NotSame(t, given, result, "pointer") - assert.NotSame(t, given.Cat, result.Cat, "cat") - assert.NotSame(t, given.SectionCat, result.SectionCat, "sectioncat") - assert.NotSame(t, given.PageCat, result.PageCat, "pagecat") - assert.NotSame(t, given.Mobile, result.Mobile, "mobile") - assert.NotSame(t, given.PrivacyPolicy, result.PrivacyPolicy, "privacypolicy") - assert.NotSame(t, given.Publisher, result.Publisher, "publisher") - assert.NotSame(t, given.Publisher.Ext, result.Publisher.Ext, "publisher-ext") - assert.NotSame(t, given.Content, result.Content, "content") - assert.NotSame(t, given.Content.Ext, result.Content.Ext, "content-ext") - assert.NotSame(t, given.KwArray, result.KwArray, "kwarray") - assert.NotSame(t, given.Ext, result.Ext, "ext") - }) - - t.Run("assumptions", func(t *testing.T) { - assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.Site{})), - []string{ - "Cat", - "SectionCat", - "PageCat", - "Mobile", - "PrivacyPolicy", - "Publisher", - "Content", - "KwArray", - "Ext", - }) - }) -} - func TestCloneUser(t *testing.T) { t.Run("nil", func(t *testing.T) { result := CloneUser(nil) @@ -1080,55 +716,6 @@ func TestCloneUID(t *testing.T) { }) } -func TestCloneDOOH(t *testing.T) { - t.Run("nil", func(t *testing.T) { - result := CloneDOOH(nil) - assert.Nil(t, result) - }) - - t.Run("empty", func(t *testing.T) { - given := &openrtb2.DOOH{} - result := CloneDOOH(given) - assert.Empty(t, result) - assert.NotSame(t, given, result) - }) - - t.Run("populated", func(t *testing.T) { - given := &openrtb2.DOOH{ - ID: "anyID", - Name: "anyName", - VenueType: []string{"venue1"}, - VenueTypeTax: ptrutil.ToPtr(adcom1.VenueTaxonomyAdCom), - Publisher: &openrtb2.Publisher{ID: "anyPublisher", Ext: json.RawMessage(`{"publisher":1}`)}, - Domain: "anyDomain", - Keywords: "anyKeywords", - Content: &openrtb2.Content{ID: "anyContent", Ext: json.RawMessage(`{"content":1}`)}, - Ext: json.RawMessage(`{"anyField":1}`), - } - result := CloneDOOH(given) - assert.Equal(t, given, result, "equality") - assert.NotSame(t, given, result, "pointer") - assert.NotSame(t, given.VenueType, result.VenueType, "venuetype") - assert.NotSame(t, given.VenueTypeTax, result.VenueTypeTax, "venuetypetax") - assert.NotSame(t, given.Publisher, result.Publisher, "publisher") - assert.NotSame(t, given.Publisher.Ext, result.Publisher.Ext, "publisher-ext") - assert.NotSame(t, given.Content, result.Content, "content") - assert.NotSame(t, given.Content.Ext, result.Content.Ext, "content-ext") - assert.NotSame(t, given.Ext, result.Ext, "ext") - }) - - t.Run("assumptions", func(t *testing.T) { - assert.ElementsMatch(t, discoverPointerFields(reflect.TypeOf(openrtb2.DOOH{})), - []string{ - "VenueType", - "VenueTypeTax", - "Publisher", - "Content", - "Ext", - }) - }) -} - func TestCloneBidderReq(t *testing.T) { t.Run("nil", func(t *testing.T) { result := CloneBidRequestPartial(nil) diff --git a/ortb/merge/app.go b/ortb/merge/app.go deleted file mode 100644 index 0a2976e858a..00000000000 --- a/ortb/merge/app.go +++ /dev/null @@ -1,61 +0,0 @@ -package merge - -import ( - "encoding/json" - - "github.com/prebid/openrtb/v20/openrtb2" - "github.com/prebid/prebid-server/v2/ortb" - "github.com/prebid/prebid-server/v2/util/jsonutil" -) - -func App(v *openrtb2.App, overrideJSON json.RawMessage) (*openrtb2.App, error) { - c := ortb.CloneApp(v) - - // Track EXTs - // It's not necessary to track `ext` fields in array items because the array - // items will be replaced entirely with the override JSON, so no merge is required. - var ext, extPublisher, extContent, extContentProducer, extContentNetwork, extContentChannel extMerger - ext.Track(&c.Ext) - if c.Publisher != nil { - extPublisher.Track(&c.Publisher.Ext) - } - if c.Content != nil { - extContent.Track(&c.Content.Ext) - } - if c.Content != nil && c.Content.Producer != nil { - extContentProducer.Track(&c.Content.Producer.Ext) - } - if c.Content != nil && c.Content.Network != nil { - extContentNetwork.Track(&c.Content.Network.Ext) - } - if c.Content != nil && c.Content.Channel != nil { - extContentChannel.Track(&c.Content.Channel.Ext) - } - - // Merge - if err := jsonutil.Unmarshal(overrideJSON, &c); err != nil { - return nil, err - } - - // Merge EXTs - if err := ext.Merge(); err != nil { - return nil, err - } - if err := extPublisher.Merge(); err != nil { - return nil, err - } - if err := extContent.Merge(); err != nil { - return nil, err - } - if err := extContentProducer.Merge(); err != nil { - return nil, err - } - if err := extContentNetwork.Merge(); err != nil { - return nil, err - } - if err := extContentChannel.Merge(); err != nil { - return nil, err - } - - return c, nil -} diff --git a/ortb/merge/app_test.go b/ortb/merge/app_test.go deleted file mode 100644 index 69dc851ffdb..00000000000 --- a/ortb/merge/app_test.go +++ /dev/null @@ -1,217 +0,0 @@ -package merge - -import ( - "encoding/json" - "reflect" - "testing" - - "github.com/prebid/openrtb/v20/openrtb2" - "github.com/prebid/prebid-server/v2/ortb" - "github.com/stretchr/testify/assert" -) - -func TestApp(t *testing.T) { - testCases := []struct { - name string - givenApp openrtb2.App - givenJson json.RawMessage - expectedApp openrtb2.App - expectError bool - }{ - { - name: "empty", - givenApp: openrtb2.App{}, - givenJson: []byte(`{}`), - expectedApp: openrtb2.App{}, - }, - { - name: "toplevel", - givenApp: openrtb2.App{ID: "1"}, - givenJson: []byte(`{"id":"2"}`), - expectedApp: openrtb2.App{ID: "2"}, - }, - { - name: "toplevel-ext", - givenApp: openrtb2.App{Ext: []byte(`{"a":1,"b":2}`)}, - givenJson: []byte(`{"ext":{"b":100,"c":3}}`), - expectedApp: openrtb2.App{Ext: []byte(`{"a":1,"b":100,"c":3}`)}, - }, - { - name: "toplevel-ext-err", - givenApp: openrtb2.App{ID: "1", Ext: []byte(`malformed`)}, - givenJson: []byte(`{"id":"2"}`), - expectError: true, - }, - { - name: "nested-publisher", - givenApp: openrtb2.App{Publisher: &openrtb2.Publisher{Name: "pub1"}}, - givenJson: []byte(`{"publisher":{"name": "pub2"}}`), - expectedApp: openrtb2.App{Publisher: &openrtb2.Publisher{Name: "pub2"}}, - }, - { - name: "nested-content", - givenApp: openrtb2.App{Content: &openrtb2.Content{Title: "content1"}}, - givenJson: []byte(`{"content":{"title": "content2"}}`), - expectedApp: openrtb2.App{Content: &openrtb2.Content{Title: "content2"}}, - }, - { - name: "nested-content-producer", - givenApp: openrtb2.App{Content: &openrtb2.Content{Title: "content1", Producer: &openrtb2.Producer{Name: "producer1"}}}, - givenJson: []byte(`{"content":{"title": "content2", "producer":{"name":"producer2"}}}`), - expectedApp: openrtb2.App{Content: &openrtb2.Content{Title: "content2", Producer: &openrtb2.Producer{Name: "producer2"}}}, - }, - { - name: "nested-content-network", - givenApp: openrtb2.App{Content: &openrtb2.Content{Title: "content1", Network: &openrtb2.Network{Name: "network1"}}}, - givenJson: []byte(`{"content":{"title": "content2", "network":{"name":"network2"}}}`), - expectedApp: openrtb2.App{Content: &openrtb2.Content{Title: "content2", Network: &openrtb2.Network{Name: "network2"}}}, - }, - { - name: "nested-content-channel", - givenApp: openrtb2.App{Content: &openrtb2.Content{Title: "content1", Channel: &openrtb2.Channel{Name: "channel1"}}}, - givenJson: []byte(`{"content":{"title": "content2", "channel":{"name":"channel2"}}}`), - expectedApp: openrtb2.App{Content: &openrtb2.Content{Title: "content2", Channel: &openrtb2.Channel{Name: "channel2"}}}, - }, - { - name: "nested-publisher-ext", - givenApp: openrtb2.App{Publisher: &openrtb2.Publisher{Ext: []byte(`{"a":1,"b":2}`)}}, - givenJson: []byte(`{"publisher":{"ext":{"b":100,"c":3}}}`), - expectedApp: openrtb2.App{Publisher: &openrtb2.Publisher{Ext: []byte(`{"a":1,"b":100,"c":3}`)}}, - }, - { - name: "nested-content-ext", - givenApp: openrtb2.App{Content: &openrtb2.Content{Ext: []byte(`{"a":1,"b":2}`)}}, - givenJson: []byte(`{"content":{"ext":{"b":100,"c":3}}}`), - expectedApp: openrtb2.App{Content: &openrtb2.Content{Ext: []byte(`{"a":1,"b":100,"c":3}`)}}, - }, - { - name: "nested-content-producer-ext", - givenApp: openrtb2.App{Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`{"a":1,"b":2}`)}}}, - givenJson: []byte(`{"content":{"producer":{"ext":{"b":100,"c":3}}}}`), - expectedApp: openrtb2.App{Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`{"a":1,"b":100,"c":3}`)}}}, - }, - { - name: "nested-content-network-ext", - givenApp: openrtb2.App{Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`{"a":1,"b":2}`)}}}, - givenJson: []byte(`{"content":{"network":{"ext":{"b":100,"c":3}}}}`), - expectedApp: openrtb2.App{Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`{"a":1,"b":100,"c":3}`)}}}, - }, - { - name: "nested-content-channel-ext", - givenApp: openrtb2.App{Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`{"a":1,"b":2}`)}}}, - givenJson: []byte(`{"content":{"channel":{"ext":{"b":100,"c":3}}}}`), - expectedApp: openrtb2.App{Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`{"a":1,"b":100,"c":3}`)}}}, - }, - { - name: "toplevel-ext-and-nested-publisher-ext", - givenApp: openrtb2.App{Ext: []byte(`{"a":1,"b":2}`), Publisher: &openrtb2.Publisher{Ext: []byte(`{"a":10,"b":20}`)}}, - givenJson: []byte(`{"ext":{"b":100,"c":3}, "publisher":{"ext":{"b":100,"c":3}}}`), - expectedApp: openrtb2.App{Ext: []byte(`{"a":1,"b":100,"c":3}`), Publisher: &openrtb2.Publisher{Ext: []byte(`{"a":10,"b":100,"c":3}`)}}, - }, - { - name: "toplevel-ext-and-nested-content-ext", - givenApp: openrtb2.App{Ext: []byte(`{"a":1,"b":2}`), Content: &openrtb2.Content{Ext: []byte(`{"a":10,"b":20}`)}}, - givenJson: []byte(`{"ext":{"b":100,"c":3}, "content":{"ext":{"b":100,"c":3}}}`), - expectedApp: openrtb2.App{Ext: []byte(`{"a":1,"b":100,"c":3}`), Content: &openrtb2.Content{Ext: []byte(`{"a":10,"b":100,"c":3}`)}}, - }, - { - name: "toplevel-ext-and-nested-content-producer-ext", - givenApp: openrtb2.App{Ext: []byte(`{"a":1,"b":2}`), Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`{"a":10,"b":20}`)}}}, - givenJson: []byte(`{"ext":{"b":100,"c":3}, "content":{"producer": {"ext":{"b":100,"c":3}}}}`), - expectedApp: openrtb2.App{Ext: []byte(`{"a":1,"b":100,"c":3}`), Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`{"a":10,"b":100,"c":3}`)}}}, - }, - { - name: "toplevel-ext-and-nested-content-network-ext", - givenApp: openrtb2.App{Ext: []byte(`{"a":1,"b":2}`), Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`{"a":10,"b":20}`)}}}, - givenJson: []byte(`{"ext":{"b":100,"c":3}, "content":{"network": {"ext":{"b":100,"c":3}}}}`), - expectedApp: openrtb2.App{Ext: []byte(`{"a":1,"b":100,"c":3}`), Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`{"a":10,"b":100,"c":3}`)}}}, - }, - { - name: "toplevel-ext-and-nested-content-channel-ext", - givenApp: openrtb2.App{Ext: []byte(`{"a":1,"b":2}`), Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`{"a":10,"b":20}`)}}}, - givenJson: []byte(`{"ext":{"b":100,"c":3}, "content":{"channel": {"ext":{"b":100,"c":3}}}}`), - expectedApp: openrtb2.App{Ext: []byte(`{"a":1,"b":100,"c":3}`), Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`{"a":10,"b":100,"c":3}`)}}}, - }, - { - name: "nested-publisher-ext-err", - givenApp: openrtb2.App{Publisher: &openrtb2.Publisher{Ext: []byte(`malformed`)}}, - givenJson: []byte(`{"publisher":{"ext":{"b":100,"c":3}}}`), - expectError: true, - }, - { - name: "nested-content-ext-err", - givenApp: openrtb2.App{Content: &openrtb2.Content{Ext: []byte(`malformed`)}}, - givenJson: []byte(`{"content":{"ext":{"b":100,"c":3}}}`), - expectError: true, - }, - { - name: "nested-content-producer-ext-err", - givenApp: openrtb2.App{Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`malformed`)}}}, - givenJson: []byte(`{"content":{"producer": {"ext":{"b":100,"c":3}}}}`), - expectError: true, - }, - { - name: "nested-content-network-ext-err", - givenApp: openrtb2.App{Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`malformed`)}}}, - givenJson: []byte(`{"content":{"network": {"ext":{"b":100,"c":3}}}}`), - expectError: true, - }, - { - name: "nested-content-channel-ext-err", - givenApp: openrtb2.App{Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`malformed`)}}}, - givenJson: []byte(`{"content":{"channelx": {"ext":{"b":100,"c":3}}}}`), - expectError: true, - }, - { - name: "json-err", - givenApp: openrtb2.App{ID: "1", Ext: []byte(`{"a":1}`)}, - givenJson: []byte(`malformed`), - expectError: true, - }, - } - - for _, test := range testCases { - t.Run(test.name, func(t *testing.T) { - originalApp := ortb.CloneApp(&test.givenApp) - merged, err := App(&test.givenApp, test.givenJson) - - assert.Equal(t, &test.givenApp, originalApp) - - if test.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, &test.expectedApp, merged) - } - }) - } -} - -// TestAppObjectStructure detects when new nested objects are added to the App object, -// as these will create a gap in the merge.App logic. If this test fails, fix merge.App -// to add support and update this test to set a new baseline. -func TestAppObjectStructure(t *testing.T) { - knownNestedStructs := []string{ - "Publisher", - "Content", - "Content.Producer", - "Content.Network", - "Content.Channel", - } - - discoveredNestedStructs := []string{} - - var discover func(parent string, t reflect.Type) - discover = func(parent string, t reflect.Type) { - fields := reflect.VisibleFields(t) - for _, field := range fields { - if field.Type.Kind() == reflect.Pointer && field.Type.Elem().Kind() == reflect.Struct { - discoveredNestedStructs = append(discoveredNestedStructs, parent+field.Name) - discover(parent+field.Name+".", field.Type.Elem()) - } - } - } - discover("", reflect.TypeOf(openrtb2.App{})) - - assert.ElementsMatch(t, knownNestedStructs, discoveredNestedStructs) -} diff --git a/ortb/merge/extmerger.go b/ortb/merge/extmerger.go deleted file mode 100644 index dd7da425cdc..00000000000 --- a/ortb/merge/extmerger.go +++ /dev/null @@ -1,59 +0,0 @@ -package merge - -import ( - "encoding/json" - "errors" - - "github.com/prebid/prebid-server/v2/util/sliceutil" - jsonpatch "gopkg.in/evanphx/json-patch.v4" -) - -var ( - ErrBadRequest = errors.New("invalid request ext") - ErrBadOverride = errors.New("invalid override ext") -) - -// extMerger tracks a JSON `ext` field within an OpenRTB request. The value of the -// `ext` field is expected to be modified when calling unmarshal on the same object -// and will later be updated when invoking Merge. -type extMerger struct { - ext *json.RawMessage // Pointer to the JSON `ext` field. - snapshot json.RawMessage // Copy of the original state of the JSON `ext` field. -} - -// Track saves a copy of the JSON `ext` field and stores a reference to the extension -// object for comparison later in the Merge call. -func (e *extMerger) Track(ext *json.RawMessage) { - e.ext = ext - e.snapshot = sliceutil.Clone(*ext) -} - -// Merge applies a JSON merge of the stored extension snapshot on top of the current -// JSON of the tracked extension object. -func (e extMerger) Merge() error { - if e.ext == nil { - return nil - } - - if len(e.snapshot) == 0 { - return nil - } - - if len(*e.ext) == 0 { - *e.ext = e.snapshot - return nil - } - - merged, err := jsonpatch.MergePatch(e.snapshot, *e.ext) - if err != nil { - if errors.Is(err, jsonpatch.ErrBadJSONDoc) { - return ErrBadRequest - } else if errors.Is(err, jsonpatch.ErrBadJSONPatch) { - return ErrBadOverride - } - return err - } - - *e.ext = merged - return nil -} diff --git a/ortb/merge/extmerger_test.go b/ortb/merge/extmerger_test.go deleted file mode 100644 index 9340887ac9a..00000000000 --- a/ortb/merge/extmerger_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package merge - -import ( - "encoding/json" - "testing" - - "github.com/prebid/prebid-server/v2/util/sliceutil" - "github.com/stretchr/testify/assert" -) - -func TestExtMerger(t *testing.T) { - t.Run("nil", func(t *testing.T) { - merger := extMerger{ext: nil, snapshot: json.RawMessage(`{"a":1}`)} - assert.NoError(t, merger.Merge()) - assert.Nil(t, merger.ext) - }) - - testCases := []struct { - name string - givenOriginal json.RawMessage - givenJson json.RawMessage - expectedExt json.RawMessage - expectedErr string - }{ - { - name: "both-populated", - givenOriginal: json.RawMessage(`{"a":1,"b":2}`), - givenJson: json.RawMessage(`{"b":200,"c":3}`), - expectedExt: json.RawMessage(`{"a":1,"b":200,"c":3}`), - }, - { - name: "both-nil", - givenJson: nil, - givenOriginal: nil, - expectedExt: nil, - }, - { - name: "both-empty", - givenOriginal: json.RawMessage(`{}`), - givenJson: json.RawMessage(`{}`), - expectedExt: json.RawMessage(`{}`), - }, - { - name: "ext-nil", - givenOriginal: json.RawMessage(`{"b":2}`), - givenJson: nil, - expectedExt: json.RawMessage(`{"b":2}`), - }, - { - name: "ext-empty", - givenOriginal: json.RawMessage(`{"b":2}`), - givenJson: json.RawMessage(`{}`), - expectedExt: json.RawMessage(`{"b":2}`), - }, - { - name: "ext-malformed", - givenOriginal: json.RawMessage(`{"b":2}`), - givenJson: json.RawMessage(`malformed`), - expectedErr: "invalid override ext", - }, - { - name: "snapshot-nil", - givenOriginal: nil, - givenJson: json.RawMessage(`{"a":1}`), - expectedExt: json.RawMessage(`{"a":1}`), - }, - { - name: "snapshot-empty", - givenOriginal: json.RawMessage(`{}`), - givenJson: json.RawMessage(`{"a":1}`), - expectedExt: json.RawMessage(`{"a":1}`), - }, - { - name: "snapshot-malformed", - givenOriginal: json.RawMessage(`malformed`), - givenJson: json.RawMessage(`{"a":1}`), - expectedErr: "invalid request ext", - }, - } - - for _, test := range testCases { - t.Run(test.name, func(t *testing.T) { - // Initialize A Ext Raw Message For Testing - simulatedExt := json.RawMessage(sliceutil.Clone(test.givenOriginal)) - - // Begin Tracking - var merger extMerger - merger.Track(&simulatedExt) - - // Unmarshal - simulatedExt.UnmarshalJSON(test.givenJson) - - // Merge - actualErr := merger.Merge() - - if test.expectedErr == "" { - assert.NoError(t, actualErr, "error") - - if test.expectedExt == nil { - assert.Nil(t, simulatedExt, "json") - } else { - assert.JSONEq(t, string(test.expectedExt), string(simulatedExt), "json") - } - } else { - assert.EqualError(t, actualErr, test.expectedErr, "error") - } - }) - } -} diff --git a/ortb/merge/site.go b/ortb/merge/site.go deleted file mode 100644 index 3e059acf282..00000000000 --- a/ortb/merge/site.go +++ /dev/null @@ -1,61 +0,0 @@ -package merge - -import ( - "encoding/json" - - "github.com/prebid/openrtb/v20/openrtb2" - "github.com/prebid/prebid-server/v2/ortb" - "github.com/prebid/prebid-server/v2/util/jsonutil" -) - -func Site(v *openrtb2.Site, overrideJSON json.RawMessage, bidderName string) (*openrtb2.Site, error) { - c := ortb.CloneSite(v) - - // Track EXTs - // It's not necessary to track `ext` fields in array items because the array - // items will be replaced entirely with the override JSON, so no merge is required. - var ext, extPublisher, extContent, extContentProducer, extContentNetwork, extContentChannel extMerger - ext.Track(&c.Ext) - if c.Publisher != nil { - extPublisher.Track(&c.Publisher.Ext) - } - if c.Content != nil { - extContent.Track(&c.Content.Ext) - } - if c.Content != nil && c.Content.Producer != nil { - extContentProducer.Track(&c.Content.Producer.Ext) - } - if c.Content != nil && c.Content.Network != nil { - extContentNetwork.Track(&c.Content.Network.Ext) - } - if c.Content != nil && c.Content.Channel != nil { - extContentChannel.Track(&c.Content.Channel.Ext) - } - - // Merge - if err := jsonutil.Unmarshal(overrideJSON, &c); err != nil { - return nil, err - } - - // Merge EXTs - if err := ext.Merge(); err != nil { - return nil, err - } - if err := extPublisher.Merge(); err != nil { - return nil, err - } - if err := extContent.Merge(); err != nil { - return nil, err - } - if err := extContentProducer.Merge(); err != nil { - return nil, err - } - if err := extContentNetwork.Merge(); err != nil { - return nil, err - } - if err := extContentChannel.Merge(); err != nil { - return nil, err - } - - return c, nil -} diff --git a/ortb/merge/site_test.go b/ortb/merge/site_test.go deleted file mode 100644 index 800886239f4..00000000000 --- a/ortb/merge/site_test.go +++ /dev/null @@ -1,217 +0,0 @@ -package merge - -import ( - "encoding/json" - "reflect" - "testing" - - "github.com/prebid/openrtb/v20/openrtb2" - "github.com/prebid/prebid-server/v2/ortb" - "github.com/stretchr/testify/assert" -) - -func TestSite(t *testing.T) { - testCases := []struct { - name string - givenSite openrtb2.Site - givenJson json.RawMessage - expectedSite openrtb2.Site - expectError bool - }{ - { - name: "empty", - givenSite: openrtb2.Site{}, - givenJson: []byte(`{}`), - expectedSite: openrtb2.Site{}, - }, - { - name: "toplevel", - givenSite: openrtb2.Site{ID: "1"}, - givenJson: []byte(`{"id":"2"}`), - expectedSite: openrtb2.Site{ID: "2"}, - }, - { - name: "toplevel-ext", - givenSite: openrtb2.Site{Page: "test.com/page", Ext: []byte(`{"a":1,"b":2}`)}, - givenJson: []byte(`{"ext":{"b":100,"c":3}}`), - expectedSite: openrtb2.Site{Page: "test.com/page", Ext: []byte(`{"a":1,"b":100,"c":3}`)}, - }, - { - name: "toplevel-ext-err", - givenSite: openrtb2.Site{ID: "1", Ext: []byte(`malformed`)}, - givenJson: []byte(`{"id":"2"}`), - expectError: true, - }, - { - name: "nested-publisher", - givenSite: openrtb2.Site{Page: "test.com/page", Publisher: &openrtb2.Publisher{Name: "pub1"}}, - givenJson: []byte(`{"publisher":{"name": "pub2"}}`), - expectedSite: openrtb2.Site{Page: "test.com/page", Publisher: &openrtb2.Publisher{Name: "pub2"}}, - }, - { - name: "nested-content", - givenSite: openrtb2.Site{Page: "test.com/page", Content: &openrtb2.Content{Title: "content1"}}, - givenJson: []byte(`{"content":{"title": "content2"}}`), - expectedSite: openrtb2.Site{Page: "test.com/page", Content: &openrtb2.Content{Title: "content2"}}, - }, - { - name: "nested-content-producer", - givenSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Title: "content1", Producer: &openrtb2.Producer{Name: "producer1"}}}, - givenJson: []byte(`{"content":{"title": "content2", "producer":{"name":"producer2"}}}`), - expectedSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Title: "content2", Producer: &openrtb2.Producer{Name: "producer2"}}}, - }, - { - name: "nested-content-network", - givenSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Title: "content1", Network: &openrtb2.Network{Name: "network1"}}}, - givenJson: []byte(`{"content":{"title": "content2", "network":{"name":"network2"}}}`), - expectedSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Title: "content2", Network: &openrtb2.Network{Name: "network2"}}}, - }, - { - name: "nested-content-channel", - givenSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Title: "content1", Channel: &openrtb2.Channel{Name: "channel1"}}}, - givenJson: []byte(`{"content":{"title": "content2", "channel":{"name":"channel2"}}}`), - expectedSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Title: "content2", Channel: &openrtb2.Channel{Name: "channel2"}}}, - }, - { - name: "nested-publisher-ext", - givenSite: openrtb2.Site{ID: "1", Publisher: &openrtb2.Publisher{Ext: []byte(`{"a":1,"b":2}`)}}, - givenJson: []byte(`{"publisher":{"ext":{"b":100,"c":3}}}`), - expectedSite: openrtb2.Site{ID: "1", Publisher: &openrtb2.Publisher{Ext: []byte(`{"a":1,"b":100,"c":3}`)}}, - }, - { - name: "nested-content-ext", - givenSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Ext: []byte(`{"a":1,"b":2}`)}}, - givenJson: []byte(`{"content":{"ext":{"b":100,"c":3}}}`), - expectedSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Ext: []byte(`{"a":1,"b":100,"c":3}`)}}, - }, - { - name: "nested-content-producer-ext", - givenSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`{"a":1,"b":2}`)}}}, - givenJson: []byte(`{"content":{"producer":{"ext":{"b":100,"c":3}}}}`), - expectedSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`{"a":1,"b":100,"c":3}`)}}}, - }, - { - name: "nested-content-network-ext", - givenSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`{"a":1,"b":2}`)}}}, - givenJson: []byte(`{"content":{"network":{"ext":{"b":100,"c":3}}}}`), - expectedSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`{"a":1,"b":100,"c":3}`)}}}, - }, - { - name: "nested-content-channel-ext", - givenSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`{"a":1,"b":2}`)}}}, - givenJson: []byte(`{"content":{"channel":{"ext":{"b":100,"c":3}}}}`), - expectedSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`{"a":1,"b":100,"c":3}`)}}}, - }, - { - name: "toplevel-ext-and-nested-publisher-ext", - givenSite: openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":2}`), Publisher: &openrtb2.Publisher{Ext: []byte(`{"a":10,"b":20}`)}}, - givenJson: []byte(`{"ext":{"b":100,"c":3}, "publisher":{"ext":{"b":100,"c":3}}}`), - expectedSite: openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":100,"c":3}`), Publisher: &openrtb2.Publisher{Ext: []byte(`{"a":10,"b":100,"c":3}`)}}, - }, - { - name: "toplevel-ext-and-nested-content-ext", - givenSite: openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":2}`), Content: &openrtb2.Content{Ext: []byte(`{"a":10,"b":20}`)}}, - givenJson: []byte(`{"ext":{"b":100,"c":3}, "content":{"ext":{"b":100,"c":3}}}`), - expectedSite: openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":100,"c":3}`), Content: &openrtb2.Content{Ext: []byte(`{"a":10,"b":100,"c":3}`)}}, - }, - { - name: "toplevel-ext-and-nested-content-producer-ext", - givenSite: openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":2}`), Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`{"a":10,"b":20}`)}}}, - givenJson: []byte(`{"ext":{"b":100,"c":3}, "content":{"producer": {"ext":{"b":100,"c":3}}}}`), - expectedSite: openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":100,"c":3}`), Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`{"a":10,"b":100,"c":3}`)}}}, - }, - { - name: "toplevel-ext-and-nested-content-network-ext", - givenSite: openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":2}`), Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`{"a":10,"b":20}`)}}}, - givenJson: []byte(`{"ext":{"b":100,"c":3}, "content":{"network": {"ext":{"b":100,"c":3}}}}`), - expectedSite: openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":100,"c":3}`), Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`{"a":10,"b":100,"c":3}`)}}}, - }, - { - name: "toplevel-ext-and-nested-content-channel-ext", - givenSite: openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":2}`), Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`{"a":10,"b":20}`)}}}, - givenJson: []byte(`{"ext":{"b":100,"c":3}, "content":{"channel": {"ext":{"b":100,"c":3}}}}`), - expectedSite: openrtb2.Site{ID: "1", Ext: []byte(`{"a":1,"b":100,"c":3}`), Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`{"a":10,"b":100,"c":3}`)}}}, - }, - { - name: "nested-publisher-ext-err", - givenSite: openrtb2.Site{ID: "1", Publisher: &openrtb2.Publisher{Ext: []byte(`malformed`)}}, - givenJson: []byte(`{"publisher":{"ext":{"b":100,"c":3}}}`), - expectError: true, - }, - { - name: "nested-content-ext-err", - givenSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Ext: []byte(`malformed`)}}, - givenJson: []byte(`{"content":{"ext":{"b":100,"c":3}}}`), - expectError: true, - }, - { - name: "nested-content-producer-ext-err", - givenSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Producer: &openrtb2.Producer{Ext: []byte(`malformed`)}}}, - givenJson: []byte(`{"content":{"producer": {"ext":{"b":100,"c":3}}}}`), - expectError: true, - }, - { - name: "nested-content-network-ext-err", - givenSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Network: &openrtb2.Network{Ext: []byte(`malformed`)}}}, - givenJson: []byte(`{"content":{"network": {"ext":{"b":100,"c":3}}}}`), - expectError: true, - }, - { - name: "nested-content-channel-ext-err", - givenSite: openrtb2.Site{ID: "1", Content: &openrtb2.Content{Channel: &openrtb2.Channel{Ext: []byte(`malformed`)}}}, - givenJson: []byte(`{"content":{"channelx": {"ext":{"b":100,"c":3}}}}`), - expectError: true, - }, - { - name: "json-err", - givenSite: openrtb2.Site{ID: "1", Ext: []byte(`{"a":1}`)}, - givenJson: []byte(`malformed`), - expectError: true, - }, - } - - for _, test := range testCases { - t.Run(test.name, func(t *testing.T) { - originalSite := ortb.CloneSite(&test.givenSite) - merged, err := Site(&test.givenSite, test.givenJson, "BidderA") - - assert.Equal(t, &test.givenSite, originalSite) - - if test.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, &test.expectedSite, merged, " result Site is incorrect") - } - }) - } -} - -// TestSiteObjectStructure detects when new nested objects are added to the Site object, -// as these will create a gap in the merge.Site logic. If this test fails, fix merge.Site -// to add support and update this test to set a new baseline. -func TestSiteObjectStructure(t *testing.T) { - knownNestedStructs := []string{ - "Publisher", - "Content", - "Content.Producer", - "Content.Network", - "Content.Channel", - } - - discoveredNestedStructs := []string{} - - var discover func(parent string, t reflect.Type) - discover = func(parent string, t reflect.Type) { - fields := reflect.VisibleFields(t) - for _, field := range fields { - if field.Type.Kind() == reflect.Pointer && field.Type.Elem().Kind() == reflect.Struct { - discoveredNestedStructs = append(discoveredNestedStructs, parent+field.Name) - discover(parent+field.Name+".", field.Type.Elem()) - } - } - } - discover("", reflect.TypeOf(openrtb2.Site{})) - - assert.ElementsMatch(t, knownNestedStructs, discoveredNestedStructs) -} diff --git a/ortb/merge/user.go b/ortb/merge/user.go deleted file mode 100644 index e2834500846..00000000000 --- a/ortb/merge/user.go +++ /dev/null @@ -1,37 +0,0 @@ -package merge - -import ( - "encoding/json" - - "github.com/prebid/openrtb/v20/openrtb2" - "github.com/prebid/prebid-server/v2/ortb" - "github.com/prebid/prebid-server/v2/util/jsonutil" -) - -func User(v *openrtb2.User, overrideJSON json.RawMessage) (*openrtb2.User, error) { - c := ortb.CloneUser(v) - - // Track EXTs - // It's not necessary to track `ext` fields in array items because the array - // items will be replaced entirely with the override JSON, so no merge is required. - var ext, extGeo extMerger - ext.Track(&c.Ext) - if c.Geo != nil { - extGeo.Track(&c.Geo.Ext) - } - - // Merge - if err := jsonutil.Unmarshal(overrideJSON, &c); err != nil { - return nil, err - } - - // Merge EXTs - if err := ext.Merge(); err != nil { - return nil, err - } - if err := extGeo.Merge(); err != nil { - return nil, err - } - - return c, nil -} diff --git a/ortb/merge/user_test.go b/ortb/merge/user_test.go deleted file mode 100644 index 3dd7ddf2dcc..00000000000 --- a/ortb/merge/user_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package merge - -import ( - "encoding/json" - "reflect" - "testing" - - "github.com/prebid/openrtb/v20/openrtb2" - "github.com/prebid/prebid-server/v2/ortb" - "github.com/prebid/prebid-server/v2/util/ptrutil" - "github.com/stretchr/testify/assert" -) - -func TestUser(t *testing.T) { - testCases := []struct { - name string - givenUser openrtb2.User - givenJson json.RawMessage - expectedUser openrtb2.User - expectError bool - }{ - { - name: "empty", - givenUser: openrtb2.User{}, - givenJson: []byte(`{}`), - expectedUser: openrtb2.User{}, - }, - { - name: "toplevel", - givenUser: openrtb2.User{ID: "1"}, - givenJson: []byte(`{"id":"2"}`), - expectedUser: openrtb2.User{ID: "2"}, - }, - { - name: "toplevel-ext", - givenUser: openrtb2.User{Ext: []byte(`{"a":1,"b":2}`)}, - givenJson: []byte(`{"ext":{"b":100,"c":3}}`), - expectedUser: openrtb2.User{Ext: []byte(`{"a":1,"b":100,"c":3}`)}, - }, - { - name: "toplevel-ext-err", - givenUser: openrtb2.User{ID: "1", Ext: []byte(`malformed`)}, - givenJson: []byte(`{"id":"2"}`), - expectError: true, - }, - { - name: "nested-geo", - givenUser: openrtb2.User{Geo: &openrtb2.Geo{Lat: ptrutil.ToPtr(1.0)}}, - givenJson: []byte(`{"geo":{"lat": 2}}`), - expectedUser: openrtb2.User{Geo: &openrtb2.Geo{Lat: ptrutil.ToPtr(2.0)}}, - }, - { - name: "nested-geo-ext", - givenUser: openrtb2.User{Geo: &openrtb2.Geo{Ext: []byte(`{"a":1,"b":2}`)}}, - givenJson: []byte(`{"geo":{"ext":{"b":100,"c":3}}}`), - expectedUser: openrtb2.User{Geo: &openrtb2.Geo{Ext: []byte(`{"a":1,"b":100,"c":3}`)}}, - }, - { - name: "toplevel-ext-and-nested-geo-ext", - givenUser: openrtb2.User{Ext: []byte(`{"a":1,"b":2}`), Geo: &openrtb2.Geo{Ext: []byte(`{"a":10,"b":20}`)}}, - givenJson: []byte(`{"ext":{"b":100,"c":3}, "geo":{"ext":{"b":100,"c":3}}}`), - expectedUser: openrtb2.User{Ext: []byte(`{"a":1,"b":100,"c":3}`), Geo: &openrtb2.Geo{Ext: []byte(`{"a":10,"b":100,"c":3}`)}}, - }, - { - name: "nested-geo-ext-err", - givenUser: openrtb2.User{Geo: &openrtb2.Geo{Ext: []byte(`malformed`)}}, - givenJson: []byte(`{"geo":{"ext":{"b":100,"c":3}}}`), - expectError: true, - }, - { - name: "json-err", - givenUser: openrtb2.User{ID: "1", Ext: []byte(`{"a":1}`)}, - givenJson: []byte(`malformed`), - expectError: true, - }, - } - - for _, test := range testCases { - t.Run(test.name, func(t *testing.T) { - originalUser := ortb.CloneUser(&test.givenUser) - merged, err := User(&test.givenUser, test.givenJson) - - assert.Equal(t, &test.givenUser, originalUser) - - if test.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, &test.expectedUser, merged, "result user is incorrect") - } - }) - } -} - -// TestUserObjectStructure detects when new nested objects are added to the User object, -// as these will create a gap in the merge.User logic. If this test fails, fix merge.User -// to add support and update this test to set a new baseline. -func TestUserObjectStructure(t *testing.T) { - knownNestedStructs := []string{ - "Geo", - } - - discoveredNestedStructs := []string{} - - var discover func(parent string, t reflect.Type) - discover = func(parent string, t reflect.Type) { - fields := reflect.VisibleFields(t) - for _, field := range fields { - if field.Type.Kind() == reflect.Pointer && field.Type.Elem().Kind() == reflect.Struct { - discoveredNestedStructs = append(discoveredNestedStructs, parent+field.Name) - discover(parent+field.Name+".", field.Type.Elem()) - } - } - } - discover("", reflect.TypeOf(openrtb2.User{})) - - assert.ElementsMatch(t, knownNestedStructs, discoveredNestedStructs) -} diff --git a/util/jsonutil/jsonutil.go b/util/jsonutil/jsonutil.go index 695ccd8a5c1..1aed24bc8a5 100644 --- a/util/jsonutil/jsonutil.go +++ b/util/jsonutil/jsonutil.go @@ -228,7 +228,7 @@ func (e *RawMessageExtension) CreateEncoder(typ reflect2.Type) jsoniter.ValEncod return nil } -var jsonRawMessageType = reflect2.TypeOfPtr(&json.RawMessage{}).Elem() +var jsonRawMessageType = reflect2.TypeOfPtr((*json.RawMessage)(nil)).Elem() // rawMessageCodec implements jsoniter.ValEncoder interface so we can override the default json.RawMessage Encode() // function with our implementation diff --git a/util/jsonutil/merge.go b/util/jsonutil/merge.go new file mode 100644 index 00000000000..a104cf9506f --- /dev/null +++ b/util/jsonutil/merge.go @@ -0,0 +1,183 @@ +package jsonutil + +import ( + "encoding/json" + "errors" + "reflect" + "unsafe" + + jsoniter "github.com/json-iterator/go" + "github.com/modern-go/reflect2" + jsonpatch "gopkg.in/evanphx/json-patch.v4" + + "github.com/prebid/prebid-server/v2/errortypes" + "github.com/prebid/prebid-server/v2/util/reflectutil" +) + +// jsonConfigMergeClone uses the same configuration as the `ConfigCompatibleWithStandardLibrary` profile +// with extensions added to support the merge clone behavior. +var jsonConfigMergeClone = jsoniter.Config{ + EscapeHTML: true, + SortMapKeys: true, + ValidateJsonRawMessage: true, +}.Froze() + +func init() { + jsonConfigMergeClone.RegisterExtension(&mergeCloneExtension{}) +} + +// MergeClone unmarshals json data on top of an existing object and clones pointers of +// the existing object before setting new values. Slices and maps are also cloned. +// Fields of type json.RawMessage are merged rather than replaced. +func MergeClone(v any, data json.RawMessage) error { + err := jsonConfigMergeClone.Unmarshal(data, v) + if err != nil { + return &errortypes.FailedToUnmarshal{ + Message: tryExtractErrorMessage(err), + } + } + return err +} + +type mergeCloneExtension struct { + jsoniter.DummyExtension +} + +func (e *mergeCloneExtension) CreateDecoder(typ reflect2.Type) jsoniter.ValDecoder { + if typ == jsonRawMessageType { + return &extMergeDecoder{sliceType: typ.(*reflect2.UnsafeSliceType)} + } + return nil +} + +func (e *mergeCloneExtension) DecorateDecoder(typ reflect2.Type, decoder jsoniter.ValDecoder) jsoniter.ValDecoder { + if typ.Kind() == reflect.Ptr { + ptrType := typ.(*reflect2.UnsafePtrType) + return &ptrCloneDecoder{valueDecoder: decoder, elemType: ptrType.Elem()} + } + + // don't use json.RawMessage on fields handled by extMergeDecoder + if typ.Kind() == reflect.Slice && typ != jsonRawMessageType { + return &sliceCloneDecoder{valueDecoder: decoder, sliceType: typ.(*reflect2.UnsafeSliceType)} + } + + if typ.Kind() == reflect.Map { + return &mapCloneDecoder{valueDecoder: decoder, mapType: typ.(*reflect2.UnsafeMapType)} + } + + return decoder +} + +type ptrCloneDecoder struct { + elemType reflect2.Type + valueDecoder jsoniter.ValDecoder +} + +func (d *ptrCloneDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + // don't clone if field is being set to nil. checking for nil "consumes" the null + // token, so must be handled in this decoder. + if iter.ReadNil() { + *((*unsafe.Pointer)(ptr)) = nil + return + } + + // clone if there is an existing object. creation of new objects is handled by the + // original decoder. + if *((*unsafe.Pointer)(ptr)) != nil { + obj := d.elemType.UnsafeNew() + d.elemType.UnsafeSet(obj, *((*unsafe.Pointer)(ptr))) + *((*unsafe.Pointer)(ptr)) = obj + } + + d.valueDecoder.Decode(ptr, iter) +} + +type sliceCloneDecoder struct { + sliceType *reflect2.UnsafeSliceType + valueDecoder jsoniter.ValDecoder +} + +func (d *sliceCloneDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + // don't clone if field is being set to nil. checking for nil "consumes" the null + // token, so must be handled in this decoder. + if iter.ReadNil() { + d.sliceType.UnsafeSetNil(ptr) + return + } + + // clone if there is an existing object. creation of new objects is handled by the + // original decoder. + if !d.sliceType.UnsafeIsNil(ptr) { + clone := reflectutil.UnsafeSliceClone(ptr, d.sliceType) + d.sliceType.UnsafeSet(ptr, clone) + } + + d.valueDecoder.Decode(ptr, iter) +} + +type mapCloneDecoder struct { + mapType *reflect2.UnsafeMapType + valueDecoder jsoniter.ValDecoder +} + +func (d *mapCloneDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + // don't clone if field is being set to nil. checking for nil "consumes" the null + // token, so must be handled in this decoder. + if iter.ReadNil() { + *(*unsafe.Pointer)(ptr) = nil + d.mapType.UnsafeSet(ptr, d.mapType.UnsafeNew()) + return + } + + // clone if there is an existing object. creation of new objects is handled by the + // original decoder. + if !d.mapType.UnsafeIsNil(ptr) { + clone := d.mapType.UnsafeMakeMap(0) + mapIter := d.mapType.UnsafeIterate(ptr) + for mapIter.HasNext() { + key, elem := mapIter.UnsafeNext() + d.mapType.UnsafeSetIndex(clone, key, elem) + } + d.mapType.UnsafeSet(ptr, clone) + } + + d.valueDecoder.Decode(ptr, iter) +} + +type extMergeDecoder struct { + sliceType *reflect2.UnsafeSliceType +} + +func (d *extMergeDecoder) Decode(ptr unsafe.Pointer, iter *jsoniter.Iterator) { + // incoming nil value, keep existing + if iter.ReadNil() { + return + } + + existing := *((*json.RawMessage)(ptr)) + incoming := iter.SkipAndReturnBytes() + + // check for read errors to avoid calling jsonpatch.MergePatch on bad data. + if iter.Error != nil { + return + } + + // existing empty value, use incoming + if len(existing) == 0 { + *((*json.RawMessage)(ptr)) = incoming + return + } + + // non-empty incoming and existing values, merge + merged, err := jsonpatch.MergePatch(existing, incoming) + if err != nil { + if errors.Is(err, jsonpatch.ErrBadJSONDoc) { + iter.ReportError("merge", "invalid json on existing object") + } else { + iter.ReportError("merge", err.Error()) + } + return + } + + *((*json.RawMessage)(ptr)) = merged +} diff --git a/util/jsonutil/merge_test.go b/util/jsonutil/merge_test.go new file mode 100644 index 00000000000..8c85d6235c1 --- /dev/null +++ b/util/jsonutil/merge_test.go @@ -0,0 +1,455 @@ +package jsonutil + +import ( + "encoding/json" + "testing" + + "github.com/prebid/prebid-server/v2/util/sliceutil" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMergeClonePtr(t *testing.T) { + t.Run("root", func(t *testing.T) { + var ( + banner = &openrtb2.Banner{ID: "1"} + imp = &openrtb2.Imp{Banner: banner} + impOriginal = imp + ) + + // root objects are not cloned + err := MergeClone(imp, []byte(`{"banner":{"id":"4"}}`)) + require.NoError(t, err) + + assert.Same(t, impOriginal, imp, "imp-ref") + assert.NotSame(t, imp.Banner, banner, "banner-ref") + }) + + t.Run("embedded-nil", func(t *testing.T) { + var ( + banner = &openrtb2.Banner{ID: "1"} + video = &openrtb2.Video{PodID: "a"} + imp = &openrtb2.Imp{Banner: banner, Video: video} + ) + + err := MergeClone(imp, []byte(`{"banner":null}`)) + require.NoError(t, err) + + assert.NotSame(t, banner, imp.Banner, "banner-ref") + assert.Same(t, video, imp.Video, "video") + assert.Nil(t, imp.Banner, "banner-nil") + }) + + t.Run("embedded-struct", func(t *testing.T) { + var ( + banner = &openrtb2.Banner{ID: "1"} + video = &openrtb2.Video{PodID: "a"} + imp = &openrtb2.Imp{Banner: banner, Video: video} + ) + + err := MergeClone(imp, []byte(`{"banner":{"id":"2"}}`)) + require.NoError(t, err) + + assert.NotSame(t, banner, imp.Banner, "banner-ref") + assert.Same(t, video, imp.Video, "video-ref") + assert.Equal(t, "1", banner.ID, "id-original") + assert.Equal(t, "2", imp.Banner.ID, "id-clone") + }) + + t.Run("embedded-int", func(t *testing.T) { + var ( + clickbrowser = int8(1) + imp = &openrtb2.Imp{ClickBrowser: &clickbrowser} + ) + + err := MergeClone(imp, []byte(`{"clickbrowser":2}`)) + require.NoError(t, err) + + require.NotNil(t, imp.ClickBrowser, "clickbrowser-nil") + assert.NotSame(t, clickbrowser, imp.ClickBrowser, "clickbrowser-ref") + assert.Equal(t, int8(2), *imp.ClickBrowser, "clickbrowser-val") + }) + + t.Run("invalid-null", func(t *testing.T) { + var ( + banner = &openrtb2.Banner{ID: "1"} + imp = &openrtb2.Imp{Banner: banner} + ) + + err := MergeClone(imp, []byte(`{"banner":nul}`)) + + // json-iter will produce an error since "nul" is not a valid json value. the + // parsing code will see the "n" and then expect "ull" to follow. the strange + // "expect ull" error being asserted is generated by json-iter. + require.EqualError(t, err, "cannot unmarshal openrtb2.Imp.Banner: expect ull") + }) + + t.Run("invalid-malformed", func(t *testing.T) { + var ( + banner = &openrtb2.Banner{ID: "1"} + imp = &openrtb2.Imp{Banner: banner} + ) + + err := MergeClone(imp, []byte(`{"banner":malformed}`)) + require.EqualError(t, err, "cannot unmarshal openrtb2.Imp.Banner: expect { or n, but found m") + }) +} + +func TestMergeCloneSlice(t *testing.T) { + t.Run("null", func(t *testing.T) { + var ( + iframeBuster = []string{"a", "b"} + imp = &openrtb2.Imp{IframeBuster: iframeBuster} + ) + + err := MergeClone(imp, []byte(`{"iframeBuster":null}`)) + require.NoError(t, err) + + assert.Equal(t, []string{"a", "b"}, iframeBuster, "iframeBuster-val") + assert.Nil(t, imp.IframeBuster, "iframeBuster-nil") + }) + + t.Run("one", func(t *testing.T) { + var ( + iframeBuster = []string{"a"} + imp = &openrtb2.Imp{IframeBuster: iframeBuster} + ) + + err := MergeClone(imp, []byte(`{"iframeBuster":["b"]}`)) + require.NoError(t, err) + + assert.NotSame(t, iframeBuster, imp.IframeBuster, "ref") + assert.Equal(t, []string{"a"}, iframeBuster, "original-val") + assert.Equal(t, []string{"b"}, imp.IframeBuster, "new-val") + }) + + t.Run("many", func(t *testing.T) { + var ( + iframeBuster = []string{"a"} + imp = &openrtb2.Imp{IframeBuster: iframeBuster} + ) + + err := MergeClone(imp, []byte(`{"iframeBuster":["b", "c"]}`)) + require.NoError(t, err) + + assert.NotSame(t, iframeBuster, imp.IframeBuster, "ref") + assert.Equal(t, []string{"a"}, iframeBuster, "original-val") + assert.Equal(t, []string{"b", "c"}, imp.IframeBuster, "new-val") + }) + + t.Run("invalid-null", func(t *testing.T) { + var ( + iframeBuster = []string{"a"} + imp = &openrtb2.Imp{IframeBuster: iframeBuster} + ) + + err := MergeClone(imp, []byte(`{"iframeBuster":nul}`)) + + // json-iter will produce an error since "nul" is not a valid json value. the + // parsing code will see the "n" and then expect "ull" to follow. the strange + // "expect ull" error being asserted is generated by json-iter. + require.EqualError(t, err, "cannot unmarshal openrtb2.Imp.IframeBuster: expect ull") + }) + + t.Run("invalid-malformed", func(t *testing.T) { + var ( + iframeBuster = []string{"a"} + imp = &openrtb2.Imp{IframeBuster: iframeBuster} + ) + + err := MergeClone(imp, []byte(`{"iframeBuster":malformed}`)) + require.EqualError(t, err, "cannot unmarshal openrtb2.Imp.IframeBuster: decode slice: expect [ or n, but found m") + }) +} + +func TestMergeCloneMap(t *testing.T) { + t.Run("null", func(t *testing.T) { + var ( + testMap = map[string]int{"a": 1, "b": 2} + test = &struct { + Foo map[string]int `json:"foo"` + }{Foo: testMap} + ) + + err := MergeClone(test, []byte(`{"foo":null}`)) + require.NoError(t, err) + + assert.NotSame(t, testMap, test.Foo, "ref") + assert.Equal(t, map[string]int{"a": 1, "b": 2}, testMap, "val") + assert.Nil(t, test.Foo, "nil") + }) + + t.Run("key-string", func(t *testing.T) { + var ( + testMap = map[string]int{"a": 1, "b": 2} + test = &struct { + Foo map[string]int `json:"foo"` + }{Foo: testMap} + ) + + err := MergeClone(test, []byte(`{"foo":{"c":3}}`)) + require.NoError(t, err) + + assert.NotSame(t, testMap, test.Foo) + assert.Equal(t, map[string]int{"a": 1, "b": 2}, testMap, "original-val") + assert.Equal(t, map[string]int{"a": 1, "b": 2, "c": 3}, test.Foo, "new-val") + + // verify modifications don't corrupt original + testMap["a"] = 10 + assert.Equal(t, map[string]int{"a": 10, "b": 2}, testMap, "mod-original-val") + assert.Equal(t, map[string]int{"a": 1, "b": 2, "c": 3}, test.Foo, "mod-ew-val") + }) + + t.Run("key-numeric", func(t *testing.T) { + var ( + testMap = map[int]string{1: "a", 2: "b"} + test = &struct { + Foo map[int]string `json:"foo"` + }{Foo: testMap} + ) + + err := MergeClone(test, []byte(`{"foo":{"3":"c"}}`)) + require.NoError(t, err) + + assert.NotSame(t, testMap, test.Foo) + assert.Equal(t, map[int]string{1: "a", 2: "b"}, testMap, "original-val") + assert.Equal(t, map[int]string{1: "a", 2: "b", 3: "c"}, test.Foo, "new-val") + + // verify modifications don't corrupt original + testMap[1] = "z" + assert.Equal(t, map[int]string{1: "z", 2: "b"}, testMap, "mod-original-val") + assert.Equal(t, map[int]string{1: "a", 2: "b", 3: "c"}, test.Foo, "mod-ew-val") + }) + + t.Run("invalid-null", func(t *testing.T) { + var ( + testMap = map[int]string{1: "a", 2: "b"} + test = &struct { + Foo map[int]string `json:"foo"` + }{Foo: testMap} + ) + + err := MergeClone(test, []byte(`{"foo":nul}`)) + + // json-iter will produce an error since "nul" is not a valid json value. the + // parsing code will see the "n" and then expect "ull" to follow. the strange + // "expect ull" error being asserted is generated by json-iter. + require.EqualError(t, err, "cannot unmarshal Foo: expect ull") + }) + + t.Run("invalid-malformed", func(t *testing.T) { + var ( + testMap = map[int]string{1: "a", 2: "b"} + test = &struct { + Foo map[int]string `json:"foo"` + }{Foo: testMap} + ) + + err := MergeClone(test, []byte(`{"foo":malformed}`)) + require.EqualError(t, err, "cannot unmarshal Foo: expect { or n, but found m") + }) +} + +func TestMergeCloneExt(t *testing.T) { + testCases := []struct { + name string + givenExisting json.RawMessage + givenIncoming json.RawMessage + expectedExt json.RawMessage + expectedErr string + }{ + { + name: "both-populated", + givenExisting: json.RawMessage(`{"a":1,"b":2}`), + givenIncoming: json.RawMessage(`{"b":200,"c":3}`), + expectedExt: json.RawMessage(`{"a":1,"b":200,"c":3}`), + }, + { + name: "both-omitted", + givenExisting: nil, + givenIncoming: nil, + expectedExt: nil, + }, + { + name: "both-nil", + givenExisting: nil, + givenIncoming: json.RawMessage(`null`), + expectedExt: nil, + }, + { + name: "both-empty", + givenExisting: nil, + givenIncoming: json.RawMessage(`{}`), + expectedExt: json.RawMessage(`{}`), + }, + { + name: "ext-omitted", + givenExisting: json.RawMessage(`{"b":2}`), + givenIncoming: nil, + expectedExt: json.RawMessage(`{"b":2}`), + }, + { + name: "ext-nil", + givenExisting: json.RawMessage(`{"b":2}`), + givenIncoming: json.RawMessage(`null`), + expectedExt: json.RawMessage(`{"b":2}`), + }, + { + name: "ext-empty", + givenExisting: json.RawMessage(`{"b":2}`), + givenIncoming: json.RawMessage(`{}`), + expectedExt: json.RawMessage(`{"b":2}`), + }, + { + name: "ext-malformed", + givenExisting: json.RawMessage(`{"b":2}`), + givenIncoming: json.RawMessage(`malformed`), + expectedErr: "openrtb2.BidRequest.Ext", + }, + { + name: "existing-nil", + givenExisting: nil, + givenIncoming: json.RawMessage(`{"a":1}`), + expectedExt: json.RawMessage(`{"a":1}`), + }, + { + name: "existing-empty", + givenExisting: json.RawMessage(`{}`), + givenIncoming: json.RawMessage(`{"a":1}`), + expectedExt: json.RawMessage(`{"a":1}`), + }, + { + name: "existing-omitted", + givenExisting: nil, + givenIncoming: json.RawMessage(`{"b":2}`), + expectedExt: json.RawMessage(`{"b":2}`), + }, + { + name: "existing-malformed", + givenExisting: json.RawMessage(`malformed`), + givenIncoming: json.RawMessage(`{"a":1}`), + expectedErr: "cannot unmarshal openrtb2.BidRequest.Ext: invalid json on existing object", + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + // copy original values to check at the end for no modification + originalExisting := sliceutil.Clone(test.givenExisting) + originalIncoming := sliceutil.Clone(test.givenIncoming) + + // build request + request := &openrtb2.BidRequest{Ext: test.givenExisting} + + // build data + data := test.givenIncoming + if len(data) > 0 { + data = []byte(`{"ext":` + string(data) + `}`) // wrap in ext + } else { + data = []byte(`{}`) // omit ext + } + + err := MergeClone(request, data) + + // assert error + if test.expectedErr == "" { + assert.NoError(t, err, "err") + } else { + assert.ErrorContains(t, err, test.expectedErr, "err") + } + + // assert ext + if test.expectedErr != "" { + // expect no change in case of error + assert.Equal(t, string(test.givenExisting), string(request.Ext), "json") + } else { + // compare as strings instead of json in case of nil or malformed ext + assert.Equal(t, string(test.expectedExt), string(request.Ext), "json") + } + + // assert no modifications + // - can't use `assert.Same`` comparison checks since that's expected if + // either existing or incoming are nil / omitted / empty. + assert.Equal(t, originalExisting, []byte(test.givenExisting), "existing") + assert.Equal(t, originalIncoming, []byte(test.givenIncoming), "incoming") + }) + } +} + +func TestMergeCloneCombinations(t *testing.T) { + t.Run("slice-of-ptr", func(t *testing.T) { + var ( + imp = &openrtb2.Imp{ID: "1"} + impSlice = []*openrtb2.Imp{imp} + test = &struct { + Imps []*openrtb2.Imp `json:"imps"` + }{Imps: impSlice} + ) + + err := MergeClone(test, []byte(`{"imps":[{"id":"2"}]}`)) + require.NoError(t, err) + + assert.NotSame(t, impSlice, test.Imps, "slice-ref") + require.Len(t, test.Imps, 1, "slice-len") + + assert.NotSame(t, imp, test.Imps[0], "item-ref") + assert.Equal(t, "1", imp.ID, "original-val") + assert.Equal(t, "2", test.Imps[0].ID, "new-val") + }) + + // special case of "slice-of-ptr" + t.Run("jsonrawmessage-ptr", func(t *testing.T) { + var ( + testJson = json.RawMessage(`{"a":1}`) + test = &struct { + Foo *json.RawMessage `json:"foo"` + }{Foo: &testJson} + ) + + err := MergeClone(test, []byte(`{"foo":{"b":2}}`)) + require.NoError(t, err) + + assert.NotSame(t, &testJson, test.Foo, "ref") + assert.Equal(t, json.RawMessage(`{"a":1}`), testJson) + assert.Equal(t, json.RawMessage(`{"a":1,"b":2}`), *test.Foo) + }) + + t.Run("struct-ptr", func(t *testing.T) { + var ( + imp = &openrtb2.Imp{ID: "1"} + test = &struct { + Imp *openrtb2.Imp `json:"imp"` + }{Imp: imp} + ) + + err := MergeClone(test, []byte(`{"imp":{"id":"2"}}`)) + require.NoError(t, err) + + assert.NotSame(t, imp, test.Imp, "ref") + assert.Equal(t, "1", imp.ID, "original-val") + assert.Equal(t, "2", test.Imp.ID, "new-val") + }) + + t.Run("map-of-ptrs", func(t *testing.T) { + var ( + imp = &openrtb2.Imp{ID: "1"} + impMap = map[string]*openrtb2.Imp{"a": imp} + test = &struct { + Imps map[string]*openrtb2.Imp `json:"imps"` + }{Imps: impMap} + ) + + err := MergeClone(test, []byte(`{"imps":{"a":{"id":"2"}}}`)) + require.NoError(t, err) + + assert.NotSame(t, impMap, test.Imps, "map-ref") + assert.NotSame(t, imp, test.Imps["a"], "imp-ref") + + assert.Same(t, impMap["a"], imp, "imp-map-ref") + + assert.Equal(t, "1", imp.ID, "original-val") + assert.Equal(t, "2", test.Imps["a"].ID, "new-val") + }) +} diff --git a/util/maputil/maputil.go b/util/maputil/maputil.go index c33740d456c..19224801a14 100644 --- a/util/maputil/maputil.go +++ b/util/maputil/maputil.go @@ -49,11 +49,12 @@ func HasElement(m map[string]interface{}, k ...string) bool { return exists } -// Clone creates an indepent copy of a map, +// Clone creates an independent copy of a map, func Clone[K comparable, V any](m map[K]V) map[K]V { if m == nil { return nil } + clone := make(map[K]V, len(m)) for key, value := range m { clone[key] = value diff --git a/util/reflectutil/slice.go b/util/reflectutil/slice.go new file mode 100644 index 00000000000..6da1aed2808 --- /dev/null +++ b/util/reflectutil/slice.go @@ -0,0 +1,49 @@ +package reflectutil + +import ( + "unsafe" + + "github.com/modern-go/reflect2" +) + +// UnsafeSliceClone clones an existing slice using unsafe.Pointer conventions. Intended +// for use by json iterator extensions and should likely be used no where else. Nil +// behavior is undefined as checks are expected upstream. +func UnsafeSliceClone(ptr unsafe.Pointer, sliceType reflect2.SliceType) unsafe.Pointer { + // it's also possible to use `sliceType.Elem().RType`, but that returns a `uintptr` + // which causes `go vet` to emit a warning even though the usage is safe. this approach + // of copying some internals from the reflect2 package avoids the cast of `uintptr` to + // `unsafe.Pointer` which keeps `go vet` output clean. + elemRType := unpackEFace(sliceType.Elem().Type1()).data + + header := (*sliceHeader)(ptr) + newHeader := (*sliceHeader)(sliceType.UnsafeMakeSlice(header.Len, header.Cap)) + typedslicecopy(elemRType, *newHeader, *header) + return unsafe.Pointer(newHeader) +} + +// sliceHeader is copied from the reflect2 package v1.0.2. +type sliceHeader struct { + Data unsafe.Pointer + Len int + Cap int +} + +// typedslicecopyis copied from the reflect2 package v1.0.2. +// it copies a slice of elemType values from src to dst, +// returning the number of elements copied. +// +//go:linkname typedslicecopy reflect.typedslicecopy +//go:noescape +func typedslicecopy(elemType unsafe.Pointer, dst, src sliceHeader) int + +// eface is copied from the reflect2 package v1.0.2. +type eface struct { + rtype unsafe.Pointer + data unsafe.Pointer +} + +// unpackEFace is copied from the reflect2 package v1.0.2. +func unpackEFace(obj interface{}) *eface { + return (*eface)(unsafe.Pointer(&obj)) +} diff --git a/util/reflectutil/slice_test.go b/util/reflectutil/slice_test.go new file mode 100644 index 00000000000..d5183892369 --- /dev/null +++ b/util/reflectutil/slice_test.go @@ -0,0 +1,78 @@ +package reflectutil + +import ( + "testing" + + "github.com/modern-go/reflect2" + "github.com/stretchr/testify/assert" +) + +func TestUnsafeSliceCloneSimple(t *testing.T) { + testCases := []struct { + name string + given []int + }{ + { + name: "empty", + given: []int{}, + }, + { + name: "one", + given: []int{1}, + }, + { + name: "many", + given: []int{1, 2}, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + original := test.given + clonePtr := UnsafeSliceClone(reflect2.PtrOf(test.given), reflect2.TypeOf([]int{}).(*reflect2.UnsafeSliceType)) + clone := *(*[]int)(clonePtr) + + assert.NotSame(t, original, clone, "reference") + assert.Equal(t, original, clone, "equality") + assert.Equal(t, len(original), len(clone), "len") + assert.Equal(t, cap(original), cap(clone), "cap") + }) + } +} + +func TestUnsafeSliceCloneComplex(t *testing.T) { + type foo struct { + value string + } + + testCases := []struct { + name string + given []foo + }{ + { + name: "empty", + given: []foo{}, + }, + { + name: "one", + given: []foo{{value: "a"}}, + }, + { + name: "many", + given: []foo{{value: "a"}, {value: "b"}}, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + original := test.given + clonePtr := UnsafeSliceClone(reflect2.PtrOf(test.given), reflect2.TypeOf([]foo{}).(*reflect2.UnsafeSliceType)) + clone := *(*[]foo)(clonePtr) + + assert.NotSame(t, original, clone, "reference") + assert.Equal(t, original, clone, "equality") + assert.Equal(t, len(original), len(clone), "len") + assert.Equal(t, cap(original), cap(clone), "cap") + }) + } +} diff --git a/util/sliceutil/clone.go b/util/sliceutil/clone.go index 2077a9336b2..64faea32a4e 100644 --- a/util/sliceutil/clone.go +++ b/util/sliceutil/clone.go @@ -5,7 +5,7 @@ func Clone[T any](s []T) []T { return nil } - c := make([]T, len(s)) + c := make([]T, len(s), cap(s)) copy(c, s) return c