diff --git a/modules/retailspotBidAdapter .md b/modules/retailspotBidAdapter .md new file mode 100644 index 00000000000..a9b4cb4bec3 --- /dev/null +++ b/modules/retailspotBidAdapter .md @@ -0,0 +1,32 @@ +# Overview + +Module Name: RetailSpot Bidder Adapter +Module Type: Bidder Adapter +Maintainer: guillaume@retail-spot.io + +# Description + +Module that connects to RetailSpot demand sources. +Banner and Video ad formats are supported. + +# Test Parameters +``` + var adUnits = { + "code": "test-div", + "mediaTypes": { + "banner": { + "sizes": ["300x250"] + }, + "video": { + context: "instream", + playerSize: [[640, 480]] + } + }, + bids: [{ + bidder: "retailspot", + params: { + placement: "test-12345" + } + }] + }; +``` diff --git a/modules/retailspotBidAdapter.js b/modules/retailspotBidAdapter.js new file mode 100644 index 00000000000..616b638e840 --- /dev/null +++ b/modules/retailspotBidAdapter.js @@ -0,0 +1,186 @@ +import {buildUrl, deepAccess, parseSizesInput} from '../src/utils.js'; +import {registerBidder} from '../src/adapters/bidderFactory.js'; +import {BANNER, VIDEO} from '../src/mediaTypes.js'; + +const BIDDER_CODE = 'retailspot'; +const DEFAULT_SUBDOMAIN = 'ssp'; +const PREPROD_SUBDOMAIN = 'ssp-preprod'; +const HOST = 'retail-spot.io'; +const ENDPOINT = '/prebid'; +const DEV_URL = 'http://localhost:8090/prebid'; + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO], + aliases: ['rs'], // short code + /** + * Determines whether or not the given bid request is valid. + * + * @param {BidRequest} bid The bid params to validate. + * @return boolean True if this is a valid bid, and false otherwise. + */ + isBidRequestValid: function (bid) { + const sizes = getSize(getSizeArray(bid)); + const sizeValid = sizes.width > 0 && sizes.height > 0; + + return deepAccess(bid, 'params.placement') && sizeValid; + }, + /** + * Make a server request from the list of BidRequests. + * + * @param {bidRequests} - bidRequests.bids[] is an array of AdUnits and bids + * @return ServerRequest Info describing the request to the server. + */ + buildRequests: function (bidRequests, bidderRequest) { + const payload = bidderRequest; + payload.rs_pbjs_version = '$prebid.version$'; + + const data = JSON.stringify(payload); + const options = { + withCredentials: true + }; + + const envParam = bidRequests[0].params.env; + var subDomain = DEFAULT_SUBDOMAIN; + if (envParam === 'preprod') { + subDomain = PREPROD_SUBDOMAIN; + } + + let url = buildUrl({ + protocol: 'https', + host: `${subDomain}.${HOST}`, + pathname: ENDPOINT + }); + + if (envParam === 'dev') { + url = DEV_URL; + } + + return { + method: 'POST', + url, + data, + options + }; + }, + /** + * Unpack the response from the server into a list of bids. + * + * @param {*} serverResponse A successful response from the server. + * @return {Bid[]} An array of bids which were nested inside the server. + */ + interpretResponse: function (serverResponse, request) { + const bidResponses = []; + var bidRequests = {}; + + try { + bidRequests = JSON.parse(request.data).bids; + } catch (err) { + // json error initial request can't be read + } + + // For this adapter, serverResponse is a list + serverResponse.body.forEach(response => { + const bid = createBid(response, bidRequests); + if (bid) { + bidResponses.push(bid); + } + }); + + return bidResponses; + } +} + +function getSizeArray(bid) { + let inputSize = bid.sizes || []; + + if (bid.mediaTypes && bid.mediaTypes.banner) { + inputSize = bid.mediaTypes.banner.sizes || []; + } + + // handle size in bid.params in formats: [w, h] and [[w,h]]. + if (bid.params && Array.isArray(bid.params.size)) { + inputSize = bid.params.size; + if (!Array.isArray(inputSize[0])) { + inputSize = [inputSize] + } + } + + return parseSizesInput(inputSize); +} + +/* Get parsed size from request size */ +function getSize(sizesArray) { + const parsed = {}; + // the main requested size is the first one + const size = sizesArray[0]; + + if (typeof size !== 'string') { + return parsed; + } + + const parsedSize = size.toUpperCase().split('X'); + const width = parseInt(parsedSize[0], 10); + if (width) { + parsed.width = width; + } + + const height = parseInt(parsedSize[1], 10); + if (height) { + parsed.height = height; + } + + return parsed; +} + +/* Create bid from response */ +function createBid(response, bidRequests) { + if (!response || !response.mediaType || + (response.mediaType === 'video' && !response.vastXml) || + (response.mediaType === 'banner' && !response.ad)) { + return; + } + + const request = bidRequests && bidRequests.length && bidRequests.find(itm => response.requestId === itm.bidId); + // In case we don't retreive the size from the adserver, use the given one. + if (request) { + if (!response.width || response.width === '0') { + response.width = request.width; + } + + if (!response.height || response.height === '0') { + response.height = request.height; + } + } + + const bid = { + bidderCode: BIDDER_CODE, + width: response.width, + height: response.height, + requestId: response.requestId, + ttl: response.ttl || 3600, + creativeId: response.creativeId, + cpm: response.cpm, + netRevenue: response.netRevenue, + currency: response.currency, + meta: response.meta || { advertiserDomains: ['retail-spot.io'] }, + mediaType: response.mediaType + }; + + // retreive video response if present + if (response.mediaType === 'video') { + bid.vastXml = window.atob(response.vastXml); + } else { + bid.ad = response.ad; + } + if (response.adId) { + bid.adId = response.adId; + } + if (response.dealId) { + bid.dealId = response.dealId; + } + + return bid; +} + +registerBidder(spec); diff --git a/test/spec/modules/retailspotBidAdapter_spec.js b/test/spec/modules/retailspotBidAdapter_spec.js new file mode 100644 index 00000000000..39cddb323b8 --- /dev/null +++ b/test/spec/modules/retailspotBidAdapter_spec.js @@ -0,0 +1,426 @@ +import { expect } from 'chai'; + +import { spec } from 'modules/retailspotBidAdapter.js'; +import { newBidder } from 'src/adapters/bidderFactory.js'; + +describe('RetailSpot Adapter', function () { + const canonicalUrl = 'https://canonical.url/?t=%26'; + const referrerUrl = 'http://referrer.url/?param=value'; + const pageUrl = 'http://page.url/?param=value'; + const domain = 'domain:123'; + const env = 'preprod'; + const consentString = 'BOJ8RZsOJ8RZsABAB8AAAAAZ+A=='; + const bidderRequest = { + 'auctionId': '1d1a030790a475', + 'bidderRequestId': '22edbae2733bf6', + 'timeout': 3000, + 'gdprConsent': { + consentString: consentString, + gdprApplies: true + }, + refererInfo: {location: referrerUrl, canonicalUrl, domain, topmostLocation: 'fakePageURL'}, + ortb2: {site: {page: pageUrl, ref: referrerUrl}} + }; + + const bidRequestWithSinglePlacement = [ + { + 'bidId': 'bid_id_0', + 'bidder': 'retailspot', + 'placementCode': 'adunit/hb-0', + 'params': { + 'placement': 'placement_0' + }, + 'sizes': '300x250', + 'mediaTypes': { + 'banner': { + 'sizes': ['300x250'] + }, + }, + 'transactionId': 'bid_id_0_transaction_id' + } + ]; + + const bidRequestWithMultipleMediatype = [ + { + 'bidId': 'bid_id_0', + 'bidder': 'retailspot', + 'placementCode': 'adunit/hb-0', + 'params': { + 'placement': 'placement_0' + }, + 'sizes': '300x250', + 'mediaTypes': { + 'banner': { + 'sizes': ['640x480'] + }, + 'video': { + 'playerSize': [640, 480], + 'context': 'outstream' + } + }, + 'transactionId': 'bid_id_0_transaction_id' + } + ]; + + const sentBidVideo = [ + { + 'bidId': 'bid_id_0', + 'placement': 'test-1234', + 'video': { + 'playerSize': [640, 480] + } + } + ]; + + const bidRequestWithDevPlacement = [ + { + 'bidId': 'bid_id_0', + 'bidder': 'retailspot', + 'placementCode': 'adunit/hb-0', + 'params': { + 'placement': 'placement_0', + 'env': 'dev' + }, + 'sizes': '300x250', + 'mediaTypes': + { 'banner': + {'sizes': ['300x250'] + } + }, + 'transactionId': 'bid_id_0_transaction_id' + } + ]; + + const bidRequestMultiPlacements = [ + { + 'bidId': 'bid_id_0', + 'bidder': 'retailspot', + 'placementCode': 'adunit/hb-0', + 'params': { + 'placement': 'placement_0' + }, + 'sizes': '300x250', + 'mediaTypes': + { 'banner': + {'sizes': ['300x250'] + } + }, + 'transactionId': 'bid_id_0_transaction_id' + }, + { + 'bidId': 'bid_id_1', + 'bidder': 'retailspot', + 'placementCode': 'adunit/hb-1', + 'params': { + 'placement': 'placement_1' + }, + 'sizes': [[300, 600]], + 'mediaTypes': + { 'banner': + {'sizes': ['300x600'] + } + }, + 'transactionId': 'bid_id_1_transaction_id' + }, + { + 'bidId': 'bid_id_2', + 'bidder': 'retailspot', + 'placementCode': 'adunit/hb-2', + 'params': {}, + 'sizes': '300x400', + 'transactionId': 'bid_id_2_transaction_id' + }, + { + 'bidId': 'bid_id_3', + 'bidder': 'retailspot', + 'placementCode': 'adunit/hb-3', + 'params': { + 'placement': 'placement_3' + }, + 'transactionId': 'bid_id_3_transaction_id' + } + ]; + + const requestDataOnePlacement = [ + { + 'bidId': 'bid_id_0', + 'placement': 'e622af275681965d3095808561a1e510', + 'width': 300, + 'height': 600 + } + ] + + const requestDataMultiPlacement = [ + { + 'bidId': 'bid_id_0', + 'placement': 'e622af275681965d3095808561a1e510', + 'width': 300, + 'height': 600 + }, + { + 'bidId': 'bid_id_1', + 'placement': 'e622af275681965d3095808561a1e510', + 'width': 400, + 'height': 250 + } + ] + + const testMetaObject = { + 'networkId': 123, + 'advertiserId': '3', + 'advertiserName': 'foobar', + 'advertiserDomains': ['foobar.com'], + 'brandId': '345', + 'brandName': 'Foo', + 'primaryCatId': '34', + 'secondaryCatIds': ['IAB-222', 'IAB-333'], + 'mediaType': 'banner' + }; + const admSample = "\u003cscript id=\"ayl-prebid-a11a121205932e75e622af275681965d\"\u003e\n(function(){\n\twindow.isPrebid = true\n\tvar prebidResults = /*PREBID*/{\"OnEvents\": {\"CLICK\": [{\"Kind\": \"PIXEL_URL\",\"Url\": \"https://testPixelCLICK.com/fake\"}],\"IMPRESSION\": [{\"Kind\": \"PIXEL_URL\",\"Url\": \"https://testPixelIMP.com/fake\"}, {\"Kind\": \"JAVASCRIPT_URL\",\"Url\": \"https://testJsIMP.com/fake.js\"}]},\"Disabled\": false,\"Attempt\": \"a11a121205932e75e622af275681965d\",\"ApiPrefix\": \"https://fo-api.omnitagjs.com/fo-api\",\"TrackingPrefix\": \"https://tracking.omnitagjs.com/tracking\",\"DynamicPrefix\": \"https://tag-dyn.omnitagjs.com/fo-dyn\",\"StaticPrefix\": \"https://fo-static.omnitagjs.com/fo-static\",\"BlobPrefix\": \"https://fo-api.omnitagjs.com/fo-api/blobs\",\"SspPrefix\": \"https://fo-ssp.omnitagjs.com/fo-ssp\",\"VisitorPrefix\": \"https://visitor.omnitagjs.com/visitor\",\"Trusted\": true,\"Placement\": \"e622af275681965d3095808561a1e510\",\"PlacementAccess\": \"ALL\",\"Site\": \"6e2df7a92203c3c7a25561ed63f25a27\",\"Lang\": \"EN\",\"SiteLogo\": null,\"HasSponsorImage\": true,\"ResizeIframe\": true,\"IntegrationConfig\": {\"Kind\": \"WIDGET\",\"Widget\": {\"ExtraStyleSheet\": \"\",\"Placeholders\": {\"Body\": {\"Color\": {\"R\": 77,\"G\": 21,\"B\": 82,\"A\": 100},\"BackgroundColor\": {\"R\": 255,\"G\": 255,\"B\": 255,\"A\": 100},\"FontFamily\": \"Lato\",\"Width\": \"100%\",\"Align\": \"\",\"BoxShadow\": true},\"CallToAction\": {\"Color\": {\"R\": 26,\"G\": 157,\"B\": 212,\"A\": 100}},\"Description\": {\"Length\": 130},\"Image\": {\"Width\": 600,\"Height\": 600,\"Lowres\": false,\"Raw\": false},\"Size\": {\"Height\": \"250px\",\"Width\": \"300px\"},\"Title\": {\"Color\": {\"R\": 219,\"G\": 181,\"B\": 255,\"A\": 100}}},\"Selector\": {\"Kind\": \"CSS\",\"Css\": \"#ayl-prebid-a11a121205932e75e622af275681965d\"},\"Insertion\": \"AFTER\",\"ClickFormat\": true,\"Creative20\": true,\"WidgetKind\": \"CREATIVE_TEMPLATE_4\"}},\"Legal\": \"Sponsored\",\"ForcedCampaign\": \"f1c80d4bb5643c222ae8de75e9b2f991\",\"ForcedTrack\": \"\",\"ForcedCreative\": \"\",\"ForcedSource\": \"\",\"DisplayMode\": \"DEFAULT\",\"Campaign\": \"f1c80d4bb5643c222ae8de75e9b2f991\",\"CampaignAccess\": \"ALL\",\"CampaignKind\": \"AD_TRAFFIC\",\"DataSource\": \"LOCAL\",\"DataSourceUrl\": \"\",\"DataSourceOnEventsIsolated\": false,\"DataSourceWithoutCookie\": false,\"Content\": {\"Preview\": {\"Thumbnail\": {\"Image\": {\"Kind\": \"EXTERNAL\",\"Data\": {\"External\": {\"Url\": \"https://tag-dyn.omnitagjs.com/fo-dyn/native/preview/image?key=fd4362d35bb174d6f1c80d4bb5643c22\\u0026kind=INTERNAL\\u0026ztop=0.000000\\u0026zleft=0.000000\\u0026zwidth=0.333333\\u0026zheight=1.000000\\u0026width=[width]\\u0026height=[height]\"}},\"ZoneTop\": 0,\"ZoneLeft\": 0,\"ZoneWidth\": 1,\"ZoneHeight\": 1,\"Smart\": false,\"NoTransform\": false,\"Quality\": \"NORMAL\"}},\"Text\": {\"CALLTOACTION\": \"Click here to learn more\",\"DESCRIPTION\": \"Considérant l'extrémité conjoncturelle, il serait bon d'anticiper toutes les voies de bon sens.\",\"SPONSOR\": \"Tested by\",\"TITLE\": \"Adserver Traffic Redirect Internal\"},\"Sponsor\": {\"Name\": \"QA Team\",\"Logo\": {\"Resource\": {\"Kind\": \"EXTERNAL\",\"Data\": {\"External\": {\"Url\": \"https://fo-static.omnitagjs.com/fo-static/native/images/info-ayl.svg\"}},\"ZoneTop\": 0,\"ZoneLeft\": 0,\"ZoneWidth\": 1,\"ZoneHeight\": 1,\"Smart\": false,\"NoTransform\": false,\"Quality\": \"NORMAL\"}}},\"Credit\": {\"Logo\": {\"Resource\": {\"Kind\": \"EXTERNAL\",\"Data\": {\"External\": {\"Url\": \"https://fo-static.omnitagjs.com/fo-static/native/images/info-ayl.png\"}},\"ZoneTop\": 0,\"ZoneLeft\": 0,\"ZoneWidth\": 1,\"ZoneHeight\": 1,\"Smart\": false,\"NoTransform\": false,\"Quality\": \"NORMAL\"}},\"Url\": \"https://blobs.omnitagjs.com/adchoice/\"}},\"Landing\": {\"Url\": \"https://www.w3.org/People/mimasa/test/xhtml/entities/entities-11.xhtml#lat1\",\"LegacyTracking\": false},\"ViewButtons\": {\"Close\": {\"Skip\": 6000}},\"InternalContentFields\": {\"AnimatedImage\": false}},\"AdDomain\": \"retailspot.com\",\"Opener\": \"REDIRECT\",\"PerformUITriggers\": [\"CLICK\"],\"RedirectionTarget\": \"TAB\"}/*PREBID*/;\n\tvar insertAds = function insertAds() {\insertAds();\n\t}\n})();\n\u003c/script\u003e"; + const responseWithSinglePlacement = [ + { + 'requestId': 'bid_id_0', + 'placement': 'placement_0', + 'ad': admSample, + 'cpm': 0.5, + 'height': 250, + 'width': 300, + 'meta': testMetaObject, + 'mediaType': 'banner' + } + ]; + + const responseWithSingleVideo = [{ + 'requestId': 'bid_id_0', + 'placement': 'placement_0', + 'vastXml': 'PFZBU1Q+RW1wdHkgc2FtcGxlPC92YXN0Pg==', + 'cpm': 0.5, + 'height': 300, + 'width': 530, + 'mediaType': 'video', + 'creativeId': 'testvideo123', + 'netRevenue': true, + 'currency': 'USD', + 'adId': 'fakeAdID', + 'dealId': 'fakeDealId' + }]; + + const videoResult = [{ + bidderCode: 'retailspot', + cpm: 0.5, + creativeId: 'testvideo123', + currency: 'USD', + height: 300, + netRevenue: true, + requestId: 'bid_id_0', + ttl: 3600, + mediaType: 'video', + meta: { + advertiserDomains: ['retail-spot.io'] + }, + vastXml: 'Empty sample', + width: 530, + adId: 'fakeAdID', + dealId: 'fakeDealId' + }]; + + const responseWithMultiplePlacements = [ + { + 'requestId': 'bid_id_0', + 'mediaType': 'banner', + 'placement': 'placement_0', + 'ad': 'placement_0', + 'cpm': 0.5, + 'height': 0, // test with wrong value + 'width': 300, + }, + { + 'requestId': 'bid_id_1', + 'mediaType': 'banner', + 'placement': 'placement_1', + 'ad': 'placement_1', + 'cpm': 0.6, + 'height': 250 + // 'width' test with missing value + } + ]; + const adapter = newBidder(spec); + + const DEV_URL = 'http://localhost:8090/'; + + describe('inherited functions', function () { + it('exists and is a function', function () { + expect(adapter.callBids).to.exist.and.to.be.a('function'); + }); + }); + + describe('isBidRequestValid', function () { + let bid = { + 'bidId': 'bid_id_1', + 'bidder': 'retailspot', + 'placementCode': 'adunit/hb-1', + 'params': { + 'placement': 'placement_1' + }, + 'sizes': [[300, 600]], + 'transactionId': 'bid_id_1_transaction_id' + }; + + let bidWSize = { + 'bidId': 'bid_id_1', + 'bidder': 'retailspot', + 'placementCode': 'adunit/hb-1', + 'params': { + 'placement': 'placement_1', + 'size': [250, 300], + }, + 'transactionId': 'bid_id_1_transaction_id' + }; + + it('should return true when required params found', function () { + expect(!!spec.isBidRequestValid(bid)).to.equal(true); + }); + + it('should return true when required params found with size in bid params', function () { + expect(!!spec.isBidRequestValid(bidWSize)).to.equal(true); + }); + + it('should return false when required params are not passed', function () { + let bid = Object.assign({}, bid); + delete bid.size; + + expect(!!spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when required params are not passed', function () { + let bid = Object.assign({}, bid); + delete bid.params; + bid.params = { + 'placement': 0 + }; + expect(!!spec.isBidRequestValid(bid)).to.equal(false); + }); + }); + + describe('buildRequests', function () { + it('should add gdpr/usp consent information to the request', function () { + let consentString = 'BOJ8RZsOJ8RZsABAB8AAAAAZ+A=='; + let uspConsentData = '1YCC'; + let bidderRequest = { + 'auctionId': '1d1a030790a475', + 'bidderRequestId': '22edbae2733bf6', + 'timeout': 3000, + 'gdprConsent': { + consentString: consentString, + gdprApplies: true + }, + 'uspConsent': uspConsentData + }; + + bidderRequest.Bids = bidRequestWithSinglePlacement; + + const request = spec.buildRequests(bidRequestWithSinglePlacement, bidderRequest); + const payload = JSON.parse(request.data); + + expect(payload.gdprConsent).to.exist; + expect(payload.gdprConsent.consentString).to.exist.and.to.equal(consentString); + expect(payload.uspConsent).to.exist.and.to.equal(uspConsentData); + }); + + it('sends bid request to endpoint with single placement', function () { + bidderRequest.Bids = bidRequestWithSinglePlacement; + + const request = spec.buildRequests(bidRequestWithSinglePlacement, bidderRequest); + const payload = JSON.parse(request.data); + + expect(request.url).to.contain('https://ssp.retail-spot.io/prebid'); + expect(request.method).to.equal('POST'); + + expect(payload).to.deep.equal(bidderRequest); + }); + + it('sends bid request to endpoint with single placement multiple mediatype', function () { + bidderRequest.Bids = bidRequestWithMultipleMediatype; + const request = spec.buildRequests(bidRequestWithSinglePlacement, bidderRequest); + const payload = JSON.parse(request.data); + + expect(request.url).to.contain('https://ssp.retail-spot.io/prebid'); + expect(request.method).to.equal('POST'); + + expect(payload).to.deep.equal(bidderRequest); + }); + + it('sends bid request to endpoint with multiple placements', function () { + bidderRequest.Bids = bidRequestMultiPlacements; + const request = spec.buildRequests(bidRequestMultiPlacements, bidderRequest); + const payload = JSON.parse(request.data); + + expect(request.url).to.contain('https://ssp.retail-spot.io/prebid'); + expect(request.method).to.equal('POST'); + + expect(payload).to.deep.equal(bidderRequest); + }); + + it('sends bid request to endpoint setted by parameters', function () { + const request = spec.buildRequests(bidRequestWithDevPlacement, bidderRequest); + expect(request.url).to.contain(DEV_URL); + }); + }); + // + describe('interpretResponse', function () { + let serverResponse; + + beforeEach(function () { + serverResponse = { + body: {} + } + }); + + it('handles nobid responses', function () { + let response = [{ + requestId: '123dfsdf', + placement: '12df1' + }]; + serverResponse.body = response; + let result = spec.interpretResponse(serverResponse, []); + expect(result).deep.equal([]); + }); + + it('receive reponse with single placement', function () { + serverResponse.body = responseWithSinglePlacement; + let result = spec.interpretResponse(serverResponse, {data: '{"bids":' + JSON.stringify(requestDataOnePlacement) + '}'}); + + expect(result.length).to.equal(1); + expect(result[0].cpm).to.equal(0.5); + expect(result[0].ad).to.equal(admSample); + expect(result[0].width).to.equal(300); + expect(result[0].height).to.equal(250); + expect(result[0].meta).to.deep.equal(testMetaObject); + }); + + it('receive reponse with multiple placement', function () { + serverResponse.body = responseWithMultiplePlacements; + let result = spec.interpretResponse(serverResponse, {data: '{"bids":' + JSON.stringify(requestDataMultiPlacement) + '}'}); + + expect(result.length).to.equal(2); + + expect(result[0].cpm).to.equal(0.5); + expect(result[0].ad).to.equal('placement_0'); + expect(result[0].width).to.equal(300); + expect(result[0].height).to.equal(600); + + expect(result[1].cpm).to.equal(0.6); + expect(result[1].ad).to.equal('placement_1'); + expect(result[1].width).to.equal(400); + expect(result[1].height).to.equal(250); + }); + + it('receive Vast reponse with Video ad', function () { + serverResponse.body = responseWithSingleVideo; + let result = spec.interpretResponse(serverResponse, {data: '{"bids":' + JSON.stringify(sentBidVideo) + '}'}); + + expect(result.length).to.equal(1); + expect(result).to.deep.equal(videoResult); + }); + }); +});