diff --git a/modules/haloadsBidAdapter.md b/modules/haloadsBidAdapter.md new file mode 100644 index 00000000000..9b5e7f44822 --- /dev/null +++ b/modules/haloadsBidAdapter.md @@ -0,0 +1,130 @@ +# Overview + +Module Name: Haloads Bidder Adapter +Module Type: Bidder Adapter +Maintainer: info.litelabs@gmail.com + +**Bidder code:** `haloads` + +# Description + +The Haloads Bidder Adapter connects to Haloads exchange for bids. This adapter supports banner, video, and native ad formats and implements the OpenRTB 2.5 protocol. + +**Currency** + +- Haloads returns prices in **USD**. The adapter always sets `bid.currency` to `USD` + +**Placement mapping** + +- The adapter sets `imp.tagid` from `bid.params.placementId`. This value must match the placement identifier provided by Haloads team. +- The adapter also requires accountId as provided by Haloads team. + +**Supported media types** + +- Banner (`adm` → `bid.ad`) +- Video (`adm` → `bid.vastXml`; optional `nurl` → `bid.vastUrl`) +- Native (`adm` as OpenRTB Native JSON → `bid.native.ortb`) + +# Bid Parameters + +| Name | Scope | Type | Description | +|----------------|----------|--------|-------------| +| `placementId` | Required | String | Placement ID on Haloads | +| `accountId` | Required | String | Account ID for the publisher as configured on Haloads | + +# Test Parameters + +## Banner Ad Unit + +```javascript +var adUnits = [ + { + code: 'banner-ad-unit', + mediaTypes: { + banner: { + sizes: [[300, 250], [728, 90]] + } + }, + bids: [{ + bidder: 'haloads', + params: { + accountId: '29291001', + placementId: '12345' + } + }] + } +]; +``` + +## Video Ad Unit + +```javascript +var adUnits = [ + { + code: 'video-ad-unit', + mediaTypes: { + video: { + context: 'instream', + playerSize: [[640, 480]], + mimes: ['video/mp4'], + protocols: [2, 3, 5, 6], + api: [2] + } + }, + bids: [{ + bidder: 'haloads', + params: { + accountId: '291001', + placementId: 'video-12345' + } + }] + } +]; +``` + +## Native Ad Unit + +```javascript +var adUnits = [ + { + code: 'native-ad-unit', + mediaTypes: { + native: { + title: { + required: true, + len: 80 + }, + image: { + required: true, + sizes: [300, 250] + }, + sponsoredBy: { + required: true + } + } + }, + bids: [{ + bidder: 'haloads', + params: { + accountId: '291001', + placementId: 'native-12345' + } + }] + } +]; +``` + +## Configuration + +### User Sync + +The adapter automatically handles user syncing with privacy consent parameters (GDPR, USP, GPP). + +### Event Tracking + +The adapter supports the following event callbacks: +- `onBidWon`: Triggered when a bid wins the auction +- `onAdRenderSucceeded`: Triggered when an ad successfully renders +- `onBidderError`: Triggered when a bidder error occurs +- `onTimeout`: Triggered when a bid request times out + diff --git a/modules/haloadsBidAdapter.ts b/modules/haloadsBidAdapter.ts new file mode 100644 index 00000000000..7f396bebacc --- /dev/null +++ b/modules/haloadsBidAdapter.ts @@ -0,0 +1,195 @@ +import { BidderSpec, ExtendedResponse, registerBidder } from '../src/adapters/bidderFactory.js'; +import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js'; +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; +import { triggerPixel, deepAccess } from '../src/utils.js'; +import { tryAppendQueryString } from '../libraries/urlUtils/urlUtils.js'; +import type { BidRequest } from '../src/adapterManager.js'; + +const BIDDER_CODE = 'haloads'; +const ENDPOINT = 'https://ads.haloads.io/bid'; +const SYNC_URL = 'https://ads.haloads.io/cookie_sync'; +const EVENT_TRACKING_URL = 'https://analytics.haloads.io/event'; + +interface HaloadsBidParams { + accountId: string; + placementId: string | number; + [key: string]: unknown; +} + +declare module '../src/adUnits' { + interface BidderParams { + [BIDDER_CODE]: HaloadsBidParams; + } +} + +declare module '../src/bidfactory' { + interface BannerBidProperties { + pbsWurl?: string; + ext?: Record; + } + interface VideoBidProperties { + pbsWurl?: string; + ext?: Record; + } + interface NativeBidProperties { + pbsWurl?: string; + ext?: Record; + } +} + +const converter = ortbConverter({ + context: { + netRevenue: true, + ttl: 300 + }, + imp: function (buildImp, bidRequest: BidRequest, context) { + const imp = buildImp(bidRequest, context); + imp.tagid = bidRequest.params.placementId.toString(); + return imp; + }, + bidResponse: function (buildBidResponse, bid, context) { + const bidResponse = buildBidResponse(bid, context) as ReturnType & { ext?: unknown }; + const bidAny = bid as unknown as Record; + if (bidAny.ext) { + bidResponse.ext = bidAny.ext; + } + if (bidResponse.mediaType === VIDEO) { + const vBidResponse = bidResponse as typeof bidResponse & { vastXml?: string }; + if (!vBidResponse.vastXml && bidAny.adm) { + vBidResponse.vastXml = bidAny.adm as string; + } + } + return bidResponse; + } +}); + +export const spec: BidderSpec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO, NATIVE], + + isBidRequestValid: function (bid) { + return !!(bid.params && bid.params.accountId && bid.params.placementId); + }, + + buildRequests: function (validBidRequests, bidderRequest) { + const data = converter.toORTB({ bidderRequest, bidRequests: validBidRequests as any }); + + const accountId = validBidRequests[0].params.accountId; + if (accountId && data.site) { + if (!data.site.publisher) { + data.site.publisher = {}; + } + data.site.publisher.id = accountId.toString(); + } + + return { + method: 'POST', + url: ENDPOINT, + data: data, + options: { + withCredentials: true + } + }; + }, + + interpretResponse: function (serverResponse, bidRequest) { + if (!serverResponse || !serverResponse.body) { + return []; + } + + const ortbResponse = converter.fromORTB({ response: serverResponse.body, request: bidRequest.data }); + return (ortbResponse as ExtendedResponse).bids ?? []; + }, + + getUserSyncs: function (syncOptions, serverResponses, gdprConsent, uspConsent, gppConsent) { + const syncs: { type: 'iframe' | 'image'; url: string }[] = []; + + if (!syncOptions.iframeEnabled && !syncOptions.pixelEnabled) { + return syncs; + } + + let query = ''; + if (gdprConsent) { + query = tryAppendQueryString(query, 'gdpr', String(gdprConsent.gdprApplies ? 1 : 0)); + } + if (gdprConsent && typeof gdprConsent.consentString === 'string') { + query = tryAppendQueryString(query, 'gdpr_consent', gdprConsent.consentString); + } + if (uspConsent) { + query = tryAppendQueryString(query, 'us_privacy', uspConsent as string); + } + if (gppConsent?.gppString && gppConsent?.applicableSections?.length) { + query = tryAppendQueryString(query, 'gpp', gppConsent.gppString); + query = tryAppendQueryString(query, 'gpp_sid', gppConsent.applicableSections.join(',')); + } + if (query.slice(-1) === '&') { + query = query.slice(0, -1); + } + + if (syncOptions.iframeEnabled) { + syncs.push({ + type: 'iframe', + url: `${SYNC_URL}/sync.html${query ? '?' + query : ''}` + }); + } + if (syncOptions.pixelEnabled) { + syncs.push({ + type: 'image', + url: `${SYNC_URL}/sync.png${query ? '?' + query : ''}` + }); + } + return syncs; + }, + + onBidWon: function (bid) { + if (bid.pbsWurl) { + triggerPixel(bid.pbsWurl); + } + }, + + onAdRenderSucceeded: function (bid) { + const impUrl = deepAccess(bid, 'ext.prebid.events.imp') || deepAccess(bid, 'meta.ext.prebid.events.imp'); + if (impUrl) { + triggerPixel(impUrl); + } + }, + + onBidderError: function (errorData) { + let query = 'eventType=error'; + + if (errorData) { + if (errorData.error) { + const status = errorData.error.status != null ? String(errorData.error.status) : 'unknown'; + query = tryAppendQueryString(query, 'error', status); + query = tryAppendQueryString(query, 'status', status); + } + if (errorData.bidderRequest) { + query = tryAppendQueryString(query, 'auctionId', errorData.bidderRequest.auctionId || ''); + } + if (query.slice(-1) === '&') { + query = query.slice(0, -1); + } + } + + triggerPixel(`${EVENT_TRACKING_URL}?${query}`); + }, + + onTimeout: function (timeoutData) { + if (!timeoutData || timeoutData.length === 0) { + return; + } + + timeoutData.forEach(function (entry) { + let query = 'eventType=timeout'; + query = tryAppendQueryString(query, 'auctionId', entry.auctionId || ''); + query = tryAppendQueryString(query, 'timeout', String(entry.timeout)); + query = tryAppendQueryString(query, 'adUnitCode', entry.adUnitCode || ''); + if (query.slice(-1) === '&') { + query = query.slice(0, -1); + } + triggerPixel(`${EVENT_TRACKING_URL}?${query}`); + }); + } +}; + +registerBidder(spec); diff --git a/test/spec/modules/haloadsBidAdapter_spec.js b/test/spec/modules/haloadsBidAdapter_spec.js new file mode 100644 index 00000000000..fd103edaf22 --- /dev/null +++ b/test/spec/modules/haloadsBidAdapter_spec.js @@ -0,0 +1,390 @@ +import { expect } from 'chai'; +import { spec } from 'modules/haloadsBidAdapter.ts'; +import { newBidder } from 'src/adapters/bidderFactory.js'; + +describe('Haloads Bidder Adapter', function () { + const adapter = newBidder(spec); + + describe('inherited functions', function () { + it('exists and is a function', function () { + expect(adapter.callBids).to.exist.and.to.be.a('function'); + }); + }); + + describe('isBidRequestValid', function () { + it('should return true when required params found', function () { + const bid = { + params: { + accountId: '123', + placementId: '12345' + } + }; + expect(spec.isBidRequestValid(bid)).to.equal(true); + }); + + it('should return false when accountId is missing', function () { + const bid = { + params: { + placementId: '12345' + } + }; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when placementId is missing', function () { + const bid = { + params: { + accountId: '123' + } + }; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + + it('should return false when params are missing entirely', function () { + const bid = {}; + expect(spec.isBidRequestValid(bid)).to.equal(false); + }); + }); + + describe('buildRequests', function () { + const bidRequests = [ + { + bidder: 'haloads', + params: { + accountId: '123', + placementId: '12345' + }, + adUnitCode: 'adunit-code', + sizes: [[300, 250]], + bidId: 'bid-id', + bidderRequestId: 'bidder-request-id', + auctionId: 'auction-id', + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + } + } + ]; + + const bidderRequest = { + refererInfo: { + page: 'https://example.com' + } + }; + + it('should return a valid request object', function () { + const request = spec.buildRequests(bidRequests, bidderRequest); + expect(request).to.be.an('object'); + expect(request.method).to.equal('POST'); + expect(request.url).to.equal('https://ads.haloads.io/bid'); + expect(request.data).to.be.an('object'); + expect(request.data.imp[0].tagid).to.equal('12345'); + }); + }); + + describe('interpretResponse', function () { + it('should return empty array if no body', function () { + const serverResponse = {}; + const bidRequest = {}; + const bids = spec.interpretResponse(serverResponse, bidRequest); + expect(bids).to.be.an('array').that.is.empty; + }); + + it('should return valid bids', function () { + const bidRequests = [ + { + bidder: 'haloads', + params: { + accountId: '123', + placementId: '12345' + }, + adUnitCode: 'adunit-code', + sizes: [[300, 250]], + bidId: 'bid-id', + bidderRequestId: 'bidder-request-id', + auctionId: 'auction-id', + mediaTypes: { + banner: { + sizes: [[300, 250]] + } + } + } + ]; + const bidderRequest = { + refererInfo: { + page: 'https://example.com' + } + }; + const request = spec.buildRequests(bidRequests, bidderRequest); + + const serverResponse = { + body: { + seatbid: [ + { + bid: [ + { + id: 'bid-id', + impid: 'bid-id', // impid matches bidId in ortbConverter + mtype: 1, // Banner + price: 1.0, + adm: '
ad
', + crid: 'creative-id', + w: 300, + h: 250 + } + ] + } + ] + } + }; + + const bids = spec.interpretResponse(serverResponse, request); + expect(bids).to.be.an('array').that.has.lengthOf(1); + expect(bids[0].cpm).to.equal(1.0); + expect(bids[0].width).to.equal(300); + expect(bids[0].height).to.equal(250); + expect(bids[0].ad).to.equal('
ad
'); + }); + + it('should return valid video bids', function () { + const bidRequests = [ + { + bidder: 'haloads', + params: { + accountId: '123', + placementId: '12345' + }, + adUnitCode: 'video-ad-unit', + bidId: 'bid-id', + bidderRequestId: 'bidder-request-id', + auctionId: 'auction-id', + mediaTypes: { + video: { + context: 'outstream', + playerSize: [640, 360] + } + } + } + ]; + const bidderRequest = { + refererInfo: { + page: 'https://example.com' + } + }; + const request = spec.buildRequests(bidRequests, bidderRequest); + + const serverResponse = { + body: { + seatbid: [ + { + bid: [ + { + id: 'bid-id', + impid: 'bid-id', + mtype: 2, // Video + price: 5.0, + adm: '...', + crid: 'creative-id', + w: 640, + h: 360 + } + ] + } + ] + } + }; + + const bids = spec.interpretResponse(serverResponse, request); + expect(bids).to.be.an('array').that.has.lengthOf(1); + expect(bids[0].cpm).to.equal(5.0); + expect(bids[0].width).to.equal(640); + expect(bids[0].height).to.equal(360); + expect(bids[0].vastXml).to.equal('...'); + expect(bids[0].mediaType).to.equal('video'); + }); + }); + + describe('getUserSyncs', function () { + it('should return empty array if no sync options enabled', function () { + const syncOptions = { + iframeEnabled: false, + pixelEnabled: false + }; + const syncs = spec.getUserSyncs(syncOptions); + expect(syncs).to.be.an('array').that.is.empty; + }); + + it('should return iframe sync if enabled', function () { + const syncOptions = { + iframeEnabled: true + }; + const syncs = spec.getUserSyncs(syncOptions); + expect(syncs).to.deep.include({ + type: 'iframe', + url: 'https://ads.haloads.io/cookie_sync/sync.html' + }); + }); + + it('should return pixel sync if enabled', function () { + const syncOptions = { + pixelEnabled: true + }; + const syncs = spec.getUserSyncs(syncOptions); + expect(syncs).to.deep.include({ + type: 'image', + url: 'https://ads.haloads.io/cookie_sync/sync.png' + }); + }); + + it('should append GDPR consent parameters', function () { + const syncOptions = { iframeEnabled: true }; + const gdprConsent = { + gdprApplies: true, + consentString: 'CONSENT_STRING' + }; + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent); + expect(syncs[0].url).to.include('gdpr=1'); + expect(syncs[0].url).to.include('gdpr_consent=CONSENT_STRING'); + }); + + it('should append US Privacy string', function () { + const syncOptions = { iframeEnabled: true }; + const uspConsent = '1YNN'; + const syncs = spec.getUserSyncs(syncOptions, [], null, uspConsent); + expect(syncs[0].url).to.include('us_privacy=1YNN'); + }); + + it('should append GPP consent parameters', function () { + const syncOptions = { iframeEnabled: true }; + const gppConsent = { + gppString: 'GPP_STRING', + applicableSections: [1, 2, 3] + }; + const syncs = spec.getUserSyncs(syncOptions, [], null, null, gppConsent); + expect(syncs[0].url).to.include('gpp=GPP_STRING'); + expect(syncs[0].url).to.include('gpp_sid=1%2C2%2C3'); + }); + + it('should append all privacy parameters together', function () { + const syncOptions = { pixelEnabled: true }; + const gdprConsent = { + gdprApplies: false, + consentString: 'CONSENT' + }; + const uspConsent = '1YNN'; + const gppConsent = { + gppString: 'GPP', + applicableSections: [1] + }; + const syncs = spec.getUserSyncs(syncOptions, [], gdprConsent, uspConsent, gppConsent); + expect(syncs[0].url).to.include('gdpr=0'); + expect(syncs[0].url).to.include('gdpr_consent=CONSENT'); + expect(syncs[0].url).to.include('us_privacy=1YNN'); + expect(syncs[0].url).to.include('gpp=GPP'); + }); + }); + + describe('onBidWon', function () { + it('should exist and be a function', function () { + expect(spec.onBidWon).to.exist.and.to.be.a('function'); + }); + + it('should trigger pbsWurl if present', function () { + const bid = { + pbsWurl: 'https://analytics.haloads.io/win?id=123' + }; + // This would require sinon to spy on triggerPixel + // For now, just ensure it doesn't throw + expect(() => spec.onBidWon(bid)).to.not.throw(); + }); + + it('should trigger nurl if present', function () { + const bid = { + nurl: 'https://analytics.haloads.io/notify?id=456' + }; + expect(() => spec.onBidWon(bid)).to.not.throw(); + }); + + it('should handle bid with multiple URLs', function () { + const bid = { + pbsWurl: 'https://analytics.haloads.io/win', + nurl: 'https://analytics.haloads.io/notify' + }; + expect(() => spec.onBidWon(bid)).to.not.throw(); + }); + }); + + describe('onAdRenderSucceeded', function () { + it('should exist and be a function', function () { + expect(spec.onAdRenderSucceeded).to.exist.and.to.be.a('function'); + }); + + it('should trigger custom imp event if present', function () { + const bid = { + ext: { + prebid: { + events: { + imp: 'https://analytics.haloads.io/imp?id=789' + } + } + } + }; + expect(() => spec.onAdRenderSucceeded(bid)).to.not.throw(); + }); + + it('should handle bid without custom imp event', function () { + const bid = {}; + expect(() => spec.onAdRenderSucceeded(bid)).to.not.throw(); + }); + }); + + describe('onBidderError', function () { + it('should exist and be a function', function () { + expect(spec.onBidderError).to.exist.and.to.be.a('function'); + }); + + it('should trigger event URL with error parameters', function () { + const errorData = { + error: { + message: 'Network error', + status: 500 + }, + bidderRequest: { + auctionId: 'auction-123' + } + }; + expect(() => spec.onBidderError(errorData)).to.not.throw(); + }); + + it('should handle error without bidderRequest', function () { + const errorData = { + error: { + message: 'Timeout', + status: 408 + } + }; + expect(() => spec.onBidderError(errorData)).to.not.throw(); + }); + }); + + describe('onTimeout', function () { + it('should exist and be a function', function () { + expect(spec.onTimeout).to.exist.and.to.be.a('function'); + }); + + it('should trigger event URL with timeout parameters', function () { + const timeoutData = [{ + auctionId: 'auction-456', + timeout: 3000, + adUnitCode: 'div-gpt-ad-123' + }]; + expect(() => spec.onTimeout(timeoutData)).to.not.throw(); + }); + + it('should handle empty timeout data', function () { + const timeoutData = []; + expect(() => spec.onTimeout(timeoutData)).to.not.throw(); + }); + }); +});