From bf8ddf64ea18f9f77970aa3671ebc8dfd0a76e4c Mon Sep 17 00:00:00 2001 From: "mike.hoyt" Date: Mon, 11 May 2026 11:49:19 -0600 Subject: [PATCH] mho-OPATH-6512-Respond-With-OpenAds-Ext Rename ext.prebid on responses to be ext.openads when OpenAds was passed in on the request --- endpoints/openrtb2/auction.go | 55 ++++++++- endpoints/openrtb2/auction_test.go | 180 +++++++++++++++++++++++++++- openrtb_ext/request_wrapper.go | 18 ++- openrtb_ext/request_wrapper_test.go | 30 +++++ 4 files changed, 274 insertions(+), 9 deletions(-) diff --git a/endpoints/openrtb2/auction.go b/endpoints/openrtb2/auction.go index f6ac6dfd..23d65b88 100644 --- a/endpoints/openrtb2/auction.go +++ b/endpoints/openrtb2/auction.go @@ -300,7 +300,12 @@ func (deps *endpointDeps) Auction(w http.ResponseWriter, r *http.Request, _ http if err != nil { logger.Errorf("Error setting seat non-bid: %v", err) } - labels, ao = sendAuctionResponse(w, hookExecutor, response, req.BidRequest, account, labels, ao) + + useOpenAdsExtKey := false + if reqExt, err := req.GetRequestExt(); err == nil && reqExt != nil && reqExt.UseOpenAdsExtKey() { + useOpenAdsExtKey = true + } + labels, ao = sendAuctionResponse(w, hookExecutor, response, req.BidRequest, account, labels, ao, useOpenAdsExtKey) } // setSeatNonBidRaw is transitional function for setting SeatNonBid inside bidResponse.Ext @@ -347,7 +352,7 @@ func rejectAuctionRequest( ao.Response = response ao.Errors = append(ao.Errors, rejectErr) - return sendAuctionResponse(w, hookExecutor, response, request, account, labels, ao) + return sendAuctionResponse(w, hookExecutor, response, request, account, labels, ao, false) } func sendAuctionResponse( @@ -358,6 +363,7 @@ func sendAuctionResponse( account *config.Account, labels metrics.Labels, ao analytics.AuctionObject, + useOpenAdsExtKey bool, ) (metrics.Labels, analytics.AuctionObject) { hookExecutor.ExecuteAuctionResponseStage(response) @@ -388,6 +394,12 @@ func sendAuctionResponse( // Exitpoint will modify the response and set response headers according to hook implementation. finalResponse := hookExecutor.ExecuteExitpointStage(response, w) + if useOpenAdsExtKey { + if br, ok := finalResponse.(*openrtb2.BidResponse); ok { + rekeyResponseForOpenAds(br) + } + } + // If an error happens when encoding the response, there isn't much we can do. // If we've sent _any_ bytes, then Go would have sent the 200 status code first. // That status code can't be un-sent... so the best we can do is log the error. @@ -399,6 +411,45 @@ func sendAuctionResponse( return labels, ao } +func rekeyResponseForOpenAds(response *openrtb2.BidResponse) { + if response == nil { + return + } + + response.Ext = rekeyPrebidInJSON(response.Ext) + + for i := range response.SeatBid { + for j := range response.SeatBid[i].Bid { + response.SeatBid[i].Bid[j].Ext = rekeyPrebidInJSON(response.SeatBid[i].Bid[j].Ext) + } + } +} + +func rekeyPrebidInJSON(data json.RawMessage) json.RawMessage { + if len(data) == 0 { + return data + } + + var raw map[string]json.RawMessage + if err := jsonutil.Unmarshal(data, &raw); err != nil { + return data + } + + v, ok := raw[openrtb_ext.PrebidExtKey] + if !ok { + return data + } + + raw[openrtb_ext.OpenAdsExtKey] = v + delete(raw, openrtb_ext.PrebidExtKey) + + result, err := jsonutil.Marshal(raw) + if err != nil { + return data + } + return result +} + // setBrowsingTopicsHeader always set the Observe-Browsing-Topics header to a value of ?1 if the Sec-Browsing-Topics is present in request func setBrowsingTopicsHeader(w http.ResponseWriter, r *http.Request) { if value := r.Header.Get(secBrowsingTopics); value != "" { diff --git a/endpoints/openrtb2/auction_test.go b/endpoints/openrtb2/auction_test.go index 727b8dbe..6a39df64 100644 --- a/endpoints/openrtb2/auction_test.go +++ b/endpoints/openrtb2/auction_test.go @@ -5102,7 +5102,7 @@ func TestSendAuctionResponse_LogsErrors(t *testing.T) { ao := analytics.AuctionObject{} account := &config.Account{DebugAllow: true} - _, ao = sendAuctionResponse(writer, test.hookExecutor, test.response, test.request, account, labels, ao) + _, ao = sendAuctionResponse(writer, test.hookExecutor, test.response, test.request, account, labels, ao, false) assert.Equal(t, ao.Errors, test.expectedErrors, "Invalid errors.") assert.Equal(t, test.expectedStatus, ao.Status, "Invalid HTTP response status.") @@ -5110,6 +5110,184 @@ func TestSendAuctionResponse_LogsErrors(t *testing.T) { } } +func TestSendAuctionResponse_OpenAdsExtKey(t *testing.T) { + hookExecutor := &mockStageExecutor{} + + bidResponse := &openrtb2.BidResponse{ + ID: "test-response", + Ext: json.RawMessage(`{"prebid":{"auctiontimestamp":1715184000},"responsetimemillis":{"ttd":42}}`), + SeatBid: []openrtb2.SeatBid{ + { + Seat: "ttd", + Bid: []openrtb2.Bid{ + { + ID: "bid-1", + Ext: json.RawMessage(`{"prebid":{"type":"banner","meta":{"adaptercode":"ttd"}},"origbidcpm":1.5}`), + }, + }, + }, + }, + } + + t.Run("ext.openads request produces openads response keys", func(t *testing.T) { + writer := httptest.NewRecorder() + labels := metrics.Labels{} + ao := analytics.AuctionObject{} + account := &config.Account{} + + responseCopy := *bidResponse + responseCopy.SeatBid = make([]openrtb2.SeatBid, len(bidResponse.SeatBid)) + copy(responseCopy.SeatBid, bidResponse.SeatBid) + responseCopy.SeatBid[0].Bid = make([]openrtb2.Bid, len(bidResponse.SeatBid[0].Bid)) + copy(responseCopy.SeatBid[0].Bid, bidResponse.SeatBid[0].Bid) + + sendAuctionResponse(writer, hookExecutor, &responseCopy, &openrtb2.BidRequest{ID: "test"}, account, labels, ao, true) + + body := writer.Body.String() + + var resp openrtb2.BidResponse + assert.NoError(t, json.Unmarshal([]byte(body), &resp)) + + var respExt map[string]json.RawMessage + assert.NoError(t, json.Unmarshal(resp.Ext, &respExt)) + assert.Contains(t, respExt, "openads") + assert.NotContains(t, respExt, "prebid") + + var bidExt map[string]json.RawMessage + assert.NoError(t, json.Unmarshal(resp.SeatBid[0].Bid[0].Ext, &bidExt)) + assert.Contains(t, bidExt, "openads") + assert.NotContains(t, bidExt, "prebid") + }) + + t.Run("ext.prebid request preserves prebid response keys", func(t *testing.T) { + writer := httptest.NewRecorder() + labels := metrics.Labels{} + ao := analytics.AuctionObject{} + account := &config.Account{} + + responseCopy := *bidResponse + responseCopy.SeatBid = make([]openrtb2.SeatBid, len(bidResponse.SeatBid)) + copy(responseCopy.SeatBid, bidResponse.SeatBid) + responseCopy.SeatBid[0].Bid = make([]openrtb2.Bid, len(bidResponse.SeatBid[0].Bid)) + copy(responseCopy.SeatBid[0].Bid, bidResponse.SeatBid[0].Bid) + + sendAuctionResponse(writer, hookExecutor, &responseCopy, &openrtb2.BidRequest{ID: "test"}, account, labels, ao, false) + + body := writer.Body.String() + + var resp openrtb2.BidResponse + assert.NoError(t, json.Unmarshal([]byte(body), &resp)) + + var respExt map[string]json.RawMessage + assert.NoError(t, json.Unmarshal(resp.Ext, &respExt)) + assert.Contains(t, respExt, "prebid") + assert.NotContains(t, respExt, "openads") + + var bidExt map[string]json.RawMessage + assert.NoError(t, json.Unmarshal(resp.SeatBid[0].Bid[0].Ext, &bidExt)) + assert.Contains(t, bidExt, "prebid") + assert.NotContains(t, bidExt, "openads") + }) +} + +func TestRekeyPrebidInJSON(t *testing.T) { + tests := []struct { + name string + input json.RawMessage + expected json.RawMessage + }{ + { + name: "renames prebid to openads", + input: json.RawMessage(`{"prebid":{"auctiontimestamp":123},"responsetimemillis":{}}`), + expected: json.RawMessage(`{"openads":{"auctiontimestamp":123},"responsetimemillis":{}}`), + }, + { + name: "no prebid key is no-op", + input: json.RawMessage(`{"other":{"foo":"bar"}}`), + expected: json.RawMessage(`{"other":{"foo":"bar"}}`), + }, + { + name: "empty input returns empty", + input: nil, + expected: nil, + }, + { + name: "invalid JSON returns unchanged", + input: json.RawMessage(`not json`), + expected: json.RawMessage(`not json`), + }, + { + name: "nested prebid keys are untouched", + input: json.RawMessage(`{"prebid":{"seatnonbid":[{"nonbid":[{"ext":{"prebid":{"bid":{}}}}]}]}}`), + expected: json.RawMessage(`{"openads":{"seatnonbid":[{"nonbid":[{"ext":{"prebid":{"bid":{}}}}]}]}}`), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := rekeyPrebidInJSON(tc.input) + if tc.expected == nil { + assert.Nil(t, result) + } else if !json.Valid(tc.expected) { + assert.Equal(t, string(tc.expected), string(result)) + } else { + assert.JSONEq(t, string(tc.expected), string(result)) + } + }) + } +} + +func TestRekeyResponseForOpenAds(t *testing.T) { + t.Run("renames response ext and bid ext", func(t *testing.T) { + response := &openrtb2.BidResponse{ + Ext: json.RawMessage(`{"prebid":{"auctiontimestamp":123},"responsetimemillis":{}}`), + SeatBid: []openrtb2.SeatBid{ + { + Bid: []openrtb2.Bid{ + {Ext: json.RawMessage(`{"prebid":{"type":"banner","meta":{"adaptercode":"ttd"}},"origbidcpm":1.5}`)}, + {Ext: json.RawMessage(`{"prebid":{"type":"video"},"origbidcpm":2.0}`)}, + }, + }, + }, + } + + rekeyResponseForOpenAds(response) + + assert.Contains(t, string(response.Ext), `"openads"`) + assert.NotContains(t, string(response.Ext), `"prebid"`) + + for _, sb := range response.SeatBid { + for _, bid := range sb.Bid { + assert.Contains(t, string(bid.Ext), `"openads"`) + assert.NotContains(t, string(bid.Ext), `"prebid"`) + } + } + }) + + t.Run("nil response is safe", func(t *testing.T) { + rekeyResponseForOpenAds(nil) + }) + + t.Run("response with no ext is safe", func(t *testing.T) { + response := &openrtb2.BidResponse{} + rekeyResponseForOpenAds(response) + assert.Nil(t, response.Ext) + }) + + t.Run("preserves errors.prebid and warnings.prebid as bidder names", func(t *testing.T) { + response := &openrtb2.BidResponse{ + Ext: json.RawMessage(`{"prebid":{"auctiontimestamp":123},"errors":{"prebid":["some error"]},"warnings":{"prebid":["some warning"]}}`), + } + rekeyResponseForOpenAds(response) + + assert.Contains(t, string(response.Ext), `"openads"`) + + var ext map[string]json.RawMessage + jsonutil.Unmarshal(response.Ext, &ext) + assert.Contains(t, string(ext["errors"]), `"prebid"`) + assert.Contains(t, string(ext["warnings"]), `"prebid"`) + }) +} + func TestParseRequestMultiBid(t *testing.T) { tests := []struct { name string diff --git a/openrtb_ext/request_wrapper.go b/openrtb_ext/request_wrapper.go index 5cfad1b4..c13aff27 100644 --- a/openrtb_ext/request_wrapper.go +++ b/openrtb_ext/request_wrapper.go @@ -749,12 +749,13 @@ func (ue *UserExt) Clone() *UserExt { // --------------------------------------------------------------- type RequestExt struct { - ext map[string]json.RawMessage - extDirty bool - prebid *ExtRequestPrebid - prebidDirty bool - schain *openrtb2.SupplyChain // ORTB 2.4 location - schainDirty bool + ext map[string]json.RawMessage + extDirty bool + prebid *ExtRequestPrebid + prebidDirty bool + schain *openrtb2.SupplyChain // ORTB 2.4 location + schainDirty bool + useOpenAdsExtKey bool } func (re *RequestExt) unmarshal(extJson json.RawMessage) error { @@ -777,6 +778,7 @@ func (re *RequestExt) unmarshal(extJson json.RawMessage) error { // stored under prebidKey so outbound marshaling emits "prebid". prebidJson, hasPrebid := re.ext[OpenAdsExtKey] if hasPrebid { + re.useOpenAdsExtKey = true re.ext[prebidKey] = prebidJson } else { prebidJson, hasPrebid = re.ext[prebidKey] @@ -846,6 +848,10 @@ func (re *RequestExt) marshal() (json.RawMessage, error) { return jsonutil.Marshal(re.ext) } +func (re *RequestExt) UseOpenAdsExtKey() bool { + return re.useOpenAdsExtKey +} + func (re *RequestExt) Dirty() bool { return re.extDirty || re.prebidDirty || re.schainDirty } diff --git a/openrtb_ext/request_wrapper_test.go b/openrtb_ext/request_wrapper_test.go index 4e17d4fb..aa58ff49 100644 --- a/openrtb_ext/request_wrapper_test.go +++ b/openrtb_ext/request_wrapper_test.go @@ -2512,6 +2512,36 @@ func TestRequestExtOpenAdsAlias(t *testing.T) { }) } +func TestRequestExtUseOpenAdsExtKey(t *testing.T) { + t.Run("openads key sets flag true", func(t *testing.T) { + re := &RequestExt{} + err := re.unmarshal([]byte(`{"openads":{"debug":true}}`)) + assert.NoError(t, err) + assert.True(t, re.UseOpenAdsExtKey()) + }) + + t.Run("prebid key leaves flag false", func(t *testing.T) { + re := &RequestExt{} + err := re.unmarshal([]byte(`{"prebid":{"debug":true}}`)) + assert.NoError(t, err) + assert.False(t, re.UseOpenAdsExtKey()) + }) + + t.Run("no ext key leaves flag false", func(t *testing.T) { + re := &RequestExt{} + err := re.unmarshal([]byte(`{}`)) + assert.NoError(t, err) + assert.False(t, re.UseOpenAdsExtKey()) + }) + + t.Run("both keys present, flag true because openads wins", func(t *testing.T) { + re := &RequestExt{} + err := re.unmarshal([]byte(`{"prebid":{"debug":true},"openads":{"debug":false}}`)) + assert.NoError(t, err) + assert.True(t, re.UseOpenAdsExtKey()) + }) +} + func TestImpExtOpenAdsAlias(t *testing.T) { t.Run("openads decodes into prebid and marshals as prebid", func(t *testing.T) { ie := &ImpExt{}