Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions modules/appnerveBidAdapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Overview

Module Name: Appnerve Bidder Adapter
Module Type: Bidder Adapter
Maintainer: prebid@appnerve.com

# Description

Connects Prebid.js publisher inventory to Appnerve Exchange. The production
endpoint is fixed by the adapter; publishers provide only their Appnerve
`sourceId`.

# Test Parameters

```javascript
var adUnits = [{
code: 'appnerve-banner-test',
mediaTypes: {
banner: {sizes: [[300, 250]]}
},
bids: [{
bidder: 'appnerve',
params: {sourceId: '74000976'}
}]
}];
```

Replace the sample ID with the permanent community-review source selected in
the Appnerve Prebid onboarding panel. Keep separate active sources and safe
creatives for video, native, and audio when declaring those formats.
197 changes: 197 additions & 0 deletions modules/appnerveBidAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import {registerBidder} from '../src/adapters/bidderFactory.js';

Check failure on line 1 in modules/appnerveBidAdapter.ts

View workflow job for this annotation

GitHub Actions / Run linter

A space is required before '}'

Check failure on line 1 in modules/appnerveBidAdapter.ts

View workflow job for this annotation

GitHub Actions / Run linter

A space is required after '{'
import {BANNER, VIDEO, NATIVE, AUDIO} from '../src/mediaTypes.js';

Check failure on line 2 in modules/appnerveBidAdapter.ts

View workflow job for this annotation

GitHub Actions / Run linter

A space is required before '}'

Check failure on line 2 in modules/appnerveBidAdapter.ts

View workflow job for this annotation

GitHub Actions / Run linter

A space is required after '{'

export const BIDDER_CODE = 'appnerve';
export const ENDPOINT = 'https://exchange.appnerve.net/openrtb2/auction';

function sourceId(bid: any): string {
return String(bid?.params?.sourceId || bid?.params?.placementId || '').trim();
}

function firstSize(sizes: any): number[] | undefined {
if (!Array.isArray(sizes)) return undefined;
const size = Array.isArray(sizes[0]) ? sizes[0] : sizes;
if (Number(size?.[0]) > 0 && Number(size?.[1]) > 0) return [Number(size[0]), Number(size[1])];
}

function bannerFormat(sizes: any): any[] {
if (!Array.isArray(sizes)) return [];
const rows = Array.isArray(sizes[0]) ? sizes : [sizes];
return rows.filter((size: any) => Number(size?.[0]) > 0 && Number(size?.[1]) > 0)
.map((size: any) => ({w: Number(size[0]), h: Number(size[1])}));

Check failure on line 21 in modules/appnerveBidAdapter.ts

View workflow job for this annotation

GitHub Actions / Run linter

A space is required before '}'

Check failure on line 21 in modules/appnerveBidAdapter.ts

View workflow job for this annotation

GitHub Actions / Run linter

A space is required after '{'
}

function requestedMediaTypes(bid: any): string[] {
return [BANNER, VIDEO, NATIVE, AUDIO].filter((type) => bid?.mediaTypes?.[type]);
}

function floorMediaType(bid: any): string {
return requestedMediaTypes(bid)[0] || BANNER;
}

function nativeRequest(native: any): any {
const assets: any[] = [];
let id = 1;
if (native?.title) assets.push({id: id++, required: native.title.required ? 1 : 0, title: {len: Number(native.title.len || 90)}});

Check failure on line 35 in modules/appnerveBidAdapter.ts

View workflow job for this annotation

GitHub Actions / Run linter

A space is required before '}'

Check failure on line 35 in modules/appnerveBidAdapter.ts

View workflow job for this annotation

GitHub Actions / Run linter

A space is required before '}'

Check failure on line 35 in modules/appnerveBidAdapter.ts

View workflow job for this annotation

GitHub Actions / Run linter

A space is required after '{'

Check failure on line 35 in modules/appnerveBidAdapter.ts

View workflow job for this annotation

GitHub Actions / Run linter

A space is required after '{'
if (native?.image) assets.push({id: id++, required: native.image.required ? 1 : 0, img: {type: 3, w: Number(native.image.sizes?.[0]), h: Number(native.image.sizes?.[1])}});
if (native?.icon) assets.push({id: id++, required: native.icon.required ? 1 : 0, img: {type: 1, w: Number(native.icon.sizes?.[0]), h: Number(native.icon.sizes?.[1])}});
if (native?.sponsoredBy) assets.push({id: id++, required: native.sponsoredBy.required ? 1 : 0, data: {type: 1, len: Number(native.sponsoredBy.len || 50)}});
if (native?.body) assets.push({id: id++, required: native.body.required ? 1 : 0, data: {type: 2, len: Number(native.body.len || 140)}});
if (native?.cta) assets.push({id: id++, required: native.cta.required ? 1 : 0, data: {type: 12, len: Number(native.cta.len || 30)}});
return {ver: '1.2', context: 1, plcmttype: 1, assets, eventtrackers: [{event: 1, methods: [1, 2]}]};
}

function impression(bid: any): any {
const imp: any = {
id: bid.bidId,
tagid: sourceId(bid),
secure: 1,
ext: {tid: bid.transactionId, prebid: {storedrequest: {id: sourceId(bid)}}}
Comment on lines +45 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve ortb2Imp when building impressions

For ad units with ortb2Imp values such as GPID/adserver data, instl, or publisher/prebid ext.tid, this builds a fresh OpenRTB impression and writes ext.tid from the legacy bid.transactionId, so those impression fields are dropped or overridden before the request is sent. Prebid carries the authoritative impression TID on bid.ortb2Imp.ext.tid, including publisher-supplied TIDs, so Appnerve can receive the wrong TID and miss GPID/FPD in those auctions; merge bid.ortb2Imp into imp and layer adapter-specific ext fields on top.

Useful? React with 👍 / 👎.

};
if (bid.adUnitCode) imp.ext.adunitcode = bid.adUnitCode;
if (bid.mediaTypes?.banner) {
const format = bannerFormat(bid.mediaTypes.banner.sizes || bid.sizes);
imp.banner = {format};
const size = firstSize(format.map((row) => [row.w, row.h]));
if (size) [imp.banner.w, imp.banner.h] = size;
}
if (bid.mediaTypes?.video) {
const video = {...bid.mediaTypes.video};
const size = firstSize(video.playerSize);
if (size) {
video.w = video.w || size[0];
video.h = video.h || size[1];
}
delete video.playerSize;
imp.video = video;
}
if (bid.mediaTypes?.native) imp.native = {request: JSON.stringify(nativeRequest(bid.mediaTypes.native)), ver: '1.2'};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use ORTB native requests for native impressions

When a publisher configures native with mediaTypes.native.ortb (or the nativeOrtbRequest derived by Prebid), this passes the wrapper object into nativeRequest(), which only reads legacy keys like title and image; ORTB-only native requests therefore serialize with an empty assets array and lose the required native asset definitions. Use bid.nativeOrtbRequest/bid.mediaTypes.native.ortb directly, or convert ORTB requests before building the impression.

Useful? React with 👍 / 👎.

if (bid.mediaTypes?.audio) imp.audio = {...bid.mediaTypes.audio};
if (typeof bid.getFloor === 'function') {
const floor = bid.getFloor({currency: 'USD', mediaType: floorMediaType(bid), size: '*'});
if (Number(floor?.floor) > 0) {
imp.bidfloor = Number(floor.floor);
imp.bidfloorcur = floor.currency || 'USD';
}
}
return imp;
}

function applyPrivacy(request: any, bidderRequest: any): void {
request.regs = {...(request.regs || {}), ext: {...(request.regs?.ext || {})}};
request.user = {...(request.user || {}), ext: {...(request.user?.ext || {})}};
if (bidderRequest?.gdprConsent) {
request.regs.ext.gdpr = bidderRequest.gdprConsent.gdprApplies ? 1 : 0;
if (bidderRequest.gdprConsent.consentString) request.user.ext.consent = bidderRequest.gdprConsent.consentString;
}
if (bidderRequest?.uspConsent) request.regs.ext.us_privacy = bidderRequest.uspConsent;
if (bidderRequest?.gppConsent?.gppString) request.regs.ext.gpp = bidderRequest.gppConsent.gppString;
if (Array.isArray(bidderRequest?.gppConsent?.applicableSections)) request.regs.ext.gpp_sid = bidderRequest.gppConsent.applicableSections;
}

function responseMediaType(bid: any, original: any): string {
const declared = bid?.ext?.prebid?.type;
if ([BANNER, VIDEO, NATIVE, AUDIO].includes(declared)) return declared;
const adm = typeof bid?.adm === 'string' ? bid.adm.trim() : '';
if (adm.startsWith('{') && /"native"\s*:/.test(adm)) return NATIVE;
if (/<VAST[\s>]/i.test(adm)) {
if (original?.mediaTypes?.audio && !original?.mediaTypes?.video) return AUDIO;
return VIDEO;
}
return original?.mediaTypes?.banner ? BANNER : requestedMediaTypes(original)[0] || BANNER;
}

function nativeResponse(adm: any): any {
let payload = adm;
try { payload = typeof adm === 'string' ? JSON.parse(adm) : adm; } catch { return null; }
const native = payload?.native || payload;
if (!native || !Array.isArray(native.assets) || !native.link?.url) return null;
const result: any = {
clickUrl: native.link.url,
clickTrackers: native.link.clicktrackers || [],
impressionTrackers: native.imptrackers || [],
javascriptTrackers: native.jstracker ? [native.jstracker] : []
};
native.assets.forEach((asset: any) => {
if (asset.title?.text) result.title = asset.title.text;
if (asset.img?.url && asset.img.type === 1) result.icon = {url: asset.img.url, width: asset.img.w, height: asset.img.h};
if (asset.img?.url && asset.img.type !== 1) result.image = {url: asset.img.url, width: asset.img.w, height: asset.img.h};
if (asset.data?.type === 1) result.sponsoredBy = asset.data.value;
if (asset.data?.type === 2) result.body = asset.data.value;
if (asset.data?.type === 12) result.cta = asset.data.value;
});
return result;
}

export const spec = {
code: BIDDER_CODE,
supportedMediaTypes: [BANNER, VIDEO, NATIVE, AUDIO],
isBidRequestValid(bid: any): boolean {
return Boolean(sourceId(bid) && bid?.bidId && requestedMediaTypes(bid).length);
},
buildRequests(validBidRequests: any[], bidderRequest: any): any[] {
const grouped = validBidRequests.reduce((groups: Map<string, any[]>, bid: any) => {
const id = sourceId(bid);
groups.set(id, [...(groups.get(id) || []), bid]);
return groups;
}, new Map<string, any[]>());
return [...grouped.entries()].map(([id, bids]) => {
const ortb2 = bidderRequest?.ortb2 || {};
const request: any = {
...ortb2,
id: bidderRequest?.bidderRequestId || bidderRequest?.auctionId,
imp: bids.map(impression),
at: Number(ortb2.at || 1),
tmax: bidderRequest?.timeout,
cur: Array.isArray(ortb2.cur) && ortb2.cur.length ? ortb2.cur : ['USD'],
source: {...(ortb2.source || {}), tid: ortb2.source?.tid || bidderRequest?.auctionId}
};
applyPrivacy(request, bidderRequest);
return {
method: 'POST',
url: `${ENDPOINT}?ssp_id=${encodeURIComponent(id)}`,
data: JSON.stringify(request),
bidMap: Object.fromEntries(bids.map((bid: any) => [bid.bidId, bid])),
options: {contentType: 'text/plain', withCredentials: false}
};
});
},
interpretResponse(serverResponse: any, request: any): any[] {
let body = serverResponse?.body || {};
if (typeof body === 'string') {
try { body = JSON.parse(body); } catch { return []; }
}
if (!Array.isArray(body?.seatbid)) return [];
const currency = String(body.cur || 'USD');
return body.seatbid.flatMap((seat: any) => Array.isArray(seat?.bid) ? seat.bid : []).flatMap((bid: any) => {
const original = request?.bidMap?.[bid?.impid];
const price = Number(bid?.price);
const creativeId = String(bid?.crid || bid?.id || '').trim();
if (!original || !bid?.impid || !Number.isFinite(price) || price <= 0 || !creativeId || !bid?.adm) return [];
const type = responseMediaType(bid, original);
const response: any = {
requestId: bid.impid, cpm: price, creativeId, currency,
netRevenue: true, ttl: Number(bid.exp || 300), mediaType: type,
dealId: bid.dealid, meta: {advertiserDomains: Array.isArray(bid.adomain) ? bid.adomain : []}
};
if (type === BANNER) {
const fallback = firstSize(original.mediaTypes?.banner?.sizes || original.sizes);
response.width = Number(bid.w || fallback?.[0]);
response.height = Number(bid.h || fallback?.[1]);
if (!response.width || !response.height) return [];
response.ad = bid.adm;
} else if (type === NATIVE) {
response.native = nativeResponse(bid.adm);
if (!response.native) return [];
} else {
response.vastXml = bid.adm;
}
return [response];
});
},
getUserSyncs(): any[] {
return [];
}
};

registerBidder(spec);

Check failure on line 197 in modules/appnerveBidAdapter.ts

View workflow job for this annotation

GitHub Actions / Build (all features disabled) / Build (linux)

Argument of type '{ code: string; supportedMediaTypes: string[]; isBidRequestValid(bid: any): boolean; buildRequests(validBidRequests: any[], bidderRequest: any): any[]; interpretResponse(serverResponse: any, request: any): any[]; getUserSyncs(): any[]; }' is not assignable to parameter of type 'BidderSpec<string>'.

Check failure on line 197 in modules/appnerveBidAdapter.ts

View workflow job for this annotation

GitHub Actions / Browser tests / build / Build (windows)

Argument of type '{ code: string; supportedMediaTypes: string[]; isBidRequestValid(bid: any): boolean; buildRequests(validBidRequests: any[], bidderRequest: any): any[]; interpretResponse(serverResponse: any, request: any): any[]; getUserSyncs(): any[]; }' is not assignable to parameter of type 'BidderSpec<string>'.

Check failure on line 197 in modules/appnerveBidAdapter.ts

View workflow job for this annotation

GitHub Actions / Browser tests / build / Build (linux)

Argument of type '{ code: string; supportedMediaTypes: string[]; isBidRequestValid(bid: any): boolean; buildRequests(validBidRequests: any[], bidderRequest: any): any[]; interpretResponse(serverResponse: any, request: any): any[]; getUserSyncs(): any[]; }' is not assignable to parameter of type 'BidderSpec<string>'.

Check failure on line 197 in modules/appnerveBidAdapter.ts

View workflow job for this annotation

GitHub Actions / Browser tests / build / Build (macos)

Argument of type '{ code: string; supportedMediaTypes: string[]; isBidRequestValid(bid: any): boolean; buildRequests(validBidRequests: any[], bidderRequest: any): any[]; interpretResponse(serverResponse: any, request: any): any[]; getUserSyncs(): any[]; }' is not assignable to parameter of type 'BidderSpec<string>'.
126 changes: 126 additions & 0 deletions test/spec/modules/appnerveBidAdapter_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import {expect} from 'chai';
import {ENDPOINT, spec} from 'modules/appnerveBidAdapter.js';

describe('appnerveBidAdapter', function () {
const bannerBid = {
bidId: 'bid-1', transactionId: 'transaction-1', adUnitCode: 'slot-1',
params: {sourceId: '74000976'},
mediaTypes: {banner: {sizes: [[300, 250], [320, 50]]}}
};
const requestContext = {
bidderRequestId: 'request-1', auctionId: 'auction-1', timeout: 1000,
ortb2: {site: {domain: 'publisher.example'}, source: {ext: {schain: {ver: '1.0', complete: 1, nodes: []}}}}
};

it('validates required parameters and media types', function () {
expect(spec.isBidRequestValid(bannerBid)).to.equal(true);
expect(spec.isBidRequestValid({...bannerBid, params: {placementId: '74000976'}})).to.equal(true);
expect(spec.isBidRequestValid({...bannerBid, params: {}})).to.equal(false);
expect(spec.isBidRequestValid({...bannerBid, mediaTypes: {}})).to.equal(false);
});

it('builds multi-size banner requests without a CORS preflight content type', function () {
const [request] = spec.buildRequests([bannerBid], requestContext);
const payload = JSON.parse(request.data);
expect(request.url).to.equal(`${ENDPOINT}?ssp_id=74000976`);
expect(request.options).to.deep.equal({contentType: 'text/plain', withCredentials: false});
expect(payload.site.domain).to.equal('publisher.example');
expect(payload.source.ext.schain.complete).to.equal(1);
expect(payload.imp[0].banner.format).to.deep.equal([{w: 300, h: 250}, {w: 320, h: 50}]);
});

it('groups multiple impressions by source ID', function () {
const requests = spec.buildRequests([
bannerBid,
{...bannerBid, bidId: 'bid-2', params: {sourceId: '74000976'}},
{...bannerBid, bidId: 'bid-3', params: {sourceId: '74000977'}}
], requestContext);
expect(requests).to.have.length(2);
expect(JSON.parse(requests[0].data).imp).to.have.length(2);
});

it('forwards GDPR, USP, GPP and COPPA signals', function () {
const [request] = spec.buildRequests([bannerBid], {
...requestContext,
ortb2: {...requestContext.ortb2, regs: {coppa: 1}},
gdprConsent: {gdprApplies: true, consentString: 'TC_STRING'},
uspConsent: '1YNN',
gppConsent: {gppString: 'DBABMA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA', applicableSections: [7, 8]}
});
const payload = JSON.parse(request.data);
expect(payload.regs.coppa).to.equal(1);
expect(payload.regs.ext.gdpr).to.equal(1);
expect(payload.regs.ext.us_privacy).to.equal('1YNN');
expect(payload.regs.ext.gpp_sid).to.deep.equal([7, 8]);
expect(payload.user.ext.consent).to.equal('TC_STRING');
});

it('adds floors and preserves currency', function () {
const bid = {...bannerBid, getFloor: () => ({floor: 1.25, currency: 'EUR'})};
const [request] = spec.buildRequests([bid], requestContext);
const imp = JSON.parse(request.data).imp[0];
expect(imp.bidfloor).to.equal(1.25);
expect(imp.bidfloorcur).to.equal('EUR');
});

it('builds video, native and audio impressions', function () {
const video = {...bannerBid, bidId: 'video', mediaTypes: {video: {context: 'outstream', playerSize: [[640, 360]], mimes: ['video/mp4'], protocols: [2, 3, 5, 6]}}};
const native = {...bannerBid, bidId: 'native', mediaTypes: {native: {title: {required: true, len: 90}, image: {required: true, sizes: [1200, 627]}, body: {required: true, len: 140}}}};
const audio = {...bannerBid, bidId: 'audio', mediaTypes: {audio: {mimes: ['audio/mpeg'], minduration: 5, maxduration: 30, protocols: [2, 3, 5, 6]}}};
const payload = JSON.parse(spec.buildRequests([video, native, audio], requestContext)[0].data);
expect(payload.imp[0].video.w).to.equal(640);
expect(JSON.parse(payload.imp[1].native.request).assets).to.have.length(3);
expect(payload.imp[2].audio.mimes).to.deep.equal(['audio/mpeg']);
});

it('uses the returned Prebid media type for multi-format bids', function () {
const multi = {...bannerBid, mediaTypes: {...bannerBid.mediaTypes, video: {context: 'outstream', playerSize: [[640, 360]]}}};
const response = spec.interpretResponse({body: {seatbid: [{bid: [{
id: 'bid-response', crid: 'creative-1', impid: 'bid-1', price: 0.2,
adm: '<div>banner</div>', w: 300, h: 250, ext: {prebid: {type: 'banner'}}
}]}]}}, {bidMap: {'bid-1': multi}});
expect(response[0].mediaType).to.equal('banner');
expect(response[0].ad).to.equal('<div>banner</div>');
});

it('maps OpenRTB Native 1.2 responses', function () {
const original = {...bannerBid, mediaTypes: {native: {title: {required: true}}}};
const adm = JSON.stringify({native: {
link: {url: 'https://brand.example', clicktrackers: ['https://tracker.example/click']},
imptrackers: ['https://tracker.example/imp'],
assets: [
{id: 1, title: {text: 'Native title'}},
{id: 2, img: {type: 3, url: 'https://cdn.example/main.jpg', w: 1200, h: 627}},
{id: 3, data: {type: 2, value: 'Native body'}}
]
}});
const response = spec.interpretResponse({body: {seatbid: [{bid: [{id: 'b', crid: 'c', impid: 'bid-1', price: 0.4, adm, ext: {prebid: {type: 'native'}}}]}]}}, {bidMap: {'bid-1': original}});
expect(response[0].native.title).to.equal('Native title');
expect(response[0].native.image.url).to.equal('https://cdn.example/main.jpg');
expect(response[0].native.clickUrl).to.equal('https://brand.example');
});

it('returns deals, currency and multiple valid bids', function () {
const body = {cur: 'EUR', seatbid: [
{seat: 'a', bid: [{id: 'b1', crid: 'c1', impid: 'bid-1', price: 0.2, adm: '<div>A</div>', w: 300, h: 250, dealid: 'deal-1'}]},
{seat: 'b', bid: [{id: 'b2', crid: 'c2', impid: 'bid-2', price: 0.3, adm: '<div>B</div>', w: 300, h: 250}]}
]};
const responses = spec.interpretResponse({body}, {bidMap: {'bid-1': bannerBid, 'bid-2': {...bannerBid, bidId: 'bid-2'}}});
expect(responses).to.have.length(2);
expect(responses[0].dealId).to.equal('deal-1');
expect(responses[0].currency).to.equal('EUR');
});

it('rejects empty, malformed, zero-price and incomplete bids', function () {
expect(spec.interpretResponse({body: 'not-json'}, {bidMap: {}})).to.deep.equal([]);
expect(spec.interpretResponse({body: {seatbid: []}}, {bidMap: {}})).to.deep.equal([]);
const invalid = (bid) => spec.interpretResponse({body: {seatbid: [{bid: [bid]}]}}, {bidMap: {'bid-1': bannerBid}});
expect(invalid({id: 'b', crid: 'c', impid: 'bid-1', price: 0, adm: '<div/>', w: 300, h: 250})).to.deep.equal([]);
expect(invalid({id: 'b', crid: 'c', impid: 'bid-1', price: 1, w: 300, h: 250})).to.deep.equal([]);
expect(invalid({impid: 'bid-1', price: 1, adm: '<div/>', w: 300, h: 250})).to.deep.equal([]);
});

it('does not perform user syncing', function () {
expect(spec.getUserSyncs()).to.deep.equal([]);
});
});
Loading