diff --git a/modules/BTBidAdapter.js b/modules/BTBidAdapter.js new file mode 100644 index 000000000..7b50b9012 --- /dev/null +++ b/modules/BTBidAdapter.js @@ -0,0 +1,204 @@ +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { deepSetValue, isPlainObject, logWarn } from '../src/utils.js'; +import { BANNER } from '../src/mediaTypes.js'; +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; + +const BIDDER_CODE = 'blockthrough'; +const GVLID = 815; +const ENDPOINT_URL = 'https://pbs.btloader.com/openrtb2/auction'; +const SYNC_URL = 'https://cdn.btloader.com/user_sync.html'; + +const CONVERTER = ortbConverter({ + context: { + netRevenue: true, + ttl: 60, + }, + imp, + request, + bidResponse, +}); + +/** + * Builds an impression object for the ORTB 2.5 request. + * + * @param {function} buildImp - The function for building an imp object. + * @param {Object} bidRequest - The bid request object. + * @param {Object} context - The context object. + * @returns {Object} The ORTB 2.5 imp object. + */ +function imp(buildImp, bidRequest, context) { + const imp = buildImp(bidRequest, context); + const { params, ortb2Imp } = bidRequest; + + if (params) { + deepSetValue(imp, 'ext', params); + } + if (ortb2Imp?.ext?.gpid) { + deepSetValue(imp, 'ext.gpid', ortb2Imp.ext.gpid); + } + + return imp; +} + +/** + * Builds a request object for the ORTB 2.5 request. + * + * @param {function} buildRequest - The function for building a request object. + * @param {Array} imps - An array of ORTB 2.5 impression objects. + * @param {Object} bidderRequest - The bidder request object. + * @param {Object} context - The context object. + * @returns {Object} The ORTB 2.5 request object. + */ +function request(buildRequest, imps, bidderRequest, context) { + const request = buildRequest(imps, bidderRequest, context); + deepSetValue(request, 'ext.prebid.channel', { + name: 'pbjs', + version: '$prebid.version$', + }); + + if (window.location.href.includes('btServerTest=true')) { + request.test = 1; + } + + return request; +} + +/** + * Processes a bid response using the provided build function, bid, and context. + * + * @param {Function} buildBidResponse - The function to build the bid response. + * @param {Object} bid - The bid object to include in the bid response. + * @param {Object} context - The context object containing additional information. + * @returns {Object} - The processed bid response. + */ +function bidResponse(buildBidResponse, bid, context) { + const bidResponse = buildBidResponse(bid, context); + const { seat } = context.seatbid || {}; + bidResponse.btBidderCode = seat; + + return bidResponse; +} + +/** + * Checks if a bid request is valid. + * + * @param {Object} bid - The bid request object. + * @returns {boolean} True if the bid request is valid, false otherwise. + */ +function isBidRequestValid(bid) { + if (!isPlainObject(bid.params) || !Object.keys(bid.params).length) { + logWarn('BT Bid Adapter: bid params must be provided.'); + return false; + } + + return true; +} + +/** + * Builds the bid requests for the BT Service. + * + * @param {Array} validBidRequests - An array of valid bid request objects. + * @param {Object} bidderRequest - The bidder request object. + * @returns {Array} An array of BT Service bid requests. + */ +function buildRequests(validBidRequests, bidderRequest) { + const data = CONVERTER.toORTB({ + bidRequests: validBidRequests, + bidderRequest, + }); + + return [ + { + method: 'POST', + url: ENDPOINT_URL, + data, + bids: validBidRequests, + }, + ]; +} + +/** + * Interprets the server response and maps it to bids. + * + * @param {Object} serverResponse - The server response object. + * @param {Object} request - The request object. + * @returns {Array} An array of bid objects. + */ +function interpretResponse(serverResponse, request) { + if (!serverResponse || !request) { + return []; + } + + return CONVERTER.fromORTB({ + response: serverResponse.body, + request: request.data, + }).bids; +} + +/** + * Generates user synchronization data based on provided options and consents. + * + * @param {Object} syncOptions - Synchronization options. + * @param {Object[]} serverResponses - An array of server responses. + * @param {Object} gdprConsent - GDPR consent information. + * @param {string} uspConsent - US Privacy consent string. + * @param {Object} gppConsent - Google Publisher Policies (GPP) consent information. + * @returns {Object[]} An array of user synchronization objects. + */ +function getUserSyncs( + syncOptions, + serverResponses, + gdprConsent, + uspConsent, + gppConsent +) { + if (!syncOptions.iframeEnabled || !serverResponses?.length) { + return []; + } + + const bidderCodes = new Set(); + serverResponses.forEach((serverResponse) => { + if (serverResponse?.body?.ext?.responsetimemillis) { + Object.keys(serverResponse.body.ext.responsetimemillis).forEach( + bidderCodes.add, + bidderCodes + ); + } + }); + + if (!bidderCodes.size) { + return []; + } + + const syncs = []; + const syncUrl = new URL(SYNC_URL); + syncUrl.searchParams.set('bidders', [...bidderCodes].join(',')); + + if (gdprConsent) { + syncUrl.searchParams.set('gdpr', Number(gdprConsent.gdprApplies)); + syncUrl.searchParams.set('gdpr_consent', gdprConsent.consentString); + } + if (gppConsent) { + syncUrl.searchParams.set('gpp', gppConsent.gppString); + syncUrl.searchParams.set('gpp_sid', gppConsent.applicableSections); + } + if (uspConsent) { + syncUrl.searchParams.set('us_privacy', uspConsent); + } + + syncs.push({ type: 'iframe', url: syncUrl.href }); + + return syncs; +} + +export const spec = { + code: BIDDER_CODE, + gvlid: GVLID, + supportedMediaTypes: [BANNER], + isBidRequestValid, + buildRequests, + interpretResponse, + getUserSyncs, +}; + +registerBidder(spec); diff --git a/modules/BTBidAdapter.md b/modules/BTBidAdapter.md new file mode 100644 index 000000000..e29bc688b --- /dev/null +++ b/modules/BTBidAdapter.md @@ -0,0 +1,70 @@ +# Overview + +**Module Name**: BT Bidder Adapter +**Module Type**: Bidder Adapter +**Maintainer**: engsupport@blockthrough.com + +# Description + +The BT Bidder Adapter provides an interface to the BT Service. The BT Bidder Adapter sends one request to the BT Service per ad unit. Behind the scenes, the BT Service further disperses requests to various configured exchanges. This operational model closely resembles that of Prebid Server, where a single request is made from the client side, and responses are gathered from multiple exchanges. + +The BT adapter requires setup and approval from the Blockthrough team. Please reach out to marketing@blockthrough.com for more information. + +# Bid Params + +| Key | Scope | Type | Description | +| ------ | -------- | ------ | -------------------------------------------------------------- | +| bidder | Required | Object | Bidder configuration. Could configure several bidders this way | + +# Bidder Config + +Make sure to set required ab, orgID, websiteID values received after approval using `pbjs.setBidderConfig`. + +## Example + +```javascript +pbjs.setBidderConfig({ + bidders: ['blockthrough'], + config: { + ortb2: { + site: { + ext: { + blockthrough: { + ab: false, + orgID: '4829301576428910', + websiteID: '5654012389765432', + }, + }, + }, + }, + }, +}); +``` + +## AdUnits configuration example + +```javascript +var adUnits = [ + { + code: 'banner-div-1', + mediaTypes: { + banner: { + sizes: [[728, 90]], + }, + }, + bids: [ + { + bidder: 'blockthrough', + params: { + bidderA: { + publisherId: 55555, + }, + bidderB: { + zoneId: 12, + }, + }, + }, + ], + }, +]; +``` diff --git a/test/spec/modules/BTBidAdapter_spec.js b/test/spec/modules/BTBidAdapter_spec.js new file mode 100644 index 000000000..e0306abb7 --- /dev/null +++ b/test/spec/modules/BTBidAdapter_spec.js @@ -0,0 +1,209 @@ +import { expect } from 'chai'; +import { spec } from 'modules/BTBidAdapter.js'; +import { BANNER } from '../../../src/mediaTypes.js'; +// load modules that register ORTB processors +import 'src/prebid.js'; +import 'modules/currency.js'; +import 'modules/userId/index.js'; +import 'modules/multibid/index.js'; +import 'modules/priceFloors.js'; +import 'modules/consentManagement.js'; +import 'modules/consentManagementUsp.js'; +import 'modules/consentManagementGpp.js'; +import 'modules/enrichmentFpdModule.js'; +import 'modules/gdprEnforcement.js'; +import 'modules/gppControl_usnat.js'; +import 'modules/schain.js'; + +describe('BT Bid Adapter', () => { + const ENDPOINT_URL = 'https://pbs.btloader.com/openrtb2/auction'; + const validBidRequests = [ + { + bidId: '2e9f38ea93bb9e', + bidder: 'blockthrough', + adUnitCode: 'adunit-code', + mediaTypes: { [BANNER]: { sizes: [[300, 250]] } }, + params: { + bidderA: { + pubId: '11111', + }, + }, + bidderRequestId: 'test-bidder-request-id', + }, + ]; + const bidderRequest = { + bidderCode: 'blockthrough', + bidderRequestId: 'test-bidder-request-id', + bids: validBidRequests, + }; + + describe('isBidRequestValid', function () { + it('should validate bid request with valid params', () => { + const validBid = { + params: { + pubmatic: { + publisherId: 55555, + }, + }, + sizes: [[300, 250]], + bidId: '123', + adUnitCode: 'leaderboard', + }; + + const isValid = spec.isBidRequestValid(validBid); + + expect(isValid).to.be.true; + }); + + it('should not validate bid request with invalid params', () => { + const invalidBid = { + params: {}, + sizes: [[300, 250]], + bidId: '123', + adUnitCode: 'leaderboard', + }; + + const isValid = spec.isBidRequestValid(invalidBid); + + expect(isValid).to.be.false; + }); + }); + + describe('buildRequests', () => { + it('should build post request when ortb2 fields are present', () => { + const impExtParams = { + bidderA: { + pubId: '11111', + }, + }; + + const requests = spec.buildRequests(validBidRequests, bidderRequest); + + expect(requests[0].method).to.equal('POST'); + expect(requests[0].url).to.equal(ENDPOINT_URL); + expect(requests[0].data).to.exist; + expect(requests[0].data.ext.prebid.channel).to.deep.equal({ + name: 'pbjs', + version: '$prebid.version$', + }); + expect(requests[0].data.imp[0].ext).to.deep.equal(impExtParams); + }); + }); + + describe('interpretResponse', () => { + it('should return empty array if serverResponse is not defined', () => { + const bidRequest = spec.buildRequests(validBidRequests, bidderRequest); + const bids = spec.interpretResponse(undefined, bidRequest); + + expect(bids.length).to.equal(0); + }); + + it('should return bids array when serverResponse is defined and seatbid array is not empty', () => { + const bidResponse = { + body: { + id: 'bid-response', + cur: 'USD', + seatbid: [ + { + bid: [ + { + impid: '2e9f38ea93bb9e', + crid: 'creative-id', + cur: 'USD', + price: 2, + w: 300, + h: 250, + mtype: 1, + adomain: ['test.com'], + }, + ], + seat: 'test-seat', + }, + ], + }, + }; + + const expectedBids = [ + { + btBidderCode: 'test-seat', + cpm: 2, + creativeId: 'creative-id', + creative_id: 'creative-id', + currency: 'USD', + height: 250, + mediaType: 'banner', + meta: { + advertiserDomains: ['test.com'], + }, + netRevenue: true, + requestId: '2e9f38ea93bb9e', + ttl: 60, + width: 300, + }, + ]; + + const request = spec.buildRequests(validBidRequests, bidderRequest)[0]; + const bids = spec.interpretResponse(bidResponse, request); + + expect(bids).to.deep.equal(expectedBids); + }); + }); + + describe('getUserSyncs', () => { + const SYNC_URL = 'https://cdn.btloader.com/user_sync.html'; + + it('should return an empty array if no sync options are provided', () => { + const syncs = spec.getUserSyncs({}, [], null, null, null); + + expect(syncs).to.deep.equal([]); + }); + + it('should return an empty array if no server responses are provided', () => { + const syncs = spec.getUserSyncs( + { iframeEnabled: true }, + [], + null, + null, + null + ); + + expect(syncs).to.deep.equal([]); + }); + + it('should pass consent parameters and bidder codes in sync URL if they are provided', () => { + const gdprConsent = { + gdprApplies: true, + consentString: 'GDPRConsentString123', + }; + const gppConsent = { + gppString: 'GPPString123', + applicableSections: ['sectionA'], + }; + const us_privacy = '1YNY'; + const expectedSyncUrl = new URL(SYNC_URL); + expectedSyncUrl.searchParams.set('bidders', 'pubmatic,ix'); + expectedSyncUrl.searchParams.set('gdpr', 1); + expectedSyncUrl.searchParams.set( + 'gdpr_consent', + gdprConsent.consentString + ); + expectedSyncUrl.searchParams.set('gpp', gppConsent.gppString); + expectedSyncUrl.searchParams.set('gpp_sid', 'sectionA'); + expectedSyncUrl.searchParams.set('us_privacy', us_privacy); + const syncs = spec.getUserSyncs( + { iframeEnabled: true }, + [ + { body: { ext: { responsetimemillis: { pubmatic: 123 } } } }, + { body: { ext: { responsetimemillis: { pubmatic: 123, ix: 123 } } } }, + ], + gdprConsent, + us_privacy, + gppConsent + ); + + expect(syncs).to.deep.equal([ + { type: 'iframe', url: expectedSyncUrl.href }, + ]); + }); + }); +});