diff --git a/modules/akceloBidAdapter.js b/modules/akceloBidAdapter.js new file mode 100644 index 00000000000..bfada1cc2eb --- /dev/null +++ b/modules/akceloBidAdapter.js @@ -0,0 +1,148 @@ +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { deepSetValue, getParameterByName, logError } from '../src/utils.js'; +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; +import { ORTB_MTYPES } from '../libraries/ortbConverter/processors/mediaType.js'; + +/** + * @typedef {import('../src/adapters/bidderFactory.js').BidRequest} BidRequest + * @typedef {import('../src/adapters/bidderFactory.js').BidderRequest} BidderRequest + * @typedef {import('../src/adapters/bidderFactory.js').Bid} Bid + * @typedef {import('../src/adapters/bidderFactory.js').ServerResponse} ServerResponse + * @typedef {import('../src/adapters/bidderFactory.js').SyncOptions} SyncOptions + * @typedef {import('../src/adapters/bidderFactory.js').UserSync} UserSync + */ + +const BIDDER_CODE = 'akcelo'; +const COOKIE_SYNC_ENDPOINT = 'akcelo'; + +const AUCTION_URL = 'https://s2s.sportslocalmedia.com/openrtb2/auction'; +const IFRAME_SYNC_URL = 'https://ads.sportslocalmedia.com/load-cookie.html'; + +const DEFAULT_TTL = 300; + +const akceloDemoIsOn = () => getParameterByName('akcelo_demo') === 'true'; + +export const converter = ortbConverter({ + context: { + netRevenue: true, + ttl: DEFAULT_TTL, + }, + imp(buildImp, bidRequest, context) { + const imp = buildImp(bidRequest, context); + + if (bidRequest.params.siteId) { + deepSetValue(imp, 'ext.akcelo.siteId', bidRequest.params.siteId); + } else { + logError('Missing parameter : siteId'); + } + + if (bidRequest.params.adUnitId) { + deepSetValue(imp, 'ext.akcelo.adUnitId', bidRequest.params.adUnitId); + } else { + logError('Missing parameter : adUnitId'); + } + + if (akceloDemoIsOn()) { + deepSetValue(imp, 'ext.akcelo.test', 1); + } + + return imp; + }, + request(buildRequest, imps, bidderRequest, context) { + const request = buildRequest(imps, bidderRequest, context); + + deepSetValue(request, 'test', akceloDemoIsOn() ? 1 : 0); + + const siteId = bidderRequest.bids.map((bid) => bid.params.siteId).find(Boolean); + deepSetValue(request, 'site.publisher.ext.prebid.parentAccount', siteId); + + return request; + }, + bidResponse(buildBidResponse, bid, context) { + // In ORTB 2.5, bid responses do not specify their mediatype, which is something Prebid.js requires + context.mediaType = bid.mtype && ORTB_MTYPES[bid.mtype] + ? ORTB_MTYPES[bid.mtype] + : bid.ext?.prebid?.type; + + return buildBidResponse(bid, context); + }, +}); + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, NATIVE, VIDEO], + + /** + * Determines whether the given bid request is valid. + * + * @param {Bid} bid The bid to validate. + * @return boolean True if this is a valid bid, and false otherwise. + */ + isBidRequestValid(bid) { + if (!bid?.params?.adUnitId) { + logError("Missing required parameter 'adUnitId'"); + return false; + } + if (!bid?.params?.siteId) { + logError("Missing required parameter 'siteId'"); + return false; + } + return true; + }, + /** + * Make a server request from the list of BidRequests. + * + * @param {BidRequest[]} bidRequests A non-empty list of bid requests which should be sent to the Server. + * @param {BidderRequest} bidderRequest bidder request object. + * @return ServerRequest Info describing the request to the server. + */ + buildRequests(bidRequests, bidderRequest) { + const data = converter.toORTB({ bidRequests, bidderRequest }); + + return [{ method: 'POST', url: AUCTION_URL, data }]; + }, + + /** + * Unpack the response from the server into a list of bids. + * + * @param {ServerResponse} serverResponse A successful response from the server. + * @param {BidRequest} bidRequest The bid request sent to the server. + * @return {Bid[]} An array of bids which were nested inside the server. + */ + interpretResponse(serverResponse, bidRequest) { + const { bids } = converter.fromORTB({ + response: serverResponse.body, + request: bidRequest.data, + }); + + return bids; + }, + + /** + * Register the user sync pixels which should be dropped after the auction. + * + * @param {SyncOptions} syncOptions Which user syncs are allowed? + * @param {ServerResponse[]} serverResponses List of server's responses. + * @param {*} gdprConsent + * @param {*} uspConsent + * @return {UserSync[]} The user syncs which should be dropped. + */ + getUserSyncs(syncOptions, serverResponses, gdprConsent, uspConsent) { + if (syncOptions.iframeEnabled) { + let syncParams = `?endpoint=${COOKIE_SYNC_ENDPOINT}`; + if (gdprConsent) { + syncParams += `&gdpr=${gdprConsent.gdprApplies ? 1 : 0}`; + syncParams += `&gdpr_consent=${encodeURIComponent(gdprConsent.consentString || '')}`; + } + if (uspConsent) { + syncParams += `&us_privacy=${encodeURIComponent(uspConsent)}`; + } + + return [{ type: 'iframe', url: IFRAME_SYNC_URL + syncParams }]; + } + return []; + }, +}; + +registerBidder(spec); diff --git a/modules/akceloBidAdapter.md b/modules/akceloBidAdapter.md new file mode 100644 index 00000000000..02881ede2e1 --- /dev/null +++ b/modules/akceloBidAdapter.md @@ -0,0 +1,57 @@ +# Overview + +**Module Name**: Akcelo Bidder Adapter +**Module Type**: Bidder Adapter +**Maintainer**: tech@akcelo.io + +# Description + +A module that connects to the Akcelo network for bids + +## AdUnits configuration example + +```javascript +const adUnits = [ + { + code: 'div-123', + mediaTypes: { + banner: { + sizes: [ + [300, 250], + [300, 600], + ], + }, + video: { + context: "outstream", + playerSize: [[640, 480]], + mimes: ['video/mp4'], + protocols: [1, 2, 3, 4, 5, 6, 7, 8], + playbackmethod: [1], + skip: 1, + api: [2], + minbitrate: 1000, + maxbitrate: 3000, + minduration: 3, + maxduration: 10, + startdelay: 2, + placement: 4, + linearity: 1 + }, + }, + bids: [ + { + bidder: 'akcelo', + params: { + siteId: 763, // required + adUnitId: 7965, // required + test: 1, // optional, use 0 to disable test creatives + }, + }, + ], + }, +]; + +pbjs.que.push(function () { + pbjs.addAdUnits(adUnits); +}); +``` diff --git a/test/spec/modules/akceloBidAdapter_spec.js b/test/spec/modules/akceloBidAdapter_spec.js new file mode 100644 index 00000000000..5c519ea9834 --- /dev/null +++ b/test/spec/modules/akceloBidAdapter_spec.js @@ -0,0 +1,263 @@ +import { converter, spec } from 'modules/akceloBidAdapter.js'; +import * as utils from '../../../src/utils.js'; +import { deepClone } from '../../../src/utils.js'; +import sinon from 'sinon'; + +describe('Akcelo bid adapter tests', () => { + let sandBox; + + beforeEach(() => { + sandBox = sinon.createSandbox(); + sandBox.stub(utils, 'logError'); + }); + + afterEach(() => sandBox.restore()); + + const DEFAULT_BANNER_BID_REQUESTS = [ + { + adUnitCode: 'div-banner-id', + bidId: 'bid-123', + mediaTypes: { + banner: { + sizes: [ + [300, 250], + [300, 600], + ], + }, + }, + bidder: 'akcelo', + params: { + siteId: 123, + adUnitId: 456, + }, + requestId: 'request-123', + } + ]; + + const DEFAULT_BANNER_BIDDER_REQUEST = { + bidderCode: 'akcelo', + bids: DEFAULT_BANNER_BID_REQUESTS, + }; + + const SAMPLE_RESPONSE = { + body: { + id: '12h712u7-k22g-8124-ab7a-h268s22dy271', + seatbid: [ + { + bid: [ + { + id: '1bh7jku7-ko2g-8654-ab72-h268abcde271', + impid: 'bid-123', + price: 0.6565, + adm: '

AD

', + adomain: ['abc.com'], + cid: '1242512', + crid: '535231', + w: 300, + h: 600, + mtype: 1, + ext: { + prebid: { + type: 'banner', + } + } + }, + ], + seat: '4212', + }, + ], + cur: 'EUR', + }, + }; + + describe('isBidRequestValid', () => { + it('should return true if params.siteId and params.adUnitId are set', () => { + const bidRequest = { + params: { + siteId: 123, + adUnitId: 456, + }, + }; + expect(spec.isBidRequestValid(bidRequest)).to.equal(true); + }); + + it('should return false if params.siteId is missing', () => { + const bidRequest = { + params: { + adUnitId: 456, + }, + }; + expect(spec.isBidRequestValid(bidRequest)).to.equal(false); + }); + + it('should return false if params.adUnitId is missing', () => { + const bidRequest = { + params: { + siteId: 123, + }, + }; + expect(spec.isBidRequestValid(bidRequest)).to.equal(false); + }); + }); + + describe('buildRequests', () => { + it('should build correct requests using ORTB converter', () => { + const request = spec.buildRequests( + DEFAULT_BANNER_BID_REQUESTS, + DEFAULT_BANNER_BIDDER_REQUEST + ); + const dataFromConverter = converter.toORTB({ + bidderRequest: DEFAULT_BANNER_BIDDER_REQUEST, + bidRequests: DEFAULT_BANNER_BID_REQUESTS, + }); + expect(request[0]).to.deep.equal({ + data: { ...dataFromConverter, id: request[0].data.id }, + method: 'POST', + url: 'https://s2s.sportslocalmedia.com/openrtb2/auction', + }); + }); + + it('should add site.publisher.ext.prebid.parentAccount to request object when siteId is defined', () => { + const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0]; + expect(request.data.site.publisher.ext.prebid.parentAccount).to.equal(123); + }); + + it('should not add site.publisher.ext.prebid.parentAccount to request object when siteId is not defined', () => { + const bidRequests = [ + { ...DEFAULT_BANNER_BID_REQUESTS[0], params: { adUnitId: 456 } }, + ]; + const bidderRequest = { ...DEFAULT_BANNER_BIDDER_REQUEST, bids: bidRequests }; + const request = spec.buildRequests(bidRequests, bidderRequest)[0]; + expect(request.data.site.publisher.ext.prebid.parentAccount).to.be.undefined; + }); + + it('should add ext.akcelo to imp object when siteId and adUnitId are defined', () => { + const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0]; + expect(request.data.imp[0].ext.akcelo).to.deep.equal({ + siteId: 123, + adUnitId: 456, + }); + }); + + it('should not add ext.akcelo.siteId to imp object when siteId is not defined', () => { + const bidRequests = [ + { ...DEFAULT_BANNER_BID_REQUESTS[0], params: { adUnitId: 456 } }, + ]; + const bidderRequest = { ...DEFAULT_BANNER_BIDDER_REQUEST, bids: bidRequests }; + const request = spec.buildRequests(bidRequests, bidderRequest)[0]; + expect(request.data.imp[0].ext.akcelo.siteId).to.be.undefined; + expect(utils.logError.calledOnce).to.equal(true); + expect(utils.logError.args[0][0]).to.equal('Missing parameter : siteId') + }); + + it('should not add ext.akcelo.adUnitId to imp object when adUnitId is not defined', () => { + const bidRequests = [ + { ...DEFAULT_BANNER_BID_REQUESTS[0], params: { siteId: 123 } }, + ]; + const bidderRequest = { ...DEFAULT_BANNER_BIDDER_REQUEST, bids: bidRequests }; + const request = spec.buildRequests(bidRequests, bidderRequest)[0]; + expect(request.data.imp[0].ext.akcelo.adUnitId).to.be.undefined; + expect(utils.logError.calledOnce).to.equal(true); + expect(utils.logError.args[0][0]).to.equal('Missing parameter : adUnitId') + }); + + it('should add ext.akcelo.test=1 to imp object when param akcelo_demo is true', () => { + sandBox.stub(utils, 'getParameterByName').callsFake(() => 'true'); + const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0]; + expect(request.data.imp[0].ext.akcelo.test).to.equal(1); + }); + + it('should not add ext.akcelo.test to imp object when param akcelo_demo is not true', () => { + sandBox.stub(utils, 'getParameterByName').callsFake(() => 'something_else'); + const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0]; + expect(request.data.imp[0].ext.akcelo.test).to.be.undefined; + }); + + it('should not add ext.akcelo.test to imp object when param akcelo_demo is not defined', () => { + const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0]; + expect(request.data.imp[0].ext.akcelo.test).to.be.undefined; + }); + }); + + describe('interpretResponse', () => { + it('should return data returned by ORTB converter', () => { + const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0]; + const bids = spec.interpretResponse(SAMPLE_RESPONSE, request); + const responseFromConverter = converter.fromORTB({ + request: request.data, + response: SAMPLE_RESPONSE.body, + }); + expect(bids).to.deep.equal(responseFromConverter.bids); + }); + + it('should find the media type from bid.mtype if possible', () => { + const serverResponse = deepClone(SAMPLE_RESPONSE); + serverResponse.body.seatbid[0].bid[0].mtype = 2; + const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0]; + const bids = spec.interpretResponse(serverResponse, request); + expect(bids[0].mediaType).to.equal('video'); + }); + + it('should find the media type from bid.ext.prebid.type if mtype is not defined', () => { + const serverResponse = deepClone(SAMPLE_RESPONSE); + delete serverResponse.body.seatbid[0].bid[0].mtype; + const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0]; + const bids = spec.interpretResponse(serverResponse, request); + expect(bids[0].mediaType).to.equal('banner'); + }); + + it('should skip the bid if bid.mtype and bid.ext.prebid.type are not defined', () => { + const serverResponse = deepClone(SAMPLE_RESPONSE); + delete serverResponse.body.seatbid[0].bid[0].mtype; + delete serverResponse.body.seatbid[0].bid[0].ext.prebid.type; + const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0]; + const bids = spec.interpretResponse(serverResponse, request); + expect(bids).to.be.empty; + }); + }); + + describe('getUserSyncs', () => { + it('should return an empty array if iframe sync is not enabled', () => { + const syncs = spec.getUserSyncs({}, [SAMPLE_RESPONSE], null, null); + expect(syncs).to.deep.equal([]); + }); + + it('should return an array with iframe url', () => { + const syncs = spec.getUserSyncs({ iframeEnabled: true }, [SAMPLE_RESPONSE], null, null); + expect(syncs).to.deep.equal([{ + type: 'iframe', + url: 'https://ads.sportslocalmedia.com/load-cookie.html?endpoint=akcelo' + }]); + }); + + it('should return an array with iframe URL and GDPR parameters', () => { + const gdprConsent = { gdprApplies: true, consentString: 'the_gdpr_consent' }; + const syncs = spec.getUserSyncs({ iframeEnabled: true }, [SAMPLE_RESPONSE], gdprConsent, null); + expect(syncs[0].url).to.contain('?endpoint=akcelo&gdpr=1&gdpr_consent=the_gdpr_consent'); + }); + + it('should return an array with iframe URL containing empty GDPR parameters when GDPR does not apply', () => { + const gdprConsent = { gdprApplies: false }; + const syncs = spec.getUserSyncs({ iframeEnabled: true }, [SAMPLE_RESPONSE], gdprConsent, null); + expect(syncs[0].url).to.contain('?endpoint=akcelo&gdpr=0&gdpr_consent='); + }); + + it('should URI encode the GDPR consent string', () => { + const gdprConsent = { gdprApplies: true, consentString: 'the_gdpr_consent==' }; + const syncs = spec.getUserSyncs({ iframeEnabled: true }, [SAMPLE_RESPONSE], gdprConsent, null); + expect(syncs[0].url).to.contain('?endpoint=akcelo&gdpr=1&gdpr_consent=the_gdpr_consent%3D%3D'); + }); + + it('should return an array with iframe URL containing USP parameters when USP is defined', () => { + const uspConsent = 'the_usp_consent'; + const syncs = spec.getUserSyncs({ iframeEnabled: true }, [SAMPLE_RESPONSE], null, uspConsent); + expect(syncs[0].url).to.contain('?endpoint=akcelo&us_privacy=the_usp_consent'); + }); + + it('should URI encode the USP consent string', () => { + const uspConsent = 'the_usp_consent=='; + const syncs = spec.getUserSyncs({ iframeEnabled: true }, [SAMPLE_RESPONSE], null, uspConsent); + expect(syncs[0].url).to.contain('?endpoint=akcelo&us_privacy=the_usp_consent%3D%3D'); + }); + }); +});