From 81a9e50cc2c666630b372363d795f39b241dd9b5 Mon Sep 17 00:00:00 2001 From: Snigel <108489367+snigelweb@users.noreply.github.com> Date: Fri, 5 Aug 2022 16:04:41 +0200 Subject: [PATCH] Snigel Bid Adapter: initial adapter release (#8723) * add snigel prebid adapter, docs and tests * fix small oversight * improve user sync behavior * implement PR feedback, add support for schain, floors and user IDs --- modules/snigelBidAdapter.js | 150 +++++++++++ modules/snigelBidAdapter.md | 46 ++++ test/spec/modules/snigelBidAdapter_spec.js | 296 +++++++++++++++++++++ 3 files changed, 492 insertions(+) create mode 100644 modules/snigelBidAdapter.js create mode 100644 modules/snigelBidAdapter.md create mode 100644 test/spec/modules/snigelBidAdapter_spec.js diff --git a/modules/snigelBidAdapter.js b/modules/snigelBidAdapter.js new file mode 100644 index 00000000000..e0ec10c6ed6 --- /dev/null +++ b/modules/snigelBidAdapter.js @@ -0,0 +1,150 @@ +import {config} from '../src/config.js'; +import {registerBidder} from '../src/adapters/bidderFactory.js'; +import {BANNER} from '../src/mediaTypes.js'; +import {deepAccess, isArray, isFn, isPlainObject} from '../src/utils.js'; +import {hasPurpose1Consent} from '../src/utils/gpdr.js'; + +const BIDDER_CODE = 'snigel'; +const GVLID = 1076; +const DEFAULT_URL = 'https://adserv.snigelweb.com/bp/v1/prebid'; +const DEFAULT_TTL = 60; +const DEFAULT_CURRENCIES = ['USD']; +const FLOOR_MATCH_ALL_SIZES = '*'; + +const getConfig = config.getConfig; + +export const spec = { + code: BIDDER_CODE, + gvlid: GVLID, + supportedMediaTypes: [BANNER], + + isBidRequestValid: function (bidRequest) { + return !!bidRequest.params.placement; + }, + + buildRequests: function (bidRequests, bidderRequest) { + const gdprApplies = deepAccess(bidderRequest, 'gdprConsent.gdprApplies'); + return { + method: 'POST', + url: getEndpoint(), + data: JSON.stringify({ + id: bidderRequest.bidderRequestId, + cur: getCurrencies(), + test: getTestFlag(), + devw: window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth, + devh: window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight, + version: $$PREBID_GLOBAL$$.version, + gdprApplies: gdprApplies, + gdprConsentString: gdprApplies === true ? deepAccess(bidderRequest, 'gdprConsent.consentString') : undefined, + gdprConsentProv: gdprApplies === true ? deepAccess(bidderRequest, 'gdprConsent.addtlConsent') : undefined, + uspConsent: deepAccess(bidderRequest, 'uspConsent'), + coppa: getConfig('coppa'), + eids: deepAccess(bidRequests, '0.userIdAsEids'), + schain: deepAccess(bidRequests, '0.schain'), + page: getPage(bidderRequest), + placements: bidRequests.map((r) => { + return { + uuid: r.bidId, + name: r.params.placement, + sizes: r.sizes, + floor: getPriceFloor(r, BANNER, FLOOR_MATCH_ALL_SIZES), + }; + }), + }), + bidderRequest, + }; + }, + + interpretResponse: function (serverResponse) { + if (!serverResponse.body || !serverResponse.body.bids) { + return []; + } + + return serverResponse.body.bids.map((bid) => { + return { + requestId: bid.uuid, + cpm: bid.price, + creativeId: bid.crid, + currency: serverResponse.body.cur, + width: bid.width, + height: bid.height, + ad: bid.ad, + netRevenue: true, + ttl: bid.ttl || DEFAULT_TTL, + meta: bid.meta, + }; + }); + }, + + getUserSyncs: function (syncOptions, responses, gdprConsent, uspConsent) { + const syncUrl = getSyncUrl(responses || []); + if (syncUrl && syncOptions.iframeEnabled && hasSyncConsent(gdprConsent, uspConsent)) { + return [{type: 'iframe', url: getSyncEndpoint(syncUrl, gdprConsent)}]; + } + }, +}; + +registerBidder(spec); + +function getPage(bidderRequest) { + return ( + getConfig(`${BIDDER_CODE}.page`) || deepAccess(bidderRequest, 'refererInfo.canonicalUrl') || window.location.href + ); +} + +function getEndpoint() { + return getConfig(`${BIDDER_CODE}.url`) || DEFAULT_URL; +} + +function getTestFlag() { + return getConfig(`${BIDDER_CODE}.test`) === true; +} + +function getCurrencies() { + const currencyOverrides = getConfig(`${BIDDER_CODE}.cur`); + if (currencyOverrides !== undefined && (!isArray(currencyOverrides) || currencyOverrides.length === 0)) { + throw Error('Currency override must be an array with at least one currency'); + } + return currencyOverrides || DEFAULT_CURRENCIES; +} + +function getFloorCurrency() { + return getConfig(`${BIDDER_CODE}.floorCur`) || getCurrencies()[0]; +} + +function getPriceFloor(bidRequest, mediaType, size) { + if (isFn(bidRequest.getFloor)) { + const cur = getFloorCurrency(); + const floorInfo = bidRequest.getFloor({ + currency: cur, + mediaType: mediaType, + size: size, + }); + if (isPlainObject(floorInfo) && !isNaN(floorInfo.floor)) { + return { + cur: floorInfo.currency || cur, + value: floorInfo.floor, + }; + } + } +} + +function hasSyncConsent(gdprConsent, uspConsent) { + if (gdprConsent?.gdprApplies && !hasPurpose1Consent(gdprConsent)) { + return false; + } else if (uspConsent && uspConsent[1] === 'Y' && uspConsent[2] === 'Y') { + return false; + } else { + return true; + } +} + +function getSyncUrl(responses) { + return getConfig(`${BIDDER_CODE}.syncUrl`) || deepAccess(responses[0], 'body.syncUrl'); +} + +function getSyncEndpoint(url, gdprConsent) { + return `${url}?gdpr=${gdprConsent?.gdprApplies ? 1 : 0}&gdpr_consent=${encodeURIComponent( + gdprConsent?.consentString || '' + )}`; +} diff --git a/modules/snigelBidAdapter.md b/modules/snigelBidAdapter.md new file mode 100644 index 00000000000..a83e133144f --- /dev/null +++ b/modules/snigelBidAdapter.md @@ -0,0 +1,46 @@ +# Overview + +- Module name: Snigel Bid Adapter +- Module type: Bidder Adapter +- Maintainer: devops@snigel.com +- Bidder code: snigel +- Supported media types: Banner + +# Description + +Connects to Snigel demand sources for bids. + +**Note:** This bid adapter requires our ad operation experts to create an optimized setup for the desired placements on your property. +Please reach out to us [through our contact form](https://snigel.com/get-in-touch). We will reply as soon as possible. + +# Parameters + +| Name | Required | Description | Example | +| :--- | :-------- | :---------- | :------ | +| placement | Yes | Placement identifier | top_leaderboard | + +# Test + +```js +var adUnits = [ + { + code: "example", + mediaTypes: { + banner: { + sizes: [ + [970, 90], + [728, 90], + ], + }, + }, + bids: [ + { + bidder: "snigel", + params: { + placement: "prebid_test_placement", + }, + }, + ], + }, +]; +``` diff --git a/test/spec/modules/snigelBidAdapter_spec.js b/test/spec/modules/snigelBidAdapter_spec.js new file mode 100644 index 00000000000..3fc09493f03 --- /dev/null +++ b/test/spec/modules/snigelBidAdapter_spec.js @@ -0,0 +1,296 @@ +import {expect} from 'chai'; +import {spec} from 'modules/snigelBidAdapter.js'; +import {config} from 'src/config.js'; +import {isValid} from 'src/adapters/bidderFactory.js'; + +const BASE_BID_REQUEST = { + adUnitCode: 'top_leaderboard', + bidId: 'bid_test', + sizes: [ + [970, 90], + [728, 90], + ], + bidder: 'snigel', + params: {}, + requestId: 'req_test', + transactionId: 'trans_test', +}; +const makeBidRequest = function (overrides) { + return {...BASE_BID_REQUEST, ...overrides}; +}; + +const BASE_BIDDER_REQUEST = { + bidderRequestId: 'test', + refererInfo: { + canonicalUrl: 'https://localhost', + }, +}; +const makeBidderRequest = function (overrides) { + return {...BASE_BIDDER_REQUEST, ...overrides}; +}; + +const DUMMY_USP_CONSENT = '1YYN'; +const DUMMY_GDPR_CONSENT_STRING = + 'BOSSotLOSSotLAPABAENBc-AAAAgR7_______9______9uz_Gv_v_f__33e8__9v_l_7_-___u_-33d4-_1vX99yfm1-7ftr3tp_86ues2_XqK_9oIiA'; + +describe('snigelBidAdapter', function () { + describe('isBidRequestValid', function () { + it('should return false if no placement provided', function () { + expect(spec.isBidRequestValid(BASE_BID_REQUEST)).to.equal(false); + }); + + it('should return true if placement provided', function () { + const bidRequest = makeBidRequest({params: {placement: 'top_leaderboard'}}); + expect(spec.isBidRequestValid(bidRequest)).to.equal(true); + }); + }); + + describe('buildRequests', function () { + afterEach(function () { + config.resetConfig(); + }); + + it('should build a single request for every impression and its placement', function () { + const bidderRequest = Object.assign({}, BASE_BIDDER_REQUEST); + const bidRequests = [ + makeBidRequest({bidId: 'a', params: {placement: 'top_leaderboard'}}), + makeBidRequest({bidId: 'b', params: {placement: 'bottom_leaderboard'}}), + ]; + + const request = spec.buildRequests(bidRequests, bidderRequest); + expect(request).to.be.an('object'); + expect(request).to.have.property('url').and.to.equal('https://adserv.snigelweb.com/bp/v1/prebid'); + expect(request).to.have.property('method').and.to.equal('POST'); + + expect(request).to.have.property('data'); + const data = JSON.parse(request.data); + expect(data).to.have.property('id').and.to.equal('test'); + expect(data).to.have.property('cur').and.to.deep.equal(['USD']); + expect(data).to.have.property('test').and.to.equal(false); + expect(data).to.have.property('page').and.to.equal('https://localhost'); + expect(data).to.have.property('placements'); + expect(data.placements.length).to.equal(2); + expect(data.placements[0].uuid).to.equal('a'); + expect(data.placements[0].name).to.equal('top_leaderboard'); + expect(data.placements[1].uuid).to.equal('b'); + expect(data.placements[1].name).to.equal('bottom_leaderboard'); + }); + + it('should forward GDPR flag and GDPR consent string if enabled', function () { + const bidderRequest = makeBidderRequest({ + gdprConsent: { + gdprApplies: true, + consentString: DUMMY_GDPR_CONSENT_STRING, + }, + }); + + const request = spec.buildRequests([], bidderRequest); + expect(request).to.have.property('data'); + const data = JSON.parse(request.data); + expect(data).to.have.property('gdprApplies').and.to.equal(true); + expect(data).to.have.property('gdprConsentString').and.to.equal(DUMMY_GDPR_CONSENT_STRING); + }); + + it('should forward GDPR flag and no GDPR consent string if disabled', function () { + const bidderRequest = makeBidderRequest({ + gdprConsent: { + gdprApplies: false, + consentString: DUMMY_GDPR_CONSENT_STRING, + }, + }); + + const request = spec.buildRequests([], bidderRequest); + expect(request).to.have.property('data'); + const data = JSON.parse(request.data); + expect(data).to.have.property('gdprApplies').and.to.equal(false); + expect(data).to.not.have.property('gdprConsentString'); + }); + + it('should forward USP consent if set', function () { + const bidderRequest = makeBidderRequest({ + uspConsent: DUMMY_USP_CONSENT, + }); + + const request = spec.buildRequests([], bidderRequest); + expect(request).to.have.property('data'); + const data = JSON.parse(request.data); + expect(data).to.have.property('uspConsent').and.to.equal(DUMMY_USP_CONSENT); + }); + + it('should forward whether or not COPPA applies', function () { + config.setConfig({ + 'coppa': true, + }); + + const request = spec.buildRequests([], BASE_BIDDER_REQUEST); + expect(request).to.have.property('data'); + const data = JSON.parse(request.data); + expect(data).to.have.property('coppa').and.to.equal(true); + }); + }); + + describe('interpretResponse', function () { + it('should not return any bids if the request failed', function () { + expect(spec.interpretResponse({}, {})).to.be.empty; + expect(spec.interpretResponse({body: 'Some error message'}, {})).to.be.empty; + }); + + it('should not return any bids if the request did not return any bids either', function () { + expect(spec.interpretResponse({body: {bids: []}})).to.be.empty; + }); + + it('should return valid bids with additional meta information', function () { + const serverResponse = { + body: { + id: BASE_BIDDER_REQUEST.bidderRequestId, + cur: 'USD', + bids: [ + { + uuid: BASE_BID_REQUEST.bidId, + price: 0.0575, + ad: '