diff --git a/modules/insticatorBidAdapter.js b/modules/insticatorBidAdapter.js new file mode 100644 index 00000000000..ed0bd480616 --- /dev/null +++ b/modules/insticatorBidAdapter.js @@ -0,0 +1,265 @@ +import {config} from '../src/config.js'; +import {BANNER} from '../src/mediaTypes.js'; +import {registerBidder} from '../src/adapters/bidderFactory.js'; +import { + cookiesAreEnabled, + deepAccess, + generateUUID, + getCookie, + localStorageIsEnabled, + logError, + setCookie, +} from '../src/utils.js'; + +const BIDDER_CODE = 'insticator'; +const ENDPOINT = 'https://ex.hunchme.com/v1/openrtb'; // staging endpoint! +const USER_ID_KEY = 'hb_insticator_uid'; +const USER_ID_COOKIE_EXP = 2592000000; // 30 days + +config.setDefaults({ + insticator: { + endpointUrl: ENDPOINT, + }, +}); + +function getUserId() { + let uid; + + if (localStorageIsEnabled()) { + uid = localStorage.getItem(USER_ID_KEY); + } else { + uid = getCookie(USER_ID_KEY); + } + + if (uid && uid.length !== 36) { + uid = undefined; + } + + return uid; +} + +function setUserId(userId) { + if (localStorageIsEnabled()) { + localStorage.setItem(USER_ID_KEY, userId); + } + + if (cookiesAreEnabled()) { + const expires = new Date(Date.now() + USER_ID_COOKIE_EXP).toISOString(); + setCookie(USER_ID_KEY, userId, expires); + } +} + +function buildImpression(bidRequest) { + const format = []; + const sizes = deepAccess(bidRequest, 'mediaTypes.banner.sizes') || bidRequest.sizes; + + for (const size of sizes) { + format.push({ + w: size[0], + h: size[1], + }) + } + + return { + id: bidRequest.bidId, + tagid: bidRequest.adUnitCode, + banner: { + format, + }, + ext: { + insticator: { + adUnitId: bidRequest.params.adUnitId, + }, + }, + }; +} + +function buildDevice() { + const device = { + w: window.innerWidth, + h: window.innerHeight, + js: true, + ext: { + localStorage: localStorageIsEnabled(), + cookies: cookiesAreEnabled(), + }, + }; + + const deviceConfig = config.getConfig('device'); + + if (typeof deviceConfig === 'object') { + Object.assign(device, deviceConfig); + } + + return device; +} + +function buildRegs(bidderRequest) { + if (bidderRequest.gdprConsent) { + return { + ext: { + gdpr: bidderRequest.gdprConsent.gdprApplies ? 1 : 0, + gdprConsentString: bidderRequest.gdprConsent.consentString, + }, + }; + } + + return {}; +} + +function buildUser() { + const userId = getUserId() || generateUUID(); + + setUserId(userId); + + return { + id: userId, + }; +} + +function buildRequest(validBidRequests, bidderRequest) { + const req = { + id: bidderRequest.bidderRequestId, + tmax: bidderRequest.timeout, + source: { + fd: 1, + tid: bidderRequest.auctionId, + }, + site: { + domain: location.hostname, + page: location.href, + ref: bidderRequest.refererInfo.referer, + }, + device: buildDevice(), + regs: buildRegs(bidderRequest), + user: buildUser(), + imp: validBidRequests.map(bidRequest => buildImpression(bidRequest)), + }; + + const params = config.getConfig('insticator.params'); + + if (params) { + req.ext = { + insticator: params, + }; + } + + return req; +} + +function buildBid(bid, bidderRequest) { + const originalBid = bidderRequest.bids.find(b => b.bidId === bid.impid); + + return { + requestId: bid.impid, + creativeId: bid.crid, + cpm: bid.price, + currency: 'USD', + netRevenue: true, + ttl: bid.exp, + width: bid.w, + height: bid.h, + mediaType: 'banner', + ad: bid.adm, + adUnitCode: originalBid.adUnitCode, + }; +} + +function buildBidSet(seatbid, bidderRequest) { + return seatbid.bid.map(bid => buildBid(bid, bidderRequest)); +} + +function validateSize(size) { + return (size instanceof Array) && + size.length === 2 && + typeof size[0] === 'number' && + typeof size[1] === 'number'; +} + +function validateSizes(sizes) { + return (sizes instanceof Array) && + sizes.length > 0 && + sizes.map(validateSize) + .reduce((a, b) => a && b, true); +} + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER], + + isBidRequestValid: function (bid) { + if (!bid.params.adUnitId) { + logError('insticator: missing adUnitId bid parameter'); + return false; + } + + if (!(BANNER in bid.mediaTypes)) { + logError('insticator: expected banner in mediaTypes'); + return false; + } + + if (!validateSizes(bid.sizes) && !validateSizes(bid.mediaTypes.banner.sizes)) { + logError('insticator: banner sizes not specified or invalid'); + return false; + } + + return true; + }, + + buildRequests: function (validBidRequests, bidderRequest) { + const requests = []; + + if (validBidRequests.length > 0) { + requests.push({ + method: 'POST', + url: config.getConfig('insticator.endpointUrl') || ENDPOINT, + options: { + contentType: 'application/json', + withCredentials: true, + }, + data: JSON.stringify(buildRequest(validBidRequests, bidderRequest)), + bidderRequest, + }); + } + + return requests; + }, + + interpretResponse: function (serverResponse, request) { + const bidderRequest = request.bidderRequest; + const body = serverResponse.body; + + if (!body || body.id !== bidderRequest.bidderRequestId) { + logError('insticator: response id does not match bidderRequestId'); + return []; + } + + if (!body.seatbid) { + return []; + } + + const bidsets = body.seatbid.map( + seatbid => buildBidSet(seatbid, bidderRequest), + ); + + return bidsets.reduce((a, b) => a.concat(b), []); + }, + + getUserSyncs: function (options, responses) { + const syncs = []; + + for (const response of responses) { + if ( + response.body && + response.body.ext && + response.body.ext.sync instanceof Array + ) { + syncs.push(...response.body.ext.sync); + } + } + + return syncs; + }, +}; + +registerBidder(spec); diff --git a/modules/insticatorBidAdapter.md b/modules/insticatorBidAdapter.md new file mode 100644 index 00000000000..da66739b333 --- /dev/null +++ b/modules/insticatorBidAdapter.md @@ -0,0 +1,49 @@ +Overview +======== + +``` +Module Name: Insticator Adapter +Module Type: Bidder Adapter +Maintainer: contact@insticator.com +``` + +Description +=========== + +This module connects publishers to Insticator exchange of demand sources through Prebid.js. + +### Supported Media Types + +| Type | Support +| --- | --- +| Banner | Fully supported for all approved sizes. + +# Bid Parameters + +Each of the Insticator-specific parameters provided under the `adUnits[].bids[].params` +object are detailed here. + +### Banner + +| Key | Scope | Type | Description +| --- | --- | --- | --- +| adUnitId | Required | String | The ad unit ID provided by Insticator. + + +# Test Parameters +``` + var adUnits = [ + { + code: 'test-div', + sizes: [[300, 250]], + bids: [ + { + bidder: 'insticator', + params: { + adUnitId: 'test' + } + } + ] + } + ] +``` diff --git a/modules/serverbidBidAdapter.js b/modules/serverbidBidAdapter.js index faeeafdd53f..6670c110958 100644 --- a/modules/serverbidBidAdapter.js +++ b/modules/serverbidBidAdapter.js @@ -13,9 +13,6 @@ const CONFIG = { 'onefiftytwo': { 'BASE_URI': 'https://e.serverbid.com/api/v2' }, - 'insticator': { - 'BASE_URI': 'https://e.serverbid.com/api/v2' - }, 'automatad': { 'BASE_URI': 'https://e.serverbid.com/api/v2' }, @@ -35,7 +32,7 @@ let bidder = 'serverbid'; export const spec = { code: BIDDER_CODE, - aliases: ['connectad', 'onefiftytwo', 'insticator', 'automatad', 'archon', 'buysellads', 'answermedia'], + aliases: ['connectad', 'onefiftytwo', 'automatad', 'archon', 'buysellads', 'answermedia'], /** * Determines whether or not the given bid request is valid. diff --git a/test/spec/modules/insticatorBidAdapter_spec.js b/test/spec/modules/insticatorBidAdapter_spec.js new file mode 100644 index 00000000000..f084199d934 --- /dev/null +++ b/test/spec/modules/insticatorBidAdapter_spec.js @@ -0,0 +1,339 @@ +import { expect } from 'chai'; +import * as utils from 'src/utils.js'; +import { spec } from 'modules/insticatorBidAdapter.js'; + +describe('Insticator bid adapter', () => { + const bidderRequestWithGDPR = { + auctionId: 'ccaabb112233', + bidderRequestId: '123d12231aa', + timeout: 200, + refererInfo: { + referer: 'http://domain.com/foo', + }, + gdprConsent: { + gdprApplies: 1, + consentString: 'foobar', + }, + }; + + const validBid = { + bidder: 'insticator', + params: { + adUnitId: '123456', + }, + sizes: [ + [300, 250], + [300, 600], + ], + mediaTypes: { + banner: { + sizes: [ + [300, 250], + [300, 600], + ], + }, + }, + adUnitCode: 'div-gpt-ad-837465923534-0', + transactionId: 'f160fc1d-3db4-4dbe-ab1d-b2814e5c2d57', + bidId: '1234abcd', + bidderRequestId: '123d12231aa', + auctionId: 'ccaabb112233', + }; + + const validBid2 = { + bidder: 'insticator', + params: { + adUnitId: '234567', + }, + sizes: [[300, 600]], + mediaTypes: { + banner: { + sizes: [[300, 600]], + }, + }, + adUnitCode: 'div-gpt-ad-4645345744-0', + transactionId: 'b47af70a-cecd-4974-8a01-50721d6033cb', + bidId: '2345abcd', + bidderRequestId: '123d12231aa', + auctionId: 'ccaabb112233', + }; + + const bidderRequest = { + auctionId: 'ccaabb112233', + bidderRequestId: '123d12231aa', + timeout: 200, + refererInfo: { + referer: 'http://domain.com/foo', + }, + bids: [validBid, validBid2], + }; + + const validResponse = { + id: '123d12231aa', + seatbid: [ + { + seat: 'insticator', + group: 0, + bid: [ + { + id: 'bid123456', + w: 300, + h: 250, + impid: '1234abcd', + price: 0.5, + exp: 300, + crid: '987654321', + adm: '
ad
', + }, + ], + }, + ], + ext: { + sync: [ + { + type: 'image', + url: 'http://ex.ingage.tech/sync/1234567', + }, + ], + }, + }; + + describe('isBidRequestValid()', () => { + it('should return true when required params found', () => { + expect(spec.isBidRequestValid(validBid)).to.equal(true); + }); + + it('should return true when mediaTypes.banner.sizes are missing, but sizes are specified', () => { + const bid = utils.deepClone(validBid); + delete bid.mediaTypes.banner.sizes; + + expect(spec.isBidRequestValid(bid)).to.equal(true); + }); + + it('should return true when sizes are missing, but mediaTypes.banner.sizes are specified', () => { + const bid = utils.deepClone(validBid); + delete bid.sizes; + + expect(spec.isBidRequestValid(bid)).to.equal(true); + }); + + it('should return false when adUnitId is not defined', () => { + const bid = utils.deepClone(validBid); + delete bid.params.adUnitId; + + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when there is no banner in mediaTypes', () => { + const bid = utils.deepClone(validBid); + delete bid.mediaTypes.banner; + + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when sizes are not specified', () => { + const bid = utils.deepClone(validBid); + delete bid.sizes; + delete bid.mediaTypes.banner.sizes; + + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when sizes are invalid', () => { + const bid = utils.deepClone(validBid); + delete bid.mediaTypes.banner.sizes; + + bid.sizes = [['123', 'foo']]; + + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + }); + + describe('buildRequests()', () => { + it('should build correct request', () => { + const requests = spec.buildRequests([validBid, validBid2], bidderRequest); + + expect(requests).to.have.lengthOf(1); + expect(requests[0]).to.deep.include({ + method: 'POST', + url: 'https://ex.ingage.tech/v1/openrtb', + options: { + contentType: 'application/json', + withCredentials: true, + }, + }); + + expect(JSON.parse(requests[0].data)).to.deep.include({ + id: '123d12231aa', + device: { + ext: { + cookies: true, + localStorage: true, + }, + js: true, + w: 100, + h: 100, + }, + source: { + fd: 1, + tid: 'ccaabb112233', + }, + tmax: 200, + imp: [ + { + banner: { + format: [ + { + w: 300, + h: 250, + }, + { + w: 300, + h: 600, + }, + ], + }, + ext: { + insticator: { + adUnitId: '123456', + }, + }, + id: '1234abcd', + tagid: 'div-gpt-ad-837465923534-0', + }, + { + banner: { + format: [ + { + w: 300, + h: 600, + }, + ], + }, + ext: { + insticator: { + adUnitId: '234567', + }, + }, + id: '2345abcd', + tagid: 'div-gpt-ad-4645345744-0', + }, + ], + }); + }); + + it('should include in request GDPR options if available', () => { + const requests = spec.buildRequests( + [validBid, validBid2], + bidderRequestWithGDPR, + ); + + expect(JSON.parse(requests[0].data)).to.deep.include({ + regs: { + ext: { + gdpr: 1, + gdprConsentString: 'foobar', + }, + }, + }); + }); + + it('should generate and pass user id', () => { + localStorage.removeItem('hb_insticator_uid'); + utils.setCookie('hb_insticator_uid'); + + const requests = spec.buildRequests([validBid, validBid2], bidderRequest); + const rtbRequest = JSON.parse(requests[0].data); + + expect(rtbRequest.user.id).to.have.lengthOf(36); + }); + + it('should pass user id if available', () => { + localStorage.setItem( + 'hb_insticator_uid', + '77016c8d-6c6e-40cb-8801-1060089b5c60', + ); + + const requests = spec.buildRequests([validBid, validBid2], bidderRequest); + const rtbRequest = JSON.parse(requests[0].data); + + expect(rtbRequest.user.id).to.equal( + '77016c8d-6c6e-40cb-8801-1060089b5c60', + ); + }); + + it('should regenerate user id if it is invalid', () => { + localStorage.setItem('hb_insticator_uid', 'foo'); + + const requests = spec.buildRequests([validBid, validBid2], bidderRequest); + const rtbRequest = JSON.parse(requests[0].data); + + expect(rtbRequest.user.id).to.have.lengthOf(36); + }); + }); + + describe('interpretResponse()', () => { + it('should correctly interpret valid response', () => { + const bids = spec.interpretResponse( + { body: validResponse }, + { bidderRequest }, + ); + + expect(bids).to.deep.equal([ + { + requestId: '1234abcd', + width: 300, + height: 250, + ttl: 300, + cpm: 0.5, + currency: 'USD', + creativeId: '987654321', + mediaType: 'banner', + netRevenue: true, + adUnitCode: 'div-gpt-ad-837465923534-0', + ad: '
ad
', + }, + ]); + }); + + it('should return not bids if response id does not match bidderRequestId', () => { + const body = utils.deepClone(validResponse); + body.id = '123'; + + const bids = spec.interpretResponse({ body }, { bidderRequest }); + + expect(bids).to.deep.equal([]); + }); + + it('should return not bids if response does not include seatbid', () => { + const body = utils.deepClone(validResponse); + delete body.seatbid; + + const bids = spec.interpretResponse({ body }, { bidderRequest }); + + expect(bids).to.deep.equal([]); + }); + + it('should return not bids if response does not include any bids', () => { + const body = utils.deepClone(validResponse); + body.seatbid = []; + + const bids = spec.interpretResponse({ body }, { bidderRequest }); + + expect(bids).to.deep.equal([]); + }); + }); + + describe('getUserSyncs()', () => { + it('should return user syncs if there are included in the response', () => { + const syncs = spec.getUserSyncs({}, [{ body: validResponse }]); + + expect(syncs).to.deep.equal([ + { + type: 'image', + url: 'http://ex.ingage.tech/sync/1234567', + }, + ]); + }); + }); +});