diff --git a/modules/yieldmoBidAdapter.js b/modules/yieldmoBidAdapter.js index f606194e132..425f1d7af11 100644 --- a/modules/yieldmoBidAdapter.js +++ b/modules/yieldmoBidAdapter.js @@ -12,6 +12,7 @@ import { isNumber, isStr, logError, + logWarn, parseQueryStringParameters, parseUrl } from '../src/utils.js'; @@ -59,7 +60,7 @@ export const spec = { */ isBidRequestValid: function (bid) { return !!(bid && bid.adUnitCode && bid.bidId && (hasBannerMediaType(bid) || hasVideoMediaType(bid)) && - validateVideoParams(bid)); + validateVideoParams(bid) && validateBlocklistParams(bid)); }, /** @@ -151,6 +152,18 @@ export const spec = { if (eids.length) { serverRequest.eids = JSON.stringify(eids); }; + + // Blocklists (request-level): merge ortb2 + params, send as comma-delimited + // params per the ad server's prebid-js endpoint (AS-5349). Omitted when empty. + const bcat = getBlocklist(bidderRequest, bannerBidRequests[0], 'bcat'); + if (bcat.length) { + serverRequest.bcat = bcat.join(','); + } + const badv = getBlocklist(bidderRequest, bannerBidRequests[0], 'badv'); + if (badv.length) { + serverRequest.badv = badv.join(','); + } + // check if url exceeded max length const fullUrl = `${bannerUrl}?${parseQueryStringParameters(serverRequest)}`; let extraCharacters = fullUrl.length - MAX_BANNER_REQUEST_URL_LENGTH; @@ -398,6 +411,40 @@ function getId(request, idType) { return (typeof deepAccess(request, 'userId') === 'object') ? request.userId[idType] : undefined; } +/** + * Resolve a request-level blocklist field (bcat/badv) from its two sources — the + * standardized ORTB global (`bidderRequest.ortb2.`) and the Yieldmo-specific + * param (`bid.params.`) — into a single deduped array of strings. Neither + * source is allowed to silently win (union, not precedence). Invalid values are + * ignored and logged rather than dropping the bid: a source that isn't an array, and + * any non-string/empty element, are filtered out (with a warning) so a + * misconfiguration is surfaced without losing the impression. + * @param {BidderRequest} bidderRequest bidder request (source of ortb2.) + * @param {BidRequest} bid bid request (source of params.) + * @param {string} field blocklist field name — 'bcat' or 'badv' + * @return {string[]} deduped, trimmed, non-empty string entries (possibly empty) + */ +function getBlocklist(bidderRequest, bid, field) { + const normalize = (value, source) => { + if (value === undefined || value === null) { + return []; + } + if (!isArray(value)) { + logWarn(`yieldmo: ignoring ${source} blocklist value; expected an array of strings, got ${JSON.stringify(value)}`); + return []; + } + const dropped = value.filter(item => !isStr(item) || !item.trim()); + if (dropped.length) { + logWarn(`yieldmo: ignoring invalid ${source} blocklist entries (expected non-empty strings): ${JSON.stringify(dropped)}`); + } + return value.filter(item => isStr(item) && item.trim()).map(item => item.trim()); + }; + return [...new Set([ + ...normalize(deepAccess(bidderRequest, `ortb2.${field}`), 'ortb2'), + ...normalize(deepAccess(bid, `params.${field}`), 'params'), + ])]; +} + /** * @param {BidRequest[]} bidRequests bid request object * @param {BidderRequest} bidderRequest bidder request object @@ -412,8 +459,8 @@ function openRtbRequest(bidRequests, bidderRequest) { imp: bidRequests.map(bidRequest => openRtbImpression(bidRequest)), site: openRtbSite(bidRequests[0], bidderRequest), device: deepAccess(bidderRequest, 'ortb2.device'), - badv: bidRequests[0].params.badv || [], - bcat: deepAccess(bidderRequest, 'bcat') || bidRequests[0].params.bcat || [], + badv: getBlocklist(bidderRequest, bidRequests[0], 'badv'), + bcat: getBlocklist(bidderRequest, bidRequests[0], 'bcat'), ext: { prebid: '$prebid.version$', }, @@ -602,6 +649,54 @@ function populateOpenRtbGdpr(openRtbRequest, bidderRequest) { } } +const isDefined = val => typeof val !== 'undefined'; + +const paramRequired = (paramStr, value, conditionStr) => { + let error = `"${paramStr}" is required`; + if (conditionStr) { + error += ' when ' + conditionStr; + } + throw new Error(error); +}; + +const paramInvalid = (paramStr, value, expectedStr) => { + expectedStr = expectedStr ? ', expected: ' + expectedStr : ''; + value = JSON.stringify(value); + throw new Error(`"${paramStr}"=${value} is invalid${expectedStr}`); +}; + +/** + * Build a field validator bound to a bid. `video.*` paths are checked against both + * `params.video.*` and `mediaTypes.video.*`; any other path is read directly. + * The error callback (paramRequired/paramInvalid) throws, so callers wrap in try/catch. + * @param {BidRequest} bid bid request + * @return {(fieldPath: string, validateCb: Function, errorCb: Function, errorCbParam?: string) => *} + */ +const createParamValidator = (bid) => (fieldPath, validateCb, errorCb, errorCbParam) => { + if (fieldPath.indexOf('video') === 0) { + const valueFieldPath = 'params.' + fieldPath; + const mediaFieldPath = 'mediaTypes.' + fieldPath; + const valueParams = deepAccess(bid, valueFieldPath); + const mediaTypesParams = deepAccess(bid, mediaFieldPath); + const hasValidValueParams = validateCb(valueParams); + const hasValidMediaTypesParams = validateCb(mediaTypesParams); + + if (hasValidValueParams) return valueParams; + else if (hasValidMediaTypesParams) return hasValidMediaTypesParams; + else { + if (!hasValidValueParams) errorCb(valueFieldPath, valueParams, errorCbParam); + else if (!hasValidMediaTypesParams) errorCb(mediaFieldPath, mediaTypesParams, errorCbParam); + } + return valueParams || mediaTypesParams; + } else { + const value = deepAccess(bid, fieldPath); + if (!validateCb(value)) { + errorCb(fieldPath, value, errorCbParam); + } + return value; + } +}; + /** * Determines whether or not the given video bid request is valid. If it's not a video bid, returns true. * @param {object} bid bid to validate @@ -611,46 +706,7 @@ function validateVideoParams(bid) { if (!hasVideoMediaType(bid)) { return true; } - - const paramRequired = (paramStr, value, conditionStr) => { - let error = `"${paramStr}" is required`; - if (conditionStr) { - error += ' when ' + conditionStr; - } - throw new Error(error); - }; - - const paramInvalid = (paramStr, value, expectedStr) => { - expectedStr = expectedStr ? ', expected: ' + expectedStr : ''; - value = JSON.stringify(value); - throw new Error(`"${paramStr}"=${value} is invalid${expectedStr}`); - }; - - const isDefined = val => typeof val !== 'undefined'; - const validate = (fieldPath, validateCb, errorCb, errorCbParam) => { - if (fieldPath.indexOf('video') === 0) { - const valueFieldPath = 'params.' + fieldPath; - const mediaFieldPath = 'mediaTypes.' + fieldPath; - const valueParams = deepAccess(bid, valueFieldPath); - const mediaTypesParams = deepAccess(bid, mediaFieldPath); - const hasValidValueParams = validateCb(valueParams); - const hasValidMediaTypesParams = validateCb(mediaTypesParams); - - if (hasValidValueParams) return valueParams; - else if (hasValidMediaTypesParams) return hasValidMediaTypesParams; - else { - if (!hasValidValueParams) errorCb(valueFieldPath, valueParams, errorCbParam); - else if (!hasValidMediaTypesParams) errorCb(mediaFieldPath, mediaTypesParams, errorCbParam); - } - return valueParams || mediaTypesParams; - } else { - const value = deepAccess(bid, fieldPath); - if (!validateCb(value)) { - errorCb(fieldPath, value, errorCbParam); - } - return value; - } - }; + const validate = createParamValidator(bid); try { validate('video.context', val => !isEmpty(val), paramRequired); @@ -680,10 +736,28 @@ function validateVideoParams(bid) { validate('video.skippable', val => !isDefined(val) || isBoolean(val), paramInvalid); validate('video.skipafter', val => !isDefined(val) || isNumber(val), paramInvalid); validate('video.pos', val => !isDefined(val) || isNumber(val), paramInvalid); - validate('params.badv', val => !isDefined(val) || isArray(val), paramInvalid, - 'array of strings, ex: ["ford.com","pepsi.com"]'); + return true; + } catch (e) { + logError(e.message); + return false; + } +} + +/** + * Validate the publisher-set blocklist params (`bcat`/`badv`) for all media types + * (banner and video). A missing value is allowed; a value that is present but is + * not an array is rejected (drops the bid) — the agreed middle ground between + * dropping nothing and dropping over an absent optional field. See FS-12403. + * @param {BidRequest} bid bid request + * @return {boolean} true if valid (or absent), false if present but malformed + */ +function validateBlocklistParams(bid) { + const validate = createParamValidator(bid); + try { validate('params.bcat', val => !isDefined(val) || isArray(val), paramInvalid, 'array of strings, ex: ["IAB1-5","IAB1-6"]'); + validate('params.badv', val => !isDefined(val) || isArray(val), paramInvalid, + 'array of strings, ex: ["ford.com","pepsi.com"]'); return true; } catch (e) { logError(e.message); diff --git a/modules/yieldmoBidAdapter.md b/modules/yieldmoBidAdapter.md index c98e2ab5c74..135cd89c54f 100644 --- a/modules/yieldmoBidAdapter.md +++ b/modules/yieldmoBidAdapter.md @@ -31,7 +31,9 @@ var adUnits = [{ // Banner adUnit params: { placementId: '1779781193098233305', // string with at most 19 characters (may include numbers only) bidFloor: .28, // optional param - lr_env: '***' // Optional. Live Ramp ATS envelope + lr_env: '***', // Optional. Live Ramp ATS envelope + bcat: ['IAB1-5', 'IAB1-6'], // optional, array of blocked IAB content categories (strings) + badv: ['ford.com', 'pepsi.com'] // optional, array of blocked advertiser domains (strings) } }] }]; @@ -66,7 +68,9 @@ var adUnits = [{ // Video adUnit skippable: true, // optional, boolean skipafter: 10 // optional, integer }, - lr_env: '***' // Optional. Live Ramp ATS envelope + lr_env: '***', // Optional. Live Ramp ATS envelope + bcat: ['IAB1-5', 'IAB1-6'], // optional, array of blocked IAB content categories (strings) + badv: ['ford.com', 'pepsi.com'] // optional, array of blocked advertiser domains (strings) } }] }]; @@ -104,3 +108,20 @@ Please also note, that we support the following OpenRTB params: 'mimes', 'startdelay', 'placement', 'startdelay', 'skipafter', 'protocols', 'api', 'playbackmethod', 'maxduration', 'minduration', 'pos', 'skip', 'skippable'. They can be specified in `mediaTypes.video` or in `bids[].params.video`. + +# Blocklists (bcat / badv) + +`bcat` (blocked IAB content categories) and `badv` (blocked advertiser domains) are +supported for **both banner and video**. Each is an optional array of strings and can +be supplied from either source: + +* the standardized first-party-data global — `ortb2.bcat` / `ortb2.badv`, or +* the Yieldmo bid params — `bids[].params.bcat` / `bids[].params.badv`. + +When both sources are present they are **merged** (union) and de-duplicated — neither +source overrides the other. + +Validation: a **missing** `bcat`/`badv` is always allowed, but if `params.bcat` / +`params.badv` is **present and not an array** the bid is rejected (`isBidRequestValid` +returns false). Non-string / empty elements inside an otherwise-valid array, and a +malformed `ortb2` value, are dropped with a console warning rather than failing the bid. diff --git a/test/spec/modules/yieldmoBidAdapter_spec.js b/test/spec/modules/yieldmoBidAdapter_spec.js index 4f4454c17ae..24dc7c4721b 100644 --- a/test/spec/modules/yieldmoBidAdapter_spec.js +++ b/test/spec/modules/yieldmoBidAdapter_spec.js @@ -148,6 +148,28 @@ describe('YieldmoAdapter', function () { expect(spec.isBidRequestValid(getBidAndExclude('api'))).to.be.false; }); }); + + describe('Blocklist params (bcat / badv):', function () { + it('allows a bid when bcat/badv are absent (missing is fine)', function () { + expect(spec.isBidRequestValid(mockBannerBid())).to.be.true; + expect(spec.isBidRequestValid(mockVideoBid())).to.be.true; + }); + + it('allows a bid when bcat/badv are arrays', function () { + expect(spec.isBidRequestValid(mockBannerBid({}, { bcat: ['IAB1-1'], badv: ['x.com'] }))).to.be.true; + expect(spec.isBidRequestValid(mockVideoBid({}, { bcat: ['IAB1-1'], badv: ['x.com'] }))).to.be.true; + }); + + it('drops a bid when bcat is present but not an array', function () { + expect(spec.isBidRequestValid(mockBannerBid({}, { bcat: 'IAB1-1' }))).to.be.false; + expect(spec.isBidRequestValid(mockVideoBid({}, { bcat: 'IAB1-1' }))).to.be.false; + }); + + it('drops a bid when badv is present but not an array', function () { + expect(spec.isBidRequestValid(mockBannerBid({}, { badv: 'ford.com' }))).to.be.false; + expect(spec.isBidRequestValid(mockVideoBid({}, { badv: 'ford.com' }))).to.be.false; + }); + }); }); describe('buildRequests', function () { @@ -829,6 +851,89 @@ describe('YieldmoAdapter', function () { expect(payload.device.language).to.exist; }); }); + + describe('bcat / badv blocklists (FS-12403)', function () { + it('banner: sends merged bcat/badv as comma-delimited GET params', function () { + const bidderReq = mockBidderRequest({ ortb2: { bcat: ['IAB1-1'], badv: ['ortb.com'] } }); + const data = buildAndGetData([mockBannerBid({}, { bcat: ['IAB2-2'], badv: ['param.com'] })], 0, bidderReq); + expect(data.bcat).to.equal('IAB1-1,IAB2-2'); + expect(data.badv).to.equal('ortb.com,param.com'); + }); + + it('banner: unions ortb2 + params (neither source silently wins)', function () { + const bidderReq = mockBidderRequest({ ortb2: { bcat: ['A'] } }); + const data = buildAndGetData([mockBannerBid({}, { bcat: ['B'] })], 0, bidderReq); + expect(data.bcat.split(',')).to.have.members(['A', 'B']); + }); + + it('banner: dedupes values across the two sources, preserving order', function () { + const bidderReq = mockBidderRequest({ ortb2: { bcat: ['DUP', 'A'] } }); + const data = buildAndGetData([mockBannerBid({}, { bcat: ['DUP', 'B'] })], 0, bidderReq); + expect(data.bcat).to.equal('DUP,A,B'); + }); + + it('banner: reads from ortb2 alone', function () { + const bidderReq = mockBidderRequest({ ortb2: { bcat: ['IAB1-1'], badv: ['x.com'] } }); + const data = buildAndGetData([mockBannerBid()], 0, bidderReq); + expect(data.bcat).to.equal('IAB1-1'); + expect(data.badv).to.equal('x.com'); + }); + + it('banner: reads from params alone', function () { + const data = buildAndGetData([mockBannerBid({}, { bcat: ['IAB1-1'], badv: ['x.com'] })], 0, mockBidderRequest()); + expect(data.bcat).to.equal('IAB1-1'); + expect(data.badv).to.equal('x.com'); + }); + + it('banner: omits bcat/badv entirely when empty', function () { + const data = buildAndGetData([mockBannerBid()], 0, mockBidderRequest()); + expect(data).to.not.have.property('bcat'); + expect(data).to.not.have.property('badv'); + }); + + it('video: sends merged bcat/badv as deduped arrays', function () { + const bidderReq = mockBidderRequest({ ortb2: { bcat: ['IAB1-1'], badv: ['ortb.com'] } }, [mockVideoBid()]); + const payload = buildAndGetData([mockVideoBid({}, { bcat: ['IAB2-2'], badv: ['param.com'] })], 0, bidderReq); + expect(payload.bcat).to.deep.equal(['IAB1-1', 'IAB2-2']); + expect(payload.badv).to.deep.equal(['ortb.com', 'param.com']); + }); + + it('video: reads ortb2.bcat (not the legacy bidderRequest.bcat path)', function () { + const bidderReq = mockBidderRequest({ bcat: ['WRONG'], ortb2: { bcat: ['RIGHT'] } }, [mockVideoBid()]); + const payload = buildAndGetData([mockVideoBid()], 0, bidderReq); + expect(payload.bcat).to.deep.equal(['RIGHT']); + }); + + it('video: defaults to empty arrays when no blocklists are set', function () { + const payload = buildAndGetData([mockVideoBid()], 0, mockBidderRequest({}, [mockVideoBid()])); + expect(payload.bcat).to.deep.equal([]); + expect(payload.badv).to.deep.equal([]); + }); + + describe('blocklist normalization in mergeBlocklist', function () { + let logWarnStub; + beforeEach(function () { logWarnStub = sinon.stub(utils, 'logWarn'); }); + afterEach(function () { logWarnStub.restore(); }); + + it('ignores a non-array ortb2 source and warns (ortb2 is not bid-validated)', function () { + const bidderReq = mockBidderRequest({ ortb2: { bcat: 'IAB1-1' } }); + const data = buildAndGetData([mockBannerBid()], 0, bidderReq); + expect(data).to.not.have.property('bcat'); + expect(logWarnStub.called).to.be.true; + }); + + it('filters non-string / empty elements out of a valid array and warns', function () { + const data = buildAndGetData([mockBannerBid({}, { bcat: ['IAB1-1', '', 5, ' '] })], 0, mockBidderRequest()); + expect(data.bcat).to.equal('IAB1-1'); + expect(logWarnStub.called).to.be.true; + }); + + it('trims whitespace around entries', function () { + const data = buildAndGetData([mockBannerBid({}, { bcat: [' IAB1-1 '] })], 0, mockBidderRequest()); + expect(data.bcat).to.equal('IAB1-1'); + }); + }); + }); }); describe('interpretResponse', function () {