diff --git a/modules/risemediatechBidAdapter.js b/modules/risemediatechBidAdapter.js new file mode 100644 index 00000000000..e3709722ae0 --- /dev/null +++ b/modules/risemediatechBidAdapter.js @@ -0,0 +1,214 @@ +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, VIDEO } from '../src/mediaTypes.js'; +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; +import { logInfo, logWarn } from '../src/utils.js'; + +const BIDDER_CODE = 'risemediatech'; +const ENDPOINT_URL = 'https://dev-ads.risemediatech.com/ads/rtb/prebid/js'; +const DEFAULT_CURRENCY = 'USD'; +const DEFAULT_TTL = 60; + +const converter = ortbConverter({ + context: { + netRevenue: true, + ttl: DEFAULT_TTL, + currency: DEFAULT_CURRENCY, + }, + imp(buildImp, bidRequest, context) { + logInfo('Building impression object for bidRequest:', bidRequest); + const imp = buildImp(bidRequest, context); + const { mediaTypes } = bidRequest; + if (bidRequest.params) { + if (bidRequest.params.bidfloor) { + logInfo('Setting bid floor for impression:', bidRequest.params.bidfloor); + imp.bidfloor = bidRequest.params.bidfloor; + } + } + if (mediaTypes[BANNER]) { + logInfo('Adding banner media type to impression:', mediaTypes[BANNER]); + imp.banner = { format: mediaTypes[BANNER].sizes.map(([w, h]) => ({ w, h })) }; + } else if (mediaTypes[VIDEO]) { + logInfo('Adding video media type to impression:', mediaTypes[VIDEO]); + imp.video = { + ...mediaTypes[VIDEO], + // all video parameters are mapped. + }; + } + + return imp; + }, + request(buildRequest, imps, bidderRequest, context) { + logInfo('Building server request with impressions:', imps); + const request = buildRequest(imps, bidderRequest, context); + request.cur = [DEFAULT_CURRENCY]; + request.tmax = bidderRequest.timeout; + request.test = bidderRequest.test || 0; + + if (Array.isArray(bidderRequest.bids)) { + const hasTestMode = bidderRequest.bids.some(bid => bid.params && bid.params.testMode === 1); + if (hasTestMode) { + request.ext = request.ext || {}; + request.ext.test = 1; + logInfo('Test mode detected in bid params, setting test flag in request:', request.ext.test); + } + } + + if (bidderRequest.gdprConsent) { + logInfo('Adding GDPR consent information to request:', bidderRequest.gdprConsent); + request.regs = { ext: { gdpr: bidderRequest.gdprConsent.gdprApplies ? 1 : 0 } }; + request.user = { ext: { consent: bidderRequest.gdprConsent.consentString } }; + } + + if (bidderRequest.uspConsent) { + logInfo('Adding USP consent information to request:', bidderRequest.uspConsent); + request.regs = request.regs || {}; + request.regs.ext = request.regs.ext || {}; + request.regs.ext.us_privacy = bidderRequest.uspConsent; + } + + return request; + }, +}); + +/** + * Validates the bid request. + * @param {Object} bid - The bid request object. + * @returns {boolean} True if the bid request is valid. + */ +const isBidRequestValid = (bid) => { + logInfo('Validating bid request:', bid); + + const { mediaTypes } = bid; + + // Validate video-specific fields if mediaTypes includes VIDEO + if (mediaTypes?.[VIDEO]) { + const video = mediaTypes[VIDEO]; + + if (!video.mimes || !Array.isArray(video.mimes) || video.mimes.length === 0) { + logWarn('Invalid video bid request: Missing or invalid mimes.'); + return false; + } + if (video.w != null && video.w <= 0) { + logWarn('Invalid video bid request: Invalid width.'); + return false; + } + if (video.h != null && video.h <= 0) { + logWarn('Invalid video bid request: Invalid height.'); + return false; + } + } + + return true; +}; + +/** + * Builds the server request for the bid. + * @param {Array} validBidRequests - Array of valid bid requests. + * @param {Object} bidderRequest - Additional information about the bid request. + * @returns {Object} Server request object. + */ +const buildRequests = (validBidRequests, bidderRequest) => { + logInfo('Building server request for valid bid requests:', validBidRequests); + const request = converter.toORTB({ bidRequests: validBidRequests, bidderRequest }); + logInfo('Converted to ORTB request:', request); + return { + method: 'POST', + url: ENDPOINT_URL, + data: request, + options: { + endpointCompression: true + }, + }; +}; + +/** + * Interprets the server response and extracts bid information. + * @param {Object} serverResponse - The response from the server. + * @param {Object} request - The original request sent to the server. + * @returns {Array} Array of bid objects. + */ +const interpretResponse = (serverResponse, request) => { + logInfo('Interpreting server response:', serverResponse); + + const bidResp = serverResponse && serverResponse.body; + if (!bidResp || !Array.isArray(bidResp.seatbid)) { + logWarn('Server response is empty, invalid, or does not contain seatbid array.'); + return []; + } + + const responses = []; + bidResp.seatbid.forEach(seatbid => { + if (Array.isArray(seatbid.bid) && seatbid.bid.length > 0) { + const bid = seatbid.bid[0]; + logInfo('Processing bid response:', bid); + const bidResponse = { + requestId: bid.impid, + cpm: bid.price, + currency: bidResp.cur || DEFAULT_CURRENCY, + width: bid.w, + height: bid.h, + ad: bid.adm, + creativeId: bid.crid, + netRevenue: true, + ttl: DEFAULT_TTL, + meta: { + advertiserDomains: bid.adomain || [], + } + } + + // Set media type based on bid.mtype + if (bid.mtype == null) { + logWarn('Bid response does not contain media type for bidId: ', bid.id); + bidResponse.mediaType = BANNER; + } + switch (bid.mtype) { + case 1: + bidResponse.mediaType = BANNER; + break; + case 2: + bidResponse.mediaType = VIDEO; + bidResponse.vastXml = bid.adm; + break; + default: + logWarn('Unknown media type: ', bid.mtype, ' for bidId: ', bid.id); + break; + } + + // set dealId if present + if (bid.dealid) { + bidResponse.dealId = bid.dealid; + } + logInfo('Interpreted response:', bidResponse, ' for bidId: ', bid.id); + responses.push(bidResponse); + } + }); + + logInfo('Interpreted bid responses:', responses); + return responses; +}; + +/** + * Handles user syncs for GDPR, CCPA, and GPP compliance. + * @param {Object} syncOptions - Options for user sync. + * @param {Array} serverResponses - Server responses. + * @param {Object} gdprConsent - GDPR consent information. + * @param {Object} uspConsent - CCPA consent information. + * @param {Object} gppConsent - GPP consent information. + * @returns {Array} Array of user sync objects. + */ +const getUserSyncs = (syncOptions, serverResponses, gdprConsent, uspConsent, gppConsent) => { + // return [{ type, url }]; + logInfo('User syncs are not implemented in this adapter yet.'); + return null; +}; + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO], + isBidRequestValid, + buildRequests, + interpretResponse, + getUserSyncs, +}; + +registerBidder(spec); diff --git a/modules/risemediatechBidAdapter.md b/modules/risemediatechBidAdapter.md new file mode 100644 index 00000000000..4eefdf8710c --- /dev/null +++ b/modules/risemediatechBidAdapter.md @@ -0,0 +1,67 @@ +# Overview + +Module Name : RiseMediaTech Bidder Adapter +Module Type : Bid Adapter +Maintainer : prebid@risemediatech.io + +# Description +Connects to RiseMediaTech Exchange for bids +RiseMediaTech supports Display & Video(Instream) currently. + +This adapter is maintained by Rise Media Technologies, the legal entity behind this implementation. Our official domain is risemediatech.io, which currently redirects to pubrise.ai for operational convenience. We also own the domain risemediatech.com. +Rise Media Technologies and PubRise are part of the same parent organization. +# Sample Ad Unit : Banner +``` + var adUnits = [ + { + code: 'test-banner-div', + mediatypes: { + banner: { + sizes:[ + [320,50] + ] + } + }, + bids:[ + { + bidder: 'risemediatech', + params: { + bidfloor: 0.001, + testMode: 1 + } + } + ] + } + ] +``` + +# Sample Ad Unit : Video +``` + var videoAdUnit = [ + { + code: 'risemediatech', + mediatypes: { + video: { + playerSize: [640, 480], // required + context: 'instream', + mimes: ['video/mp4','video/webm'], + minduration: 5, + maxduration: 30, + startdelay: 30, + maxseq: 2, + poddur: 30, + protocols: [1,3,4], + } + }, + bids:[ + { + bidder: 'risemediatech', + params: { + bidfloor: 0.001 + testMode: 1 + } + } + ] + } + ] +``` diff --git a/test/spec/modules/risemediatechBidAdapter_spec.js b/test/spec/modules/risemediatechBidAdapter_spec.js new file mode 100644 index 00000000000..d4d70017ceb --- /dev/null +++ b/test/spec/modules/risemediatechBidAdapter_spec.js @@ -0,0 +1,497 @@ +import { expect } from 'chai'; +import { spec } from 'modules/risemediatechBidAdapter.js'; + +describe('RiseMediaTech adapter', () => { + const validBidRequest = { + bidder: 'risemediatech', + params: { + publisherId: '12345', + adSlot: '/1234567/adunit', + }, + mediaTypes: { + banner: { + sizes: [[300, 250], [728, 90]], + }, + }, + bidId: '1abc', + auctionId: '2def', + }; + + const bidderRequest = { + refererInfo: { + page: 'https://example.com', + }, + timeout: 3000, + gdprConsent: { + gdprApplies: true, + consentString: 'consent123', + }, + uspConsent: '1YNN', + }; + + const serverResponse = { + body: { + id: '2def', + seatbid: [ + { + bid: [ + { + id: '1abc', + impid: '1abc', + price: 1.5, + adm: '
Ad
', + w: 300, + h: 250, + crid: 'creative123', + adomain: ['example.com'], + }, + ], + }, + ], + }, + }; + + describe('isBidRequestValid', () => { + it('should return true for valid bid request', () => { + expect(spec.isBidRequestValid(validBidRequest)).to.equal(true); + }); + + it('should return false for invalid video bid request', () => { + const invalidVideoRequest = { + ...validBidRequest, + mediaTypes: { + video: { + mimes: [], + }, + }, + }; + expect(spec.isBidRequestValid(invalidVideoRequest)).to.equal(false); + }); + + it('should return false for video bid request with missing mimes', () => { + const invalidVideoRequest = { + ...validBidRequest, + mediaTypes: { + video: { + w: 640, + h: 480 + // mimes missing + } + } + }; + expect(spec.isBidRequestValid(invalidVideoRequest)).to.equal(false); + }); + + it('should return false for video request with invalid mimes (not an array)', () => { + const invalidBid = { + ...validBidRequest, + mediaTypes: { + video: { + mimes: 'video/mp4', // Not an array + w: 640, + h: 480 + } + } + }; + expect(spec.isBidRequestValid(invalidBid)).to.equal(false); + }); + + it('should return false for video request with empty mimes array', () => { + const invalidBid = { + ...validBidRequest, + mediaTypes: { + video: { + mimes: [], + w: 640, + h: 480 + } + } + }; + expect(spec.isBidRequestValid(invalidBid)).to.equal(false); + }); + + it('should return false for video request with width <= 0', () => { + const invalidBid = { + ...validBidRequest, + mediaTypes: { + video: { + mimes: ['video/mp4'], + w: 0, + h: 480 + } + } + }; + expect(spec.isBidRequestValid(invalidBid)).to.equal(false); + }); + + it('should return false for video request with height <= 0', () => { + const invalidBid = { + ...validBidRequest, + mediaTypes: { + video: { + mimes: ['video/mp4'], + w: 640, + h: -10 + } + } + }; + expect(spec.isBidRequestValid(invalidBid)).to.equal(false); + }); + + it('should return false for video bid request with invalid width', () => { + const invalidVideoRequest = { + ...validBidRequest, + mediaTypes: { + video: { + mimes: ['video/mp4'], + w: 0, + h: 480 + } + } + }; + expect(spec.isBidRequestValid(invalidVideoRequest)).to.equal(false); + }); + + it('should return false for video bid request with invalid height', () => { + const invalidVideoRequest = { + ...validBidRequest, + mediaTypes: { + video: { + mimes: ['video/mp4'], + w: 640, + h: 0 + } + } + }; + expect(spec.isBidRequestValid(invalidVideoRequest)).to.equal(false); + }); + }); + + describe('buildRequests', () => { + it('should build a valid server request', () => { + const request = spec.buildRequests([validBidRequest], bidderRequest); + expect(request).to.be.an('object'); + expect(request.method).to.equal('POST'); + expect(request.url).to.equal('https://dev-ads.risemediatech.com/ads/rtb/prebid/js'); + expect(request.data).to.be.an('object'); + }); + + it('should include GDPR and USP consent in the request', () => { + const request = spec.buildRequests([validBidRequest], bidderRequest); + const { regs, user } = request.data; + expect(regs.ext).to.have.property('gdpr', 1); + expect(user.ext).to.have.property('consent', 'consent123'); + expect(regs.ext).to.have.property('us_privacy', '1YNN'); + }); + + it('should include banner impressions in the request', () => { + const request = spec.buildRequests([validBidRequest], bidderRequest); + const { imp } = request.data; + expect(imp).to.be.an('array'); + expect(imp[0]).to.have.property('banner'); + expect(imp[0].banner).to.have.property('format').with.lengthOf(2); + }); + + it('should set request.test to 0 if bidderRequest.test is not provided', () => { + const request = spec.buildRequests([validBidRequest], { ...bidderRequest }); + expect(request.data.test).to.equal(0); + }); + + it('should set request.test to bidderRequest.test if provided', () => { + const testBidderRequest = { ...bidderRequest, test: 1 }; + const request = spec.buildRequests([validBidRequest], testBidderRequest); + expect(request.data.test).to.equal(1); + }); + + it('should build a video impression if only video mediaType is present', () => { + const videoBidRequest = { + ...validBidRequest, + mediaTypes: { + video: { + mimes: ['video/mp4'], + w: 640, + h: 480 + } + }, + params: { + ...validBidRequest.params, + mimes: ['video/mp4'], + minduration: 5, + maxduration: 30, + startdelay: 0, + maxseq: 1, + poddur: 60, + protocols: [2, 3] + } + }; + const request = spec.buildRequests([videoBidRequest], bidderRequest); + const { imp } = request.data; + expect(imp[0]).to.have.property('video'); + expect(imp[0]).to.not.have.property('banner'); + expect(imp[0].video).to.include({ w: 640, h: 480 }); + expect(imp[0].video.mimes).to.include('video/mp4'); + }); + + it('should set gdpr to 0 if gdprApplies is false', () => { + const noGdprBidderRequest = { + ...bidderRequest, + gdprConsent: { + gdprApplies: false, + consentString: 'consent123' + } + }; + const request = spec.buildRequests([validBidRequest], noGdprBidderRequest); + expect(request.data.regs.ext).to.have.property('gdpr', 0); + expect(request.data.user.ext).to.have.property('consent', 'consent123'); + }); + + it('should set regs and regs.ext to {} if not already set when only USP consent is present', () => { + const onlyUspBidderRequest = { + ...bidderRequest, + gdprConsent: undefined, + uspConsent: '1YNN' + }; + const request = spec.buildRequests([validBidRequest], onlyUspBidderRequest); + expect(request.data.regs).to.be.an('object'); + expect(request.data.regs.ext).to.be.an('object'); + expect(request.data.regs.ext).to.have.property('us_privacy', '1YNN'); + }); + }); + + describe('interpretResponse', () => { + it('should interpret the server response correctly', () => { + const request = spec.buildRequests([validBidRequest], bidderRequest); + const bids = spec.interpretResponse(serverResponse, request); + expect(bids).to.be.an('array').with.lengthOf(1); + const bid = bids[0]; + expect(bid).to.have.property('requestId', '1abc'); + expect(bid).to.have.property('cpm', 1.5); + expect(bid).to.have.property('width', 300); + expect(bid).to.have.property('height', 250); + expect(bid).to.have.property('creativeId', 'creative123'); + expect(bid).to.have.property('currency', 'USD'); + expect(bid).to.have.property('netRevenue', true); + expect(bid).to.have.property('ttl', 60); + }); + + it('should return an empty array if no bids are present', () => { + const emptyResponse = { body: { seatbid: [] } }; + const request = spec.buildRequests([validBidRequest], bidderRequest); + const bids = spec.interpretResponse(emptyResponse, request); + expect(bids).to.be.an('array').with.lengthOf(0); + }); + + it('should interpret multiple seatbids as multiple bids', () => { + const multiSeatbidResponse = { + body: { + id: '2def', + seatbid: [ + { + bid: [ + { + id: '1abc', + impid: '1abc', + price: 1.5, + adm: '
Ad1
', + w: 300, + h: 250, + crid: 'creative123', + adomain: ['example.com'], + mtype: 1 + }, + ], + }, + { + bid: [ + { + id: '2bcd', + impid: '2bcd', + price: 2.0, + adm: '
Ad2
', + w: 728, + h: 90, + crid: 'creative456', + adomain: ['another.com'], + mtype: 2 + }, + ], + }, + ], + }, + }; + const request = spec.buildRequests([validBidRequest], bidderRequest); + const bids = spec.interpretResponse(multiSeatbidResponse, request); + expect(bids).to.be.an('array').with.lengthOf(2); + expect(bids[0]).to.have.property('requestId', '1abc'); + expect(bids[1]).to.have.property('requestId', '2bcd'); + expect(bids[0].mediaType).to.equal('banner'); + expect(bids[1].mediaType).to.equal('video'); + expect(bids[0]).to.have.property('cpm', 1.5); + expect(bids[1]).to.have.property('cpm', 2.0); + }); + + it('should set mediaType to banner if mtype is missing', () => { + const responseNoMtype = { + body: { + id: '2def', + seatbid: [ + { + bid: [ + { + id: '1abc', + impid: '1abc', + price: 1.5, + adm: '
Ad
', + w: 300, + h: 250, + crid: 'creative123', + adomain: ['example.com'] + // mtype missing + } + ] + } + ] + } + }; + const request = spec.buildRequests([validBidRequest], bidderRequest); + const bids = spec.interpretResponse(responseNoMtype, request); + expect(bids[0].mediaType).to.equal('banner'); + }); + + it('should set meta.advertiserDomains to an empty array if adomain is missing', () => { + const responseWithoutAdomain = { + body: { + id: '2def', + seatbid: [ + { + bid: [ + { + id: '1abc', + impid: '1abc', + price: 1.5, + adm: '
Ad
', + w: 300, + h: 250, + crid: 'creative123' + // adomain is missing + } + ] + } + ] + } + }; + const request = spec.buildRequests([validBidRequest], bidderRequest); + const bids = spec.interpretResponse(responseWithoutAdomain, request); + expect(bids[0].meta.advertiserDomains).to.be.an('array').that.is.empty; + }); + + it('should return an empty array and warn if server response is undefined', () => { + const request = spec.buildRequests([validBidRequest], bidderRequest); + const bids = spec.interpretResponse(undefined, request); + expect(bids).to.be.an('array').that.is.empty; + }); + + it('should return an empty array and warn if server response body is missing', () => { + const request = spec.buildRequests([validBidRequest], bidderRequest); + const bids = spec.interpretResponse({}, request); + expect(bids).to.be.an('array').that.is.empty; + }); + + it('should return bids from converter if present', () => { + const request = spec.buildRequests([validBidRequest], bidderRequest); + const bids = spec.interpretResponse(serverResponse, request); + expect(bids).to.be.an('array').with.lengthOf(1); + }); + + it('should log a warning and not set mediaType for unknown mtype', () => { + const responseWithUnknownMtype = { + body: { + id: '2def', + seatbid: [ + { + bid: [ + { + id: '1abc', + impid: '1abc', + price: 1.5, + adm: '
Ad
', + w: 300, + h: 250, + crid: 'creative123', + adomain: ['example.com'], + mtype: 999, // Unknown mtype + }, + ], + }, + ], + }, + }; + const request = spec.buildRequests([validBidRequest], bidderRequest); + const bids = spec.interpretResponse(responseWithUnknownMtype, request); + expect(bids).to.be.an('array').with.lengthOf(1); + expect(bids[0].meta).to.not.have.property('mediaType'); + }); + + it('should include dealId if present in the bid response', () => { + const responseWithDealId = { + body: { + id: '2def', + seatbid: [ + { + bid: [ + { + id: '1abc', + impid: '1abc', + price: 1.5, + adm: '
Ad
', + w: 300, + h: 250, + crid: 'creative123', + adomain: ['example.com'], + dealid: 'deal123', + }, + ], + }, + ], + }, + }; + const request = spec.buildRequests([validBidRequest], bidderRequest); + const bids = spec.interpretResponse(responseWithDealId, request); + expect(bids).to.be.an('array').with.lengthOf(1); + expect(bids[0]).to.have.property('dealId', 'deal123'); + }); + + it('should handle bids with missing price gracefully', () => { + const responseWithoutPrice = { + body: { + id: '2def', + seatbid: [ + { + bid: [ + { + id: '1abc', + impid: '1abc', + adm: '
Ad
', + w: 300, + h: 250, + crid: 'creative123', + adomain: ['example.com'], + }, + ], + }, + ], + }, + }; + const request = spec.buildRequests([validBidRequest], bidderRequest); + const bids = spec.interpretResponse(responseWithoutPrice, request); + expect(bids).to.be.an('array').that.is.not.empty; + }); + }); + + describe('getUserSyncs', () => { + it('should return null as user syncs are not implemented', () => { + const syncs = spec.getUserSyncs({ iframeEnabled: true }, [], bidderRequest.gdprConsent, bidderRequest.uspConsent); + expect(syncs).to.be.null; + }); + }); +});