diff --git a/src/auction.js b/src/auction.js index 09fb275afdb..01553b2d25a 100644 --- a/src/auction.js +++ b/src/auction.js @@ -76,7 +76,7 @@ import { timestamp } from './utils.js'; import {getPriceBucketString} from './cpmBucketManager.js'; -import {getNativeTargeting} from './native.js'; +import {getNativeTargeting, toLegacyResponse} from './native.js'; import {getCacheUrl, store} from './videoCache.js'; import {Renderer} from './Renderer.js'; import {config} from './config.js'; @@ -462,9 +462,14 @@ export function auctionCallbacks(auctionDone, auctionInstance, {index = auctionM handleBidResponse(adUnitCode, bid, (done) => { let bidResponse = getPreparedBidForAuction(bid); - if (bidResponse.mediaType === 'video') { + if (bidResponse.mediaType === VIDEO) { tryAddVideoBid(auctionInstance, bidResponse, done); } else { + if (FEATURES.NATIVE && bidResponse.native != null && typeof bidResponse.native === 'object') { + // NOTE: augment bidResponse.native even if bidResponse.mediaType !== NATIVE; it's possible + // to treat banner responses as native + addLegacyFieldsIfNeeded(bidResponse); + } addBidToAuction(auctionInstance, bidResponse); done(); } @@ -593,6 +598,17 @@ function tryAddVideoBid(auctionInstance, bidResponse, afterBidAdded, {index = au } } +// Native bid response might be in ortb2 format - adds legacy field for backward compatibility +const addLegacyFieldsIfNeeded = (bidResponse) => { + const nativeOrtbRequest = auctionManager.index.getAdUnit(bidResponse)?.nativeOrtbRequest; + const nativeOrtbResponse = bidResponse.native?.ortb + + if (nativeOrtbRequest && nativeOrtbResponse) { + const legacyResponse = toLegacyResponse(nativeOrtbResponse, nativeOrtbRequest); + Object.assign(bidResponse.native, legacyResponse); + } +} + const storeInCache = (batch) => { store(batch.map(entry => entry.bidResponse), function (error, cacheIds) { cacheIds.forEach((cacheId, i) => { diff --git a/src/native.js b/src/native.js index 022ece457f5..25f8c38cb30 100644 --- a/src/native.js +++ b/src/native.js @@ -23,7 +23,7 @@ export const NATIVE_TARGETING_KEYS = Object.keys(CONSTANTS.NATIVE_KEYS).map( key => CONSTANTS.NATIVE_KEYS[key] ); -const IMAGE = { +export const IMAGE = { ortb: { ver: '1.2', assets: [ @@ -385,26 +385,14 @@ export function getNativeTargeting(bid, {index = auctionManager.index} = {}) { return keyValues; } -const getNativeRequest = (bidResponse) => auctionManager.index.getAdUnit(bidResponse)?.nativeOrtbRequest; - -function assetsMessage(data, adObject, keys, {getNativeReq = getNativeRequest} = {}) { +function assetsMessage(data, adObject, keys) { const message = { message: 'assetResponse', adId: data.adId, }; - // Pass to Prebid Universal Creative all assets, the legacy ones + the ortb ones (under ortb property) - const ortbRequest = getNativeReq(adObject); let nativeResp = adObject.native; - const ortbResponse = adObject.native?.ortb; - let legacyResponse = {}; - if (ortbRequest && ortbResponse) { - legacyResponse = toLegacyResponse(ortbResponse, ortbRequest); - nativeResp = { - ...adObject.native, - ...legacyResponse - }; - } + if (adObject.native.ortb) { message.ortb = adObject.native.ortb; } @@ -435,13 +423,13 @@ function assetsMessage(data, adObject, keys, {getNativeReq = getNativeRequest} = * Constructs a message object containing asset values for each of the * requested data keys. */ -export function getAssetMessage(data, adObject, {getNativeReq = getNativeRequest} = {}) { +export function getAssetMessage(data, adObject) { const keys = data.assets.map((k) => getKeyByValue(CONSTANTS.NATIVE_KEYS, k)); - return assetsMessage(data, adObject, keys, {getNativeReq}); + return assetsMessage(data, adObject, keys); } -export function getAllAssetsMessage(data, adObject, {getNativeReq = getNativeRequest} = {}) { - return assetsMessage(data, adObject, null, {getNativeReq}); +export function getAllAssetsMessage(data, adObject) { + return assetsMessage(data, adObject, null); } /** @@ -749,7 +737,7 @@ export function toOrtbNativeResponse(legacyResponse, ortbRequest) { * @param {*} ortbRequest the ortb request, useful to match ids. * @returns an object containing the response in legacy native format: { title: "this is a title", image: ... } */ -function toLegacyResponse(ortbResponse, ortbRequest) { +export function toLegacyResponse(ortbResponse, ortbRequest) { const legacyResponse = {}; const requestAssets = ortbRequest?.assets || []; legacyResponse.clickUrl = ortbResponse.link.url; @@ -764,6 +752,29 @@ function toLegacyResponse(ortbResponse, ortbRequest) { legacyResponse[PREBID_NATIVE_DATA_KEYS_TO_ORTB_INVERSE[NATIVE_ASSET_TYPES_INVERSE[requestAsset.data.type]]] = asset.data.value; } } + + // Handle trackers + legacyResponse.impressionTrackers = []; + let jsTrackers = []; + + if (ortbRequest?.imptrackers) { + legacyResponse.impressionTrackers.push(...ortbRequest.imptrackers); + } + for (const eventTracker of ortbResponse?.eventtrackers || []) { + if (eventTracker.event === TRACKER_EVENTS.impression && eventTracker.method === TRACKER_METHODS.img) { + legacyResponse.impressionTrackers.push(eventTracker.url); + } + if (eventTracker.event === TRACKER_EVENTS.impression && eventTracker.method === TRACKER_METHODS.js) { + jsTrackers.push(eventTracker.url); + } + } + + jsTrackers = jsTrackers.map(url => ``); + if (ortbResponse?.jstracker) { jsTrackers.push(ortbResponse.jstracker); } + if (jsTrackers.length) { + legacyResponse.javascriptTrackers = jsTrackers.join('\n'); + } + return legacyResponse; } diff --git a/test/spec/auctionmanager_spec.js b/test/spec/auctionmanager_spec.js index 47bdd4a3772..ba5f7c7cb80 100644 --- a/test/spec/auctionmanager_spec.js +++ b/test/spec/auctionmanager_spec.js @@ -22,6 +22,7 @@ import 'modules/debugging/index.js' // some tests look for debugging side effect import {AuctionIndex} from '../../src/auctionIndex.js'; import {expect} from 'chai'; import {deepClone} from '../../src/utils.js'; +import { IMAGE as ortbNativeRequest } from 'src/native.js'; var assert = require('assert'); @@ -1276,6 +1277,81 @@ describe('auctionmanager.js', function () { }); }); + if (FEATURES.NATIVE) { + describe('addBidResponse native', function () { + let makeRequestsStub; + let ajaxStub; + let spec; + let auction; + + beforeEach(function () { + makeRequestsStub = sinon.stub(adapterManager, 'makeBidRequests'); + ajaxStub = sinon.stub(ajaxLib, 'ajaxBuilder').callsFake(mockAjaxBuilder); + + const adUnits = [{ + code: ADUNIT_CODE, + transactionId: ADUNIT_CODE, + bids: [ + {bidder: BIDDER_CODE, params: {placementId: 'id'}}, + ], + nativeOrtbRequest: ortbNativeRequest.ortb, + mediaTypes: { native: { type: 'image' } } + }]; + auction = auctionModule.newAuction({adUnits, adUnitCodes: [ADUNIT_CODE], callback: function() {}, cbTimeout: 3000}); + indexAuctions = [auction]; + const createAuctionStub = sinon.stub(auctionModule, 'newAuction'); + createAuctionStub.returns(auction); + + spec = mockBidder(BIDDER_CODE); + registerBidder(spec); + }); + + afterEach(function () { + ajaxStub.restore(); + auctionModule.newAuction.restore(); + adapterManager.makeBidRequests.restore(); + }); + + it('should add legacy fields to native response', function () { + let nativeBid = mockBid(); + nativeBid.mediaType = 'native'; + nativeBid.native = { + ortb: { + ver: '1.2', + assets: [ + { id: 2, title: { text: 'Sample title' } }, + { id: 4, data: { value: 'Sample body' } }, + { id: 3, data: { value: 'Sample sponsoredBy' } }, + { id: 1, img: { url: 'https://www.example.com/image.png', w: 200, h: 200 } }, + { id: 5, img: { url: 'https://www.example.com/icon.png', w: 32, h: 32 } } + ], + link: { url: 'http://www.click.com' }, + eventtrackers: [ + { event: 1, method: 1, url: 'http://www.imptracker.com' }, + { event: 1, method: 2, url: 'http://www.jstracker.com/file.js' } + ] + } + } + + let bidRequest = mockBidRequest(nativeBid, { mediaType: { native: ortbNativeRequest } }); + makeRequestsStub.returns([bidRequest]); + + spec.interpretResponse.returns(nativeBid); + auction.callBids(); + + const addedBid = auction.getBidsReceived().pop(); + assert.equal(addedBid.native.body, 'Sample body') + assert.equal(addedBid.native.title, 'Sample title') + assert.equal(addedBid.native.sponsoredBy, 'Sample sponsoredBy') + assert.equal(addedBid.native.clickUrl, 'http://www.click.com') + assert.equal(addedBid.native.image, 'https://www.example.com/image.png') + assert.equal(addedBid.native.icon, 'https://www.example.com/icon.png') + assert.equal(addedBid.native.impressionTrackers[0], 'http://www.imptracker.com') + assert.equal(addedBid.native.javascriptTrackers, '') + }); + }); + } + describe('getMediaTypeGranularity', function () { it('video', function () { let mediaTypes = { video: {id: '1'} }; diff --git a/test/spec/native_spec.js b/test/spec/native_spec.js index 23350781a3d..2b7c2b88449 100644 --- a/test/spec/native_spec.js +++ b/test/spec/native_spec.js @@ -5,6 +5,7 @@ import { nativeBidIsValid, getAssetMessage, getAllAssetsMessage, + toLegacyResponse, decorateAdUnitsWithNativeParams, isOpenRTBBidRequestValid, isNativeOpenRTBBidValid, @@ -37,6 +38,7 @@ const bid = { clickTrackers: ['https://tracker.example'], impressionTrackers: ['https://impression.example'], javascriptTrackers: '', + privacyLink: 'https://privacy-link.example', ext: { foo: 'foo-value', baz: 'baz-value', @@ -98,9 +100,18 @@ const ortbBid = { privacy: 'https://privacy-link.example', ver: '1.2' } - }, + } }; +const completeNativeBid = { + adId: '123', + transactionId: 'au', + native: { + ...bid.native, + ...ortbBid.native + } +} + const ortbRequest = { assets: [ { @@ -224,6 +235,14 @@ describe('native.js', function () { expect(targeting.hb_native_baz).to.equal('hb_native_baz:123'); }); + it('sends placeholdes targetings with ortb native response', function () { + const targeting = getNativeTargeting(completeNativeBid); + + expect(targeting[CONSTANTS.NATIVE_KEYS.title]).to.equal('Native Creative'); + expect(targeting[CONSTANTS.NATIVE_KEYS.body]).to.equal('Cool description great stuff'); + expect(targeting[CONSTANTS.NATIVE_KEYS.clickUrl]).to.equal('https://www.link.example'); + }); + it('should only include native targeting keys with values', function () { const adUnit = { transactionId: 'au', @@ -302,6 +321,10 @@ describe('native.js', function () { required: false, sendTargetingKeys: false, }, + privacyLink: { + required: false, + sendTargetingKeys: false, + }, ext: { foo: { required: false, @@ -348,6 +371,7 @@ describe('native.js', function () { CONSTANTS.NATIVE_KEYS.icon, CONSTANTS.NATIVE_KEYS.sponsoredBy, CONSTANTS.NATIVE_KEYS.clickUrl, + CONSTANTS.NATIVE_KEYS.privacyLink, CONSTANTS.NATIVE_KEYS.rendererUrl, ]); @@ -380,6 +404,7 @@ describe('native.js', function () { CONSTANTS.NATIVE_KEYS.icon, CONSTANTS.NATIVE_KEYS.sponsoredBy, CONSTANTS.NATIVE_KEYS.clickUrl, + CONSTANTS.NATIVE_KEYS.privacyLink, ]); expect(bid.native.adTemplate).to.deep.equal( @@ -437,9 +462,9 @@ describe('native.js', function () { adId: '123', }; - const message = getAllAssetsMessage(messageRequest, bid, {getNativeReq: () => null}); + const message = getAllAssetsMessage(messageRequest, bid); - expect(message.assets.length).to.equal(9); + expect(message.assets.length).to.equal(10); expect(message.assets).to.deep.include({ key: 'body', value: bid.native.body, @@ -485,7 +510,7 @@ describe('native.js', function () { adId: '123', }; - const message = getAllAssetsMessage(messageRequest, bidWithUndefinedFields, {getNativeReq: () => null}); + const message = getAllAssetsMessage(messageRequest, bidWithUndefinedFields); expect(message.assets.length).to.equal(4); expect(message.assets).to.deep.include({ @@ -506,16 +531,16 @@ describe('native.js', function () { }); }); - it('creates native all asset message with OpenRTB format', function () { + it('creates native all asset message with complete format', function () { const messageRequest = { message: 'Prebid Native', action: 'allAssetRequest', adId: '123', }; - const message = getAllAssetsMessage(messageRequest, ortbBid, {getNativeReq: () => ortbRequest}); + const message = getAllAssetsMessage(messageRequest, completeNativeBid); - expect(message.assets.length).to.equal(8); + expect(message.assets.length).to.equal(10); expect(message.assets).to.deep.include({ key: 'body', value: bid.native.body, @@ -548,6 +573,14 @@ describe('native.js', function () { key: 'privacyLink', value: ortbBid.native.ortb.privacy, }); + expect(message.assets).to.deep.include({ + key: 'foo', + value: bid.native.ext.foo, + }); + expect(message.assets).to.deep.include({ + key: 'baz', + value: bid.native.ext.baz, + }); }); const SAMPLE_ORTB_REQUEST = toOrtbNativeRequest({ @@ -555,60 +588,39 @@ describe('native.js', function () { body: 'vbody' }); const SAMPLE_ORTB_RESPONSE = { - native: { - ortb: { - link: { - url: 'url' - }, - assets: [ - { - id: 0, - title: { - text: 'vtitle' - } - }, - { - id: 1, - data: { - value: 'vbody' - } - } - ] + link: { + url: 'url' + }, + assets: [ + { + id: 0, + title: { + text: 'vtitle' + } + }, + { + id: 1, + data: { + value: 'vbody' + } } - } + ], + eventtrackers: [ + { event: 1, method: 1, url: 'https://sampleurl.com' }, + { event: 1, method: 2, url: 'https://sampleurljs.com' } + ] } - describe('getAllAssetsMessage', () => { + describe('toLegacyResponse', () => { it('returns assets in legacy format for ortb responses', () => { - const actual = getAllAssetsMessage({}, SAMPLE_ORTB_RESPONSE, {getNativeReq: () => SAMPLE_ORTB_REQUEST}); - expect(actual.assets).to.eql([ - { - key: 'clickUrl', - value: 'url' - }, - { - key: 'title', - value: 'vtitle' - }, - { - key: 'body', - value: 'vbody' - }, - ]) + const actual = toLegacyResponse(SAMPLE_ORTB_RESPONSE, SAMPLE_ORTB_REQUEST); + expect(actual.body).to.equal('vbody'); + expect(actual.title).to.equal('vtitle'); + expect(actual.clickUrl).to.equal('url'); + expect(actual.javascriptTrackers).to.equal(''); + expect(actual.impressionTrackers.length).to.equal(1); + expect(actual.impressionTrackers[0]).to.equal('https://sampleurl.com'); }); }); - describe('getAssetsMessage', () => { - Object.entries({ - 'hb_native_title': {key: 'title', value: 'vtitle'}, - 'hb_native_body': {key: 'body', value: 'vbody'} - }).forEach(([tkey, assetVal]) => { - it(`returns ${tkey} asset in legacy format for ortb responses`, () => { - const actual = getAssetMessage({ - assets: [tkey] - }, SAMPLE_ORTB_RESPONSE, {getNativeReq: () => SAMPLE_ORTB_REQUEST}) - expect(actual.assets).to.eql([assetVal]) - }) - }) - }) }); describe('validate native openRTB', function () {