Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions config/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type Account struct {
Privacy AccountPrivacy `mapstructure:"privacy" json:"privacy"`
PreferredMediaType openrtb_ext.PreferredMediaType `mapstructure:"preferredmediatype" json:"preferredmediatype"`
TargetingPrefix string `mapstructure:"targeting_prefix" json:"targeting_prefix"`
StoredRequest AccountStoredRequest `mapstructure:"stored_request" json:"stored_request"`
}

// CookieSync represents the account-level defaults for the cookie sync endpoint.
Expand Down Expand Up @@ -395,3 +396,18 @@ func (ip *IPv4) Validate(errs []error) []error {
}
return errs
}

// AccountStoredRequest represents account-specific stored request configuration
type AccountStoredRequest struct {
ArrayMerge ArrayMergeMode `mapstructure:"array_merge" json:"array_merge"`
}

// ArrayMergeMode defines how array fields are merged during stored request processing
// "replace" (default): Arrays are replaced, RFC 7386 behavior
// "concat": Arrays are concatenated
type ArrayMergeMode string

const (
ArrayMergeReplace ArrayMergeMode = "replace"
ArrayMergeConcat ArrayMergeMode = "concat"
)
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,7 @@ func SetupViper(v *viper.Viper, filename string, bidderInfos BidderInfos) {
v.SetDefault("account_defaults.privacy.privacysandbox.topicsdomain", "")
v.SetDefault("account_defaults.privacy.privacysandbox.cookiedeprecation.enabled", false)
v.SetDefault("account_defaults.privacy.privacysandbox.cookiedeprecation.ttl_sec", 604800)
v.SetDefault("account_defaults.stored_request.array_merge", "replace")

v.SetDefault("account_defaults.events_enabled", false)
v.BindEnv("account_defaults.privacy.dsa.default")
Expand Down
4 changes: 4 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ func TestDefaults(t *testing.T) {
cmpStrings(t, "account_defaults.privacy.topicsdomain", "", cfg.AccountDefaults.Privacy.PrivacySandbox.TopicsDomain)
cmpBools(t, "account_defaults.privacy.privacysandbox.cookiedeprecation.enabled", false, cfg.AccountDefaults.Privacy.PrivacySandbox.CookieDeprecation.Enabled)
cmpInts(t, "account_defaults.privacy.privacysandbox.cookiedeprecation.ttl_sec", 604800, cfg.AccountDefaults.Privacy.PrivacySandbox.CookieDeprecation.TTLSec)
cmpStrings(t, "account_defaults.stored_request.array_merge", "replace", string(cfg.AccountDefaults.StoredRequest.ArrayMerge))

cmpBools(t, "account_defaults.events.enabled", false, cfg.AccountDefaults.Events.Enabled)

Expand Down Expand Up @@ -579,6 +580,8 @@ account_defaults:
cookiedeprecation:
enabled: true
ttl_sec: 86400
stored_request:
array_merge: "replace"
tmax_adjustments:
enabled: true
bidder_response_duration_min_ms: 700
Expand Down Expand Up @@ -726,6 +729,7 @@ func TestFullConfig(t *testing.T) {
cmpInts(t, "account_defaults.price_floors.fetch.period_sec", 2000, cfg.AccountDefaults.PriceFloors.Fetcher.Period)
cmpInts(t, "account_defaults.price_floors.fetch.max_age_sec", 6000, cfg.AccountDefaults.PriceFloors.Fetcher.MaxAge)
cmpInts(t, "account_defaults.price_floors.fetch.max_schema_dims", 10, cfg.AccountDefaults.PriceFloors.Fetcher.MaxSchemaDims)
cmpStrings(t, "account_defaults.stored_request.array_merge", "replace", string(cfg.AccountDefaults.StoredRequest.ArrayMerge))

assert.Equal(t, RoundingModeUp, cfg.AccountDefaults.BidRounding)

Expand Down
81 changes: 76 additions & 5 deletions endpoints/openrtb2/auction.go
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ func (deps *endpointDeps) parseRequest(httpRequest *http.Request, labels *metric
}

// Fetch the Stored Request data and merge it into the HTTP request.
if requestJson, impExtInfoMap, errs = deps.processStoredRequests(requestJson, impInfo, storedRequests, storedImps, storedBidRequestId, hasStoredBidRequest); len(errs) > 0 {
if requestJson, impExtInfoMap, errs = deps.processStoredRequests(requestJson, impInfo, storedRequests, storedImps, storedBidRequestId, hasStoredBidRequest, account); len(errs) > 0 {
return
}

Expand Down Expand Up @@ -1689,7 +1689,69 @@ func (deps *endpointDeps) getStoredRequests(ctx context.Context, requestJson []b
return storedBidRequestId, hasStoredBidRequest, storedRequests, storedImps, errs
}

func (deps *endpointDeps) processStoredRequests(requestJson []byte, impInfo []ImpExtPrebidData, storedRequests map[string]json.RawMessage, storedImps map[string]json.RawMessage, storedBidRequestId string, hasStoredBidRequest bool) ([]byte, map[string]exchange.ImpExtInfo, []error) {
// mergeWithArrayConcat performs JSON merge with array concatenation for specific fields.
// For the specified arrayFields (e.g., "bcat", "badv"), arrays are concatenated
// All other fields follow RFC 7386 JSON Merge Patch semantics.
// Empty arrays inside the patch request will clear the base request arrays (preserving RFC 7386 semantics).
func mergeWithArrayConcat(base, patch []byte, arrayFields []string) ([]byte, error) {
if len(base) == 0 {
return patch, nil
}
if len(patch) == 0 {
return base, nil
}

// Parse both JSON documents
var baseObj, patchObj map[string]interface{}
if err := jsonutil.UnmarshalValid(base, &baseObj); err != nil {
return nil, fmt.Errorf("failed to unmarshal base request: %w", err)
}
if err := jsonutil.UnmarshalValid(patch, &patchObj); err != nil {
return nil, fmt.Errorf("failed to unmarshal patch request: %w", err)
}

// For each array field, concat if both exist and are non-empty arrays, otherwise preserve RFC 7386 semantics
for _, field := range arrayFields {
baseVal, ok := baseObj[field]
if !ok {
continue
}
patchVal, ok := patchObj[field]
if !ok {
continue
}

baseArr, ok := baseVal.([]interface{})
if !ok {
continue
}
patchArr, ok := patchVal.([]interface{})
if !ok {
continue
}

if len(patchArr) > 0 && len(baseArr) > 0 {
combined := make([]interface{}, len(baseArr)+len(patchArr))
copy(combined, baseArr)
copy(combined[len(baseArr):], patchArr)
patchObj[field] = combined
}
}

// Perform RFC 7386 merge on the modified objects
baseBytes, err := jsonutil.Marshal(baseObj)
if err != nil {
return nil, fmt.Errorf("failed to marshal base object: %w", err)
}
patchBytes, err := jsonutil.Marshal(patchObj)
if err != nil {
return nil, fmt.Errorf("failed to marshal patch object: %w", err)
}

return jsonpatch.MergePatch(baseBytes, patchBytes)
}

func (deps *endpointDeps) processStoredRequests(requestJson []byte, impInfo []ImpExtPrebidData, storedRequests map[string]json.RawMessage, storedImps map[string]json.RawMessage, storedBidRequestId string, hasStoredBidRequest bool, account *config.Account) ([]byte, map[string]exchange.ImpExtInfo, []error) {
bidRequestID, err := getBidRequestID(storedRequests[storedBidRequestId])
if err != nil {
return nil, nil, []error{err}
Expand All @@ -1698,6 +1760,14 @@ func (deps *endpointDeps) processStoredRequests(requestJson []byte, impInfo []Im
// Apply the Stored BidRequest, if it exists
resolvedRequest := requestJson

useConcatMode := account != nil && account.StoredRequest.ArrayMerge == config.ArrayMergeConcat
merge := func(base, patch []byte) ([]byte, error) {
if useConcatMode {
return mergeWithArrayConcat(base, patch, []string{"bcat", "badv"})
}
return jsonpatch.MergePatch(base, patch)
}

if hasStoredBidRequest {
isAppRequest, err := checkIfAppRequest(requestJson)
if err != nil {
Expand All @@ -1713,13 +1783,14 @@ func (deps *endpointDeps) processStoredRequests(requestJson []byte, impInfo []Im
errL := storedRequestErrorChecker(requestJson, storedRequests, storedBidRequestId)
return nil, nil, errL
}
resolvedRequest, err = jsonpatch.MergePatch(requestJson, uuidPatch)

resolvedRequest, err = merge(requestJson, uuidPatch)
if err != nil {
errL := storedRequestErrorChecker(requestJson, storedRequests, storedBidRequestId)
return nil, nil, errL
}
} else {
resolvedRequest, err = jsonpatch.MergePatch(storedRequests[storedBidRequestId], requestJson)
resolvedRequest, err = merge(storedRequests[storedBidRequestId], requestJson)
if err != nil {
errL := storedRequestErrorChecker(requestJson, storedRequests, storedBidRequestId)
return nil, nil, errL
Expand All @@ -1729,7 +1800,7 @@ func (deps *endpointDeps) processStoredRequests(requestJson []byte, impInfo []Im

// apply default stored request
if deps.defaultRequest {
merged, err := jsonpatch.MergePatch(deps.defReqJSON, resolvedRequest)
merged, err := merge(deps.defReqJSON, resolvedRequest)
if err != nil {
hasErr, Err := getJsonSyntaxError(resolvedRequest)
if hasErr {
Expand Down
Loading
Loading