diff --git a/modules/mobkoiBidAdapter.js b/modules/mobkoiBidAdapter.js new file mode 100644 index 00000000000..14e676aba22 --- /dev/null +++ b/modules/mobkoiBidAdapter.js @@ -0,0 +1,211 @@ +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER } from '../src/mediaTypes.js'; +import { _each, replaceMacros, deepAccess, deepSetValue, logError } from '../src/utils.js'; + +const BIDDER_CODE = 'mobkoi'; +/** + * !IMPORTANT: This value must match the value in mobkoiAnalyticsAdapter.js + * The name of the parameter that the publisher can use to specify the ad server endpoint. + */ +const PARAM_NAME_AD_SERVER_BASE_URL = 'adServerBaseUrl'; +/** + * The list of ORTB response fields that are used in the macros. Field + * replacement is self-implemented in the adapter. Use dot-notated path for + * nested fields. For example, 'ad.ext.adomain'. For more information, visit + * https://www.npmjs.com/package/dset and https://www.npmjs.com/package/dlv. + */ +const ORTB_RESPONSE_FIELDS_SUPPORT_MACROS = ['adm', 'nurl', 'lurl']; + +export const converter = ortbConverter({ + context: { + netRevenue: true, + ttl: 30, + }, + request(buildRequest, imps, bidderRequest, context) { + const ortbRequest = buildRequest(imps, bidderRequest, context); + const prebidBidRequest = context.bidRequests[0]; + + ortbRequest.id = utils.getOrtbId(prebidBidRequest); + + return ortbRequest; + }, + bidResponse(buildPrebidBidResponse, ortbBidResponse, context) { + utils.replaceAllMacrosInPlace(ortbBidResponse, context); + + const prebidBid = buildPrebidBidResponse(ortbBidResponse, context); + utils.addCustomFieldsToPrebidBidResponse(prebidBid, ortbBidResponse); + return prebidBid; + }, +}); + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER], + + isBidRequestValid(bid) { + if (!deepAccess(bid, 'ortb2.site.publisher.id')) { + logError('The "ortb2.site.publisher.id" field is required in the bid request.' + + 'Please set it via the "config.ortb2.site.publisher.id" field with pbjs.setBidderConfig.' + ); + return false; + } + + return true; + }, + + buildRequests(prebidBidRequests, prebidBidderRequest) { + const adServerEndpoint = utils.getAdServerEndpointBaseUrl(prebidBidderRequest) + '/bid'; + + return { + method: 'POST', + url: adServerEndpoint, + options: { + contentType: 'application/json', + }, + data: converter.toORTB({ + bidRequests: prebidBidRequests, + bidderRequest: prebidBidderRequest + }), + }; + }, + + interpretResponse(serverResponse, customBidRequest) { + if (!serverResponse.body) return []; + + const responseBody = {...serverResponse.body, seatbid: serverResponse.body.seatbid}; + const prebidBidResponse = converter.fromORTB({ + request: customBidRequest.data, + response: responseBody, + }); + return prebidBidResponse.bids; + }, +}; + +registerBidder(spec); + +export const utils = { + + /** + * !IMPORTANT: Make sure the implementation of this function matches getAdServerEndpointBaseUrl + * in both adapters. + * Obtain the Ad Server Base URL from the given Prebid object. + * @param {*} bid Prebid Bidder Request Object or Prebid Bid Response/Request + * or ORTB Request/Response Object + * @returns {string} The Ad Server Base URL + * @throws {Error} If the ORTB ID cannot be found in the given + */ + getAdServerEndpointBaseUrl (bid) { + const ortbPath = `site.publisher.ext.${PARAM_NAME_AD_SERVER_BASE_URL}`; + const prebidPath = `ortb2.${ortbPath}`; + + const adServerBaseUrl = + deepAccess(bid, prebidPath) || + deepAccess(bid, ortbPath); + + if (!adServerBaseUrl) { + throw new Error('Failed to find the Ad Server Base URL in the given object. ' + + `Please set it via the "${prebidPath}" field with pbjs.setBidderConfig.\n` + + 'Given Object:\n' + + JSON.stringify(bid, null, 2) + ); + } + + return adServerBaseUrl; + }, + + /** + * !IMPORTANT: Make sure the implementation of this function matches utils.getPublisherId in + * both adapters. + * Extract the publisher ID from the given object. + * @param {*} prebidBidRequestOrOrtbBidRequest + * @returns string + * @throws {Error} If the publisher ID is not found in the given object. + */ + getPublisherId: function (prebidBidRequestOrOrtbBidRequest) { + const ortbPath = 'site.publisher.id'; + const prebidPath = `ortb2.${ortbPath}`; + + const publisherId = + deepAccess(prebidBidRequestOrOrtbBidRequest, prebidPath) || + deepAccess(prebidBidRequestOrOrtbBidRequest, ortbPath); + + if (!publisherId) { + throw new Error( + 'Failed to obtain publisher ID from the given object. ' + + `Please set it via the "${prebidPath}" field with pbjs.setBidderConfig.\n` + + 'Given object:\n' + + JSON.stringify(prebidBidRequestOrOrtbBidRequest, null, 2) + ); + } + + return publisherId; + }, + + /** + * !IMPORTANT: Make sure the implementation of this function matches utils.getOrtbId in + * mobkoiAnalyticsAdapter.js. + * We use the bidderRequestId as the ortbId. We could do so because we only + * make one ORTB request per Prebid Bidder Request. + * The ID field named differently when the value passed on to different contexts. + * @param {*} bid Prebid Bidder Request Object or Prebid Bid Response/Request + * or ORTB Request/Response Object + * @returns {string} The ORTB ID + * @throws {Error} If the ORTB ID cannot be found in the given object. + */ + getOrtbId(bid) { + const ortbId = + // called bidderRequestId in Prebid Request + bid.bidderRequestId || + // called seatBidId in Prebid Bid Response Object + bid.seatBidId || + // called ortbId in Interpreted Prebid Response Object + bid.ortbId || + // called id in ORTB object + (Object.hasOwn(bid, 'imp') && bid.id); + + if (!ortbId) { + throw new Error('Unable to find the ORTB ID in the bid object. Given Object:\n' + + JSON.stringify(bid, null, 2) + ); + } + + return ortbId; + }, + + /** + * Append custom fields to the prebid bid response. so that they can be accessed + * in various event handlers. + * @param {*} prebidBidResponse + * @param {*} ortbBidResponse + */ + addCustomFieldsToPrebidBidResponse(prebidBidResponse, ortbBidResponse) { + prebidBidResponse.ortbBidResponse = ortbBidResponse; + prebidBidResponse.ortbId = ortbBidResponse.id; + }, + + replaceAllMacrosInPlace(ortbBidResponse, context) { + const macros = { + // ORTB macros + AUCTION_PRICE: ortbBidResponse.price, + AUCTION_IMP_ID: ortbBidResponse.impid, + AUCTION_CURRENCY: ortbBidResponse.cur, + AUCTION_BID_ID: context.bidderRequest.auctionId, + + // Custom macros + BIDDING_API_BASE_URL: utils.getAdServerEndpointBaseUrl(context.bidderRequest), + CREATIVE_ID: ortbBidResponse.crid, + CAMPAIGN_ID: ortbBidResponse.cid, + ORTB_ID: ortbBidResponse.id, + PUBLISHER_ID: deepAccess(context, 'bidRequest.ortb2.site.publisher.id') || deepAccess(context, 'bidderRequest.ortb2.site.publisher.id') + }; + + _each(ORTB_RESPONSE_FIELDS_SUPPORT_MACROS, ortbField => { + deepSetValue( + ortbBidResponse, + ortbField, + replaceMacros(deepAccess(ortbBidResponse, ortbField), macros) + ); + }); + }, +} diff --git a/modules/mobkoiBidAdapter.md b/modules/mobkoiBidAdapter.md new file mode 100644 index 00000000000..bf59585a3c8 --- /dev/null +++ b/modules/mobkoiBidAdapter.md @@ -0,0 +1,49 @@ +# Overview + +Module Name: Mobkoi Bidder Adapter +Module Type: Bidder Adapter +Maintainer: platformteam@mobkoi.com + +# Description + +Module that connects to Mobkoi Ad Server + +### Supported formats: +- Banner + +# Test Parameters +```js +const adUnits = [ + { + code: 'banner-ad', + mediaTypes: { + banner: { sizes: [300, 200] }, + }, + bids: [ + { + bidder: 'mobkoi', + }, + ], + }, +]; + +pbjs.que.push(function () { + pbjs.setBidderConfig({ + bidders: ['mobkoi'], + config: { + ortb2: { + site: { + publisher: { + id: 'module-test-publisher-id', + ext: { + adServerBaseUrl: 'https://adserver.dev.mobkoi.com', + }, + }, + }, + }, + }, + }); + + pbjs.addAdUnits(adUnits); +}); +``` diff --git a/test/spec/modules/mobkoiBidAdapter_spec.js b/test/spec/modules/mobkoiBidAdapter_spec.js new file mode 100644 index 00000000000..f71768e5b6b --- /dev/null +++ b/test/spec/modules/mobkoiBidAdapter_spec.js @@ -0,0 +1,284 @@ +import {spec, utils} from 'modules/mobkoiBidAdapter.js'; + +describe('Mobkoi bidding Adapter', function () { + const adServerBaseUrl = 'http://adServerBaseUrl'; + const requestId = 'test-request-id' + const publisherId = 'mobkoiPublisherId' + const bidId = 'test-bid-id' + const bidderCode = 'mobkoi' + const transactionId = 'test-transaction-id' + const adUnitId = 'test-ad-unit-id' + const auctionId = 'test-auction-id' + + const getOrtb2 = () => ({ + site: { + publisher: { + id: publisherId, + ext: { adServerBaseUrl } + } + } + }) + + const getBidRequest = () => ({ + bidder: bidderCode, + adUnitCode: 'banner-ad', + transactionId, + adUnitId, + bidId: bidId, + bidderRequestId: requestId, + auctionId, + ortb2: getOrtb2() + }) + + const getBidderRequest = () => ({ + bidderCode, + auctionId, + bidderRequestId: requestId, + bids: [getBidRequest()], + ortb2: getOrtb2() + }) + + const getConvertedBidRequest = () => ({ + id: requestId, + cur: [ + 'USD' + ], + imp: [{ + id: bidId, + }], + ...getOrtb2(), + test: 0 + }) + + const adm = '
test ad
'; + const lurl = 'test.com/loss'; + const nurl = 'test.com/win'; + + const getBidderResponse = () => ({ + body: { + id: bidId, + cur: 'USD', + seatbid: [ + { + seat: 'mobkoi_debug', + bid: [ + { + id: bidId, + impid: bidId, + cid: 'campaign_1', + crid: 'creative_1', + price: 1, + cur: [ + 'USD' + ], + adomain: [ + 'advertiser.com' + ], + adm, + w: 300, + h: 250, + mtype: 1, + lurl, + nurl + } + ] + } + ], + } + }) + + describe('isBidRequestValid', function () { + let bid; + + beforeEach(function () { + bid = getBidderRequest().bids[0]; + }); + + it('should return true when publisher id exists in ortb2', function () { + expect(spec.isBidRequestValid(bid)).to.equal(true); + }); + + it('should return false when publisher id is missing', function () { + delete bid.ortb2.site.publisher.id; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when publisher id is empty', function () { + bid.ortb2.site.publisher.id = ''; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + }) + + describe('buildRequests', function () { + let bidderRequest, convertedBidRequest; + + beforeEach(function () { + bidderRequest = getBidderRequest(); + convertedBidRequest = getConvertedBidRequest(); + }); + + it('should return valid request object with correct structure', function () { + const request = spec.buildRequests(bidderRequest.bids, bidderRequest); + const expectedUrl = adServerBaseUrl + '/bid'; + + expect(request.method).to.equal('POST'); + expect(request.options.contentType).to.equal('application/json'); + expect(request.url).to.equal(expectedUrl); + expect(request.data).to.deep.equal(convertedBidRequest); + }); + + it('should include converted ORTB data in request', function () { + const request = spec.buildRequests(bidderRequest.bids, bidderRequest); + const ortbData = request.data; + + expect(ortbData.id).to.equal(bidderRequest.bidderRequestId); + expect(ortbData.site.publisher.id).to.equal(bidderRequest.ortb2.site.publisher.id); + }); + + it('should throw error when adServerBaseUrl is missing', function () { + delete bidderRequest.ortb2.site.publisher.ext.adServerBaseUrl; + + expect(() => { + spec.buildRequests(bidderRequest.bids, bidderRequest); + }).to.throw(); + }); + }) + + describe('interpretResponse', function () { + let bidderRequest, bidRequest, bidderResponse; + + beforeEach(function () { + bidderRequest = getBidderRequest(); + bidRequest = spec.buildRequests(bidderRequest.bids, bidderRequest); + bidderResponse = getBidderResponse(); + }); + + it('should return empty array when response is empty', function () { + expect(spec.interpretResponse({}, {})).to.deep.equal([]); + }); + + it('should interpret valid bid response', function () { + const bidsResponse = spec.interpretResponse(bidderResponse, bidRequest); + expect(bidsResponse).to.not.be.empty; + const bid = bidsResponse[0]; + + expect(bid.ad).to.include(adm); + expect(bid.requestId).to.equal(bidderResponse.body.seatbid[0].bid[0].impid); + expect(bid.cpm).to.equal(bidderResponse.body.seatbid[0].bid[0].price); + expect(bid.width).to.equal(bidderResponse.body.seatbid[0].bid[0].w); + expect(bid.height).to.equal(bidderResponse.body.seatbid[0].bid[0].h); + expect(bid.creativeId).to.equal(bidderResponse.body.seatbid[0].bid[0].crid); + expect(bid.currency).to.equal(bidderResponse.body.cur); + expect(bid.netRevenue).to.be.true; + expect(bid.ttl).to.equal(30); + }); + }) + + describe('utils', function () { + let bidderRequest; + + beforeEach(function () { + bidderRequest = getBidderRequest(); + }); + + describe('getAdServerEndpointBaseUrl', function () { + it('should return the adServerBaseUrl from the given object', function () { + expect(utils.getAdServerEndpointBaseUrl(bidderRequest)) + .to.equal(adServerBaseUrl); + }); + + it('should throw error when adServerBaseUrl is missing', function () { + delete bidderRequest.ortb2.site.publisher.ext.adServerBaseUrl; + + expect(() => { + utils.getAdServerEndpointBaseUrl(bidderRequest); + }).to.throw(); + }); + }) + + describe('getPublisherId', function () { + it('should return the publisherId from the given object', function () { + expect(utils.getPublisherId(bidderRequest)).to.equal(bidderRequest.ortb2.site.publisher.id); + }); + + it('should throw error when publisherId is missing', function () { + delete bidderRequest.ortb2.site.publisher.id; + expect(() => { + utils.getPublisherId(bidderRequest); + }).to.throw(); + }); + }) + + describe('getOrtbId', function () { + it('should return the ortbId from the prebid request object (i.e bidderRequestId)', function () { + expect(utils.getOrtbId(bidderRequest)).to.equal(bidderRequest.bidderRequestId); + }); + + it('should return the ortbId from the prebid response object (i.e seatBidId)', function () { + const customBidRequest = { ...bidderRequest, seatBidId: bidderRequest.bidderRequestId }; + delete customBidRequest.bidderRequestId; + expect(utils.getOrtbId(customBidRequest)).to.equal(bidderRequest.bidderRequestId); + }); + + it('should return the ortbId from the interpreted prebid response object (i.e ortbId)', function () { + const customBidRequest = { ...bidderRequest, ortbId: bidderRequest.bidderRequestId }; + delete customBidRequest.bidderRequestId; + expect(utils.getOrtbId(customBidRequest)).to.equal(bidderRequest.bidderRequestId); + }); + + it('should return the ortbId from the ORTB request object (i.e has imp)', function () { + const customBidRequest = { ...bidderRequest, imp: {}, id: bidderRequest.bidderRequestId }; + delete customBidRequest.bidderRequestId; + expect(utils.getOrtbId(customBidRequest)).to.equal(bidderRequest.bidderRequestId); + }); + + it('should throw error when ortbId is missing', function () { + delete bidderRequest.bidderRequestId; + expect(() => { + utils.getOrtbId(bidderRequest); + }).to.throw(); + }); + }) + + describe('replaceAllMacrosInPlace', function () { + let bidderResponse, bidRequest, bidderRequest; + + beforeEach(function () { + bidderRequest = getBidderRequest(); + bidRequest = spec.buildRequests(bidderRequest.bids, bidderRequest); + bidderResponse = getBidderResponse(); + }); + + it('should replace all macros in adm, nurl, and lurl fields', function () { + const bid = bidderResponse.body.seatbid[0].bid[0]; + bid.nurl = '${BIDDING_API_BASE_URL}/win?price=${AUCTION_PRICE}&impressionId=${AUCTION_IMP_ID}¤cy=${AUCTION_CURRENCY}&campaignId=${CAMPAIGN_ID}&creativeId=${CREATIVE_ID}&publisherId=${PUBLISHER_ID}&ortbId=${ORTB_ID}'; + bid.lurl = '${BIDDING_API_BASE_URL}/loss?price=${AUCTION_PRICE}&impressionId=${AUCTION_IMP_ID}¤cy=${AUCTION_CURRENCY}&campaignId=${CAMPAIGN_ID}&creativeId=${CREATIVE_ID}&publisherId=${PUBLISHER_ID}&ortbId=${ORTB_ID}'; + bid.adm = '
${AUCTION_PRICE}${AUCTION_CURRENCY}${AUCTION_IMP_ID}${AUCTION_BID_ID}${CAMPAIGN_ID}${CREATIVE_ID}${PUBLISHER_ID}${ORTB_ID}${BIDDING_API_BASE_URL}
'; + + const BIDDING_API_BASE_URL = adServerBaseUrl; + const AUCTION_CURRENCY = bidderResponse.body.cur; + const AUCTION_BID_ID = bidderRequest.auctionId; + const AUCTION_PRICE = bid.price; + const AUCTION_IMP_ID = bid.impid; + const CREATIVE_ID = bid.crid; + const CAMPAIGN_ID = bid.cid; + const PUBLISHER_ID = bidderRequest.ortb2.site.publisher.id; + const ORTB_ID = bidderResponse.body.id; + + const context = { + bidRequest, + bidderRequest + } + utils.replaceAllMacrosInPlace(bid, context); + + expect(bid.adm).to.equal(`
${AUCTION_PRICE}${AUCTION_CURRENCY}${AUCTION_IMP_ID}${AUCTION_BID_ID}${CAMPAIGN_ID}${CREATIVE_ID}${PUBLISHER_ID}${ORTB_ID}${BIDDING_API_BASE_URL}
`); + expect(bid.lurl).to.equal( + `${BIDDING_API_BASE_URL}/loss?price=${AUCTION_PRICE}&impressionId=${AUCTION_IMP_ID}¤cy=${AUCTION_CURRENCY}&campaignId=${CAMPAIGN_ID}&creativeId=${CREATIVE_ID}&publisherId=${PUBLISHER_ID}&ortbId=${ORTB_ID}` + ); + expect(bid.nurl).to.equal( + `${BIDDING_API_BASE_URL}/win?price=${AUCTION_PRICE}&impressionId=${AUCTION_IMP_ID}¤cy=${AUCTION_CURRENCY}&campaignId=${CAMPAIGN_ID}&creativeId=${CREATIVE_ID}&publisherId=${PUBLISHER_ID}&ortbId=${ORTB_ID}` + ); + }); + }) + }) +})