diff --git a/modules/alvadsBidAdapter.js b/modules/alvadsBidAdapter.js new file mode 100644 index 00000000000..7a56ba549a5 --- /dev/null +++ b/modules/alvadsBidAdapter.js @@ -0,0 +1,151 @@ +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import * as utils from '../src/utils.js'; +import { BANNER, VIDEO } from '../src/mediaTypes.js'; +const BIDDER_CODE = 'alvads'; +const ENDPOINT_BANNER = 'https://helios-ads-qa-core.ssidevops.com/decision/openrtb'; + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO], + isBidRequestValid: (bid) => { + return Boolean( + bid.params && + bid.params.publisherId && + (bid.mediaTypes?.[BANNER] ? bid.params.tagid : true) + ); + }, + + buildRequests: function(validBidRequests, bidderRequest) { + return validBidRequests.map(bid => { + const floorInfo = (typeof bid.getFloor === 'function') + ? bid.getFloor({ + currency: 'USD', + mediaType: bid.mediaTypes?.banner ? BANNER : VIDEO, + size: '*' + }) + : { floor: 0, currency: 'USD' }; + + const imps = []; + // Banner + if (bid.mediaTypes?.banner) { + const sizes = utils.parseSizesInput(bid.mediaTypes.banner.sizes || bid.sizes) + .map(s => { + const parts = s.split('x').map(Number); + return { w: parts[0], h: parts[1] }; + }); + + sizes.forEach(size => { + imps.push({ + id: bid.bidId, + banner: { w: size.w, h: size.h }, + tagid: bid.params.tagid, + bidfloor: floorInfo.floor, + bidfloorcur: floorInfo.currency, + ext: { userId: bid.params.userId } + }); + }); + } + + // Video + if (bid.mediaTypes?.video) { + const wh = (bid.mediaTypes.video.playerSize && bid.mediaTypes.video.playerSize[0]) || [1280, 720]; + imps.push({ + id: bid.bidId, + video: { w: wh[0], h: wh[1] }, + tagid: bid.params.tagid, + bidfloor: floorInfo.floor, + bidfloorcur: floorInfo.currency, + ext: { userId: bid.params.userId } + }); + } + + // Payload OpenRTB por bid + const payload = { + id: 'REQ-OPENRTB-' + Date.now(), + site: { + page: bidderRequest.refererInfo.page, + ref: bidderRequest.refererInfo.ref, + publisher: { id: bid.params.publisherId } + }, + imp: imps, + device: { + ua: navigator.userAgent + }, + user: { + id: bid.params.userId || utils.generateUUID(), + buyeruid: utils.generateUUID() + }, + regs: { + gpp: '', + gpp_sid: [], + ext: { + gdpr: Number(bidderRequest.gdprConsent?.gdprApplies) + } + }, + ext: { + user_fingerprint: utils.generateUUID() + } + }; + const endpoint = bid.params.endpoint || ENDPOINT_BANNER; + + return { + method: 'POST', + url: endpoint, + data: JSON.stringify(payload), + options: { withCredentials: false } + }; + }); + }, + + interpretResponse: (serverResponse) => { + const bidResponses = []; + const body = serverResponse.body; + + // --- Banners OpenRTB --- + if (body && body.seatbid) { + body.seatbid.forEach(seat => { + seat.bid.forEach(bid => { + const isVideo = bid.adm && bid.adm.includes(' { + utils.logWarn('Timeout bids ALVA:', timeoutData); + }, + + onBidWon: (bid) => { + utils.logInfo('Bid winner ALVA:', bid); + } +}; + +registerBidder(spec); diff --git a/modules/alvadsBidAdapter.md b/modules/alvadsBidAdapter.md new file mode 100644 index 00000000000..b85d6df968d --- /dev/null +++ b/modules/alvadsBidAdapter.md @@ -0,0 +1,135 @@ +# Overview +**Module Name:** alvadsBidAdapter +**Module Type:** bidder +**Maintainer:** alvads@oyealva.com + +--- + +# Description +The **Alva Bid Adapter** allows publishers to connect their banner and video inventory with the Alva demand platform. + +- **Bidder Code:** `alvads` +- **Supported Media Types:** `banner`, `video` +- **Protocols:** OpenRTB 2.5 via POST for both banner and video +- **Dynamic Endpoints:** The adapter can use a default endpoint or a custom endpoint provided in the bid params. +- **Price Floors:** Supported via `bid.getFloor()`. If configured, the adapter will send `bidfloor` and `bidfloorcur` per impression. + +--- +# Parameters + +| Parameter | Required | Description | +|------------ |---------------- |------------ | +| publisherId | Yes | Publisher ID assigned by Alva | +| tagid | Banner only | Required for banner impressions | +| bidfloor | No | Optional; adapter supports floors module via `bid.getFloor()` | +| userId | No | Optional; used for user identification | +| endpoint | No | Optional; overrides default endpoint | + +--- + +# Test Parameters + +## Banner Example + +```javascript +var adUnits = [{ + code: 'div-banner', + mediaTypes: { + banner: { + sizes: [[300, 250], [320, 100]] + } + }, + bids: [{ + bidder: 'alvads', + params: { + publisherId: 'pub-123', // required + tagid: 'tag-456', // required for banner + bidfloor: 0.50, // optional + userId: '+59165352182', // optional + endpoint: 'https://custom-endpoint.com/openrtb' // optional, overrides default + } + }] +}]; +``` + +## Video Example + +```javascript +var adUnits = [{ + code: 'video-ad', + mediaTypes: { + video: { + context: 'instream', + playerSize: [[640, 360]] + } + }, + bids: [{ + bidder: 'alvads', + params: { + publisherId: 'pub-123', // required + bidfloor: 0.5, // optional + userId: '+59165352182', // optional + endpoint: 'https://custom-endpoint.com/video' // optional, overrides default + } + }] +}]; +``` + +--- + +# Request Information + +### Banner / Video +- **Endpoint:** + ``` + https://helios-ads-qa-core.ssidevops.com/decision/openrtb + ``` +- **Method:** `POST` +- **Payload:** OpenRTB 2.5 request containing `site`, `device`, `user`, `regs`, `imp`. +- **Dynamic Endpoint:** The request URL can be overridden by bid.params.endpoint. + + +# Response Information + +### Banner +The response is standard OpenRTB with `seatbid`. Example: + +```json +{ + "id": "response-id", + "seatbid": [{ + "bid": [{ + "impid": "imp-123", + "price": 0.50, + "adm": "
Creative
", + "crid": "creative-1", + "w": 320, + "h": 100, + "ext": { + "vast_url": "http://example.com/vast.xml" + }, + "adomain": ["example.com"] + }] + }], + "cur": "USD" +} + +``` +# Interpretation: + +If adm contains , the adapter sets mediaType: 'video' and includes vastXml & vastUrl. + +Otherwise, mediaType: 'banner' and ad contains the HTML. + + +# Additional Details + +- **Defaults:** + - `netRevenue = true` + - `ttl = 300` + - Banner fallback size: `320x100` + - Video fallback size: `1280x720` + +- **Callbacks:** + - `onTimeout` → logs timeout events + - `onBidWon` → logs winning bid diff --git a/test/spec/alvadsBidAdapter_spec.js b/test/spec/alvadsBidAdapter_spec.js new file mode 100644 index 00000000000..a61551e47c4 --- /dev/null +++ b/test/spec/alvadsBidAdapter_spec.js @@ -0,0 +1,171 @@ +import { expect } from 'chai'; +import { spec } from 'modules/alvadsBidAdapter.js'; + +describe('ALVADS Bid Adapter', function() { + const bannerBid = { + bidId: 'banner-1', + mediaTypes: { banner: { sizes: [[320, 100]] } }, + params: { + publisherId: 'D7DACCE3-C23D-4AB9-8FE6-9FF41BF32F8F', + tagid: 'zone-001', + bidfloor: 0.1, + userId: 'user-001' + } + }; + + const videoBid = { + bidId: 'video-1', + mediaTypes: { video: { playerSize: [[1280, 720]] } }, + params: { + publisherId: 'D7DACCE3-C23D-4AB9-8FE6-9FF41BF32F8F', + bidfloor: 0.5, + userId: 'user-002', + language: 'en', + count: 1 + } + }; + + const bidderRequestBanner = { + refererInfo: { page: "http://localhost:1200" }, + gdprConsent: true, + uspConsent: '1YNN' + }; + const bidderRequestVideo = { + refererInfo: { page: "https://instagram.com" }, + gdprConsent: true, + uspConsent: '1YNN' + }; + + // ----------------------------- + describe('isBidRequestValid', function() { + it('validates banner bid requests', function() { + expect(spec.isBidRequestValid(bannerBid)).to.be.true; + }); + + it('validates video bid requests', function() { + expect(spec.isBidRequestValid(videoBid)).to.be.true; + }); + + it('rejects invalid bid requests', function() { + expect(spec.isBidRequestValid({})).to.be.false; + }); + }); + + // ----------------------------- + describe('buildRequests', function() { + it('uses default endpoint if none provided', function() { + const requests = spec.buildRequests([bannerBid], bidderRequestBanner); + expect(requests[0].url).to.equal('https://helios-ads-qa-core.ssidevops.com/decision/openrtb'); + }); + + it('uses custom endpoint from bid params', function() { + const customBid = { + ...bannerBid, + params: { ...bannerBid.params, endpoint: 'https://helios-ads-qa-core.ssidevops.com/decision/openrtb' } + }; + const requests = spec.buildRequests([customBid], bidderRequestBanner); + expect(requests[0].url).to.equal('https://helios-ads-qa-core.ssidevops.com/decision/openrtb'); + }); + + it('builds correct banner request payload', function() { + const requests = spec.buildRequests([bannerBid], bidderRequestBanner); + const request = requests[0]; + const data = JSON.parse(request.data); + + expect(data.imp).to.have.lengthOf(1); + expect(data.imp[0].banner).to.deep.equal({ w: 320, h: 100 }); + expect(data.imp[0].tagid).to.equal(bannerBid.params.tagid); + expect(data.site.publisher.id).to.equal(bannerBid.params.publisherId); + }); + + it('builds correct video request payload', function() { + const requests = spec.buildRequests([videoBid], bidderRequestVideo); + const request = requests[0]; + const data = JSON.parse(request.data); + + expect(data.imp).to.have.lengthOf(1); + expect(data.imp[0].video).to.deep.equal({ w: 1280, h: 720 }); + }); + }); + // ----------------------------- + describe('interpretResponse', function() { + it('returns empty array if no bids', function() { + const serverResponse = { body: { seatbid: [] } }; + const result = spec.interpretResponse(serverResponse, { bid: bannerBid }); + expect(result).to.have.lengthOf(0); + }); + + it('interprets banner bid response', function() { + const serverResponse = { + body: { + seatbid: [ + { bid: [{ impid: 'banner-1', price: 1.2, w: 320, h: 100, crid: 'c1', adm: '
ad
' }] } + ], + cur: 'USD' + } + }; + + const result = spec.interpretResponse(serverResponse, { bid: bannerBid }); + expect(result).to.have.lengthOf(1); + const r = result[0]; + expect(r.mediaType).to.equal('banner'); + expect(r.cpm).to.equal(1.2); + expect(r.ad).to.equal('
ad
'); + expect(r.width).to.equal(320); + expect(r.height).to.equal(100); + expect(r.creativeId).to.equal('c1'); + }); + + it('interprets video bid response', function() { + const serverResponse = { + body: { + seatbid: [ + { bid: [{ impid: 'video-1', price: 2.5, w: 1280, h: 720, crid: 'v1', adm: '', ext: { vast_url: 'http://vast.url' }, adomain: ['example.com'] }] } + ], + cur: 'USD' + } + }; + + const result = spec.interpretResponse(serverResponse, { bid: videoBid }); + expect(result).to.have.lengthOf(1); + const r = result[0]; + expect(r.mediaType).to.equal('video'); + expect(r.cpm).to.equal(2.5); + expect(r.vastXml).to.equal(''); + expect(r.vastUrl).to.equal('http://vast.url'); + expect(r.width).to.equal(1280); + expect(r.height).to.equal(720); + expect(r.creativeId).to.equal('v1'); + expect(r.meta.advertiserDomains).to.deep.equal(['example.com']); + }); + }); + + // ----------------------------- + describe('onTimeout', function() { + it('calls logWarn with timeout data', function() { + const logs = []; + const original = spec.onTimeout; + spec.onTimeout = (data) => logs.push(data); + + spec.onTimeout({ bidId: 'timeout-1' }); + expect(logs).to.have.lengthOf(1); + expect(logs[0].bidId).to.equal('timeout-1'); + + spec.onTimeout = original; + }); + }); + + describe('onBidWon', function() { + it('calls logInfo with bid won', function() { + const logs = []; + const original = spec.onBidWon; + spec.onBidWon = (bid) => logs.push(bid); + + spec.onBidWon({ bidId: 'won-1' }); + expect(logs).to.have.lengthOf(1); + expect(logs[0].bidId).to.equal('won-1'); + + spec.onBidWon = original; + }); + }); +}); diff --git a/test/spec/modules/alvadsBidAdapter_spec.js b/test/spec/modules/alvadsBidAdapter_spec.js new file mode 100644 index 00000000000..a61551e47c4 --- /dev/null +++ b/test/spec/modules/alvadsBidAdapter_spec.js @@ -0,0 +1,171 @@ +import { expect } from 'chai'; +import { spec } from 'modules/alvadsBidAdapter.js'; + +describe('ALVADS Bid Adapter', function() { + const bannerBid = { + bidId: 'banner-1', + mediaTypes: { banner: { sizes: [[320, 100]] } }, + params: { + publisherId: 'D7DACCE3-C23D-4AB9-8FE6-9FF41BF32F8F', + tagid: 'zone-001', + bidfloor: 0.1, + userId: 'user-001' + } + }; + + const videoBid = { + bidId: 'video-1', + mediaTypes: { video: { playerSize: [[1280, 720]] } }, + params: { + publisherId: 'D7DACCE3-C23D-4AB9-8FE6-9FF41BF32F8F', + bidfloor: 0.5, + userId: 'user-002', + language: 'en', + count: 1 + } + }; + + const bidderRequestBanner = { + refererInfo: { page: "http://localhost:1200" }, + gdprConsent: true, + uspConsent: '1YNN' + }; + const bidderRequestVideo = { + refererInfo: { page: "https://instagram.com" }, + gdprConsent: true, + uspConsent: '1YNN' + }; + + // ----------------------------- + describe('isBidRequestValid', function() { + it('validates banner bid requests', function() { + expect(spec.isBidRequestValid(bannerBid)).to.be.true; + }); + + it('validates video bid requests', function() { + expect(spec.isBidRequestValid(videoBid)).to.be.true; + }); + + it('rejects invalid bid requests', function() { + expect(spec.isBidRequestValid({})).to.be.false; + }); + }); + + // ----------------------------- + describe('buildRequests', function() { + it('uses default endpoint if none provided', function() { + const requests = spec.buildRequests([bannerBid], bidderRequestBanner); + expect(requests[0].url).to.equal('https://helios-ads-qa-core.ssidevops.com/decision/openrtb'); + }); + + it('uses custom endpoint from bid params', function() { + const customBid = { + ...bannerBid, + params: { ...bannerBid.params, endpoint: 'https://helios-ads-qa-core.ssidevops.com/decision/openrtb' } + }; + const requests = spec.buildRequests([customBid], bidderRequestBanner); + expect(requests[0].url).to.equal('https://helios-ads-qa-core.ssidevops.com/decision/openrtb'); + }); + + it('builds correct banner request payload', function() { + const requests = spec.buildRequests([bannerBid], bidderRequestBanner); + const request = requests[0]; + const data = JSON.parse(request.data); + + expect(data.imp).to.have.lengthOf(1); + expect(data.imp[0].banner).to.deep.equal({ w: 320, h: 100 }); + expect(data.imp[0].tagid).to.equal(bannerBid.params.tagid); + expect(data.site.publisher.id).to.equal(bannerBid.params.publisherId); + }); + + it('builds correct video request payload', function() { + const requests = spec.buildRequests([videoBid], bidderRequestVideo); + const request = requests[0]; + const data = JSON.parse(request.data); + + expect(data.imp).to.have.lengthOf(1); + expect(data.imp[0].video).to.deep.equal({ w: 1280, h: 720 }); + }); + }); + // ----------------------------- + describe('interpretResponse', function() { + it('returns empty array if no bids', function() { + const serverResponse = { body: { seatbid: [] } }; + const result = spec.interpretResponse(serverResponse, { bid: bannerBid }); + expect(result).to.have.lengthOf(0); + }); + + it('interprets banner bid response', function() { + const serverResponse = { + body: { + seatbid: [ + { bid: [{ impid: 'banner-1', price: 1.2, w: 320, h: 100, crid: 'c1', adm: '
ad
' }] } + ], + cur: 'USD' + } + }; + + const result = spec.interpretResponse(serverResponse, { bid: bannerBid }); + expect(result).to.have.lengthOf(1); + const r = result[0]; + expect(r.mediaType).to.equal('banner'); + expect(r.cpm).to.equal(1.2); + expect(r.ad).to.equal('
ad
'); + expect(r.width).to.equal(320); + expect(r.height).to.equal(100); + expect(r.creativeId).to.equal('c1'); + }); + + it('interprets video bid response', function() { + const serverResponse = { + body: { + seatbid: [ + { bid: [{ impid: 'video-1', price: 2.5, w: 1280, h: 720, crid: 'v1', adm: '', ext: { vast_url: 'http://vast.url' }, adomain: ['example.com'] }] } + ], + cur: 'USD' + } + }; + + const result = spec.interpretResponse(serverResponse, { bid: videoBid }); + expect(result).to.have.lengthOf(1); + const r = result[0]; + expect(r.mediaType).to.equal('video'); + expect(r.cpm).to.equal(2.5); + expect(r.vastXml).to.equal(''); + expect(r.vastUrl).to.equal('http://vast.url'); + expect(r.width).to.equal(1280); + expect(r.height).to.equal(720); + expect(r.creativeId).to.equal('v1'); + expect(r.meta.advertiserDomains).to.deep.equal(['example.com']); + }); + }); + + // ----------------------------- + describe('onTimeout', function() { + it('calls logWarn with timeout data', function() { + const logs = []; + const original = spec.onTimeout; + spec.onTimeout = (data) => logs.push(data); + + spec.onTimeout({ bidId: 'timeout-1' }); + expect(logs).to.have.lengthOf(1); + expect(logs[0].bidId).to.equal('timeout-1'); + + spec.onTimeout = original; + }); + }); + + describe('onBidWon', function() { + it('calls logInfo with bid won', function() { + const logs = []; + const original = spec.onBidWon; + spec.onBidWon = (bid) => logs.push(bid); + + spec.onBidWon({ bidId: 'won-1' }); + expect(logs).to.have.lengthOf(1); + expect(logs[0].bidId).to.equal('won-1'); + + spec.onBidWon = original; + }); + }); +});