diff --git a/modules/clydoBidAdapter.js b/modules/clydoBidAdapter.js new file mode 100644 index 00000000000..1ffdd3df474 --- /dev/null +++ b/modules/clydoBidAdapter.js @@ -0,0 +1,101 @@ +import { registerBidder } from '../src/adapters/bidderFactory.js'; +import { deepSetValue, deepAccess, isFn } from '../src/utils.js'; +import { BANNER, VIDEO, NATIVE } from '../src/mediaTypes.js'; +import { ortbConverter } from '../libraries/ortbConverter/converter.js'; + +const BIDDER_CODE = 'clydo'; +const METHOD = 'POST'; +const DEFAULT_CURRENCY = 'USD'; +const params = { + region: "{{region}}", + partnerId: "{{partnerId}}" +} +const BASE_ENDPOINT_URL = `https://${params.region}.clydo.io/${params.partnerId}` + +const converter = ortbConverter({ + context: { + netRevenue: true, + ttl: 30 + }, + bidResponse(buildBidResponse, bid, context) { + context.mediaType = deepAccess(bid, 'ext.mediaType'); + return buildBidResponse(bid, context) + } +}); + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO, NATIVE], + isBidRequestValid: function(bid) { + if (!bid || !bid.params) return false; + const { partnerId, region } = bid.params; + if (typeof partnerId !== 'string' || partnerId.length === 0) return false; + if (typeof region !== 'string') return false; + const allowedRegions = ['us', 'usw', 'eu', 'apac']; + return allowedRegions.includes(region); + }, + buildRequests: function(validBidRequests, bidderRequest) { + const data = converter.toORTB({bidRequests: validBidRequests, bidderRequest}); + const { partnerId, region } = validBidRequests[0].params; + + if (Array.isArray(data.imp)) { + data.imp.forEach((imp, index) => { + const srcBid = validBidRequests[index] || validBidRequests[0]; + const bidderParams = deepAccess(srcBid, 'params') || {}; + deepSetValue(data, `imp.${index}.ext.clydo`, bidderParams); + + const mediaType = imp.banner ? 'banner' : (imp.video ? 'video' : (imp.native ? 'native' : '*')); + let floor = deepAccess(srcBid, 'floor'); + if (!floor && isFn(srcBid.getFloor)) { + const floorInfo = srcBid.getFloor({currency: DEFAULT_CURRENCY, mediaType, size: '*'}); + if (floorInfo && typeof floorInfo.floor === 'number') { + floor = floorInfo.floor; + } + } + + if (typeof floor === 'number') { + deepSetValue(data, `imp.${index}.bidfloor`, floor); + deepSetValue(data, `imp.${index}.bidfloorcur`, DEFAULT_CURRENCY); + } + }); + } + + const ENDPOINT_URL = BASE_ENDPOINT_URL + .replace(params.partnerId, partnerId) + .replace(params.region, region); + + return [{ + method: METHOD, + url: ENDPOINT_URL, + data + }] + }, + interpretResponse: function(serverResponse, request) { + let bids = []; + let body = serverResponse.body || {}; + if (body) { + const normalized = Array.isArray(body.seatbid) + ? { + ...body, + seatbid: body.seatbid.map(seat => ({ + ...seat, + bid: (seat.bid || []).map(b => { + if (typeof b?.adm === 'string') { + try { + const parsed = JSON.parse(b.adm); + if (parsed && parsed.native && Array.isArray(parsed.native.assets)) { + return {...b, adm: JSON.stringify(parsed.native)}; + } + } catch (e) {} + } + return b; + }) + })) + } + : body; + bids = converter.fromORTB({response: normalized, request: request.data}).bids; + } + return bids; + }, +} +registerBidder(spec); diff --git a/modules/clydoBidAdapter.md b/modules/clydoBidAdapter.md new file mode 100644 index 00000000000..a7ec0b57800 --- /dev/null +++ b/modules/clydoBidAdapter.md @@ -0,0 +1,93 @@ +# Overview + +``` +Module Name: Clydo Bid Adapter +Module Type: Bidder Adapter +Maintainer: cto@clydo.io +``` + +# Description + +The Clydo adapter connects to the Clydo bidding endpoint to request bids using OpenRTB. + +- Supported media types: banner, video, native +- Endpoint is derived from parameters: `https://{region}.clydo.io/{partnerId}` +- Passes GDPR, USP/CCPA, and GPP consent when available +- Propagates `schain` and `userIdAsEids` + +# Bid Params + +- `partnerId` (string, required): Partner identifier provided by Clydo +- `region` (string, required): One of `us`, `usw`, `eu`, `apac` + +# Test Parameters (Banner) +```javascript +var adUnits = [{ + code: '/15185185/prebid_banner_example_1', + mediaTypes: { + banner: { + sizes: [[300, 250], [300, 600]] + } + }, + bids: [{ + bidder: 'clydo', + params: { + partnerId: 'abcdefghij', + region: 'us' + } + }] +}]; +``` + +# Test Parameters (Video) +```javascript +var adUnits = [{ + code: '/15185185/prebid_video_example_1', + mediaTypes: { + video: { + context: 'instream', + playerSize: [[640, 480]], + mimes: ['video/mp4'] + } + }, + bids: [{ + bidder: 'clydo', + params: { + partnerId: 'abcdefghij', + region: 'us' + } + }] +}]; +``` + +# Test Parameters (Native) +```javascript +var adUnits = [{ + code: '/15185185/prebid_native_example_1', + mediaTypes: { + native: { + title: { required: true }, + image: { required: true, sizes: [120, 120] }, + icon: { required: false, sizes: [50, 50] }, + body: { required: false }, + sponsoredBy: { required: false }, + clickUrl: { required: false }, + cta: { required: false } + } + }, + bids: [{ + bidder: 'clydo', + params: { + partnerId: 'abcdefghij', + region: 'us' + } + }] +}]; +``` + +# Notes + +- Floors: If the ad unit implements `getFloor`, the adapter forwards the value as `imp.bidfloor` (USD). +- Consent: When present, the adapter forwards `gdprApplies`/`consentString`, `uspConsent`, and `gpp`/`gpp_sid`. +- Supply Chain and IDs: `schain` is set under `source.ext.schain`; user IDs are forwarded under `user.ext.eids`. + diff --git a/test/spec/modules/clydoBidAdapter_spec.js b/test/spec/modules/clydoBidAdapter_spec.js new file mode 100644 index 00000000000..d26a6fca872 --- /dev/null +++ b/test/spec/modules/clydoBidAdapter_spec.js @@ -0,0 +1,75 @@ +import { expect } from 'chai'; +import { spec } from 'modules/clydoBidAdapter.js'; + +function makeBid(overrides = {}) { + return Object.assign({ + adUnitCode: '/15185185/prebid_example_1', + bidId: 'bid-1', + ortb2: {}, + ortb2Imp: {}, + mediaTypes: { + banner: { sizes: [[300, 250]] } + }, + bidder: 'clydo', + params: { + partnerId: 'abcdefghij', + region: 'us' + } + }, overrides); +} + +describe('clydoBidAdapter', () => { + describe('isBidRequestValid', () => { + it('returns false for missing params', () => { + expect(spec.isBidRequestValid(makeBid({ params: undefined }))).to.equal(false); + }); + it('returns false for invalid region', () => { + expect(spec.isBidRequestValid(makeBid({ params: { partnerId: 'x', region: 'xx' } }))).to.equal(false); + }); + it('returns true for valid params', () => { + expect(spec.isBidRequestValid(makeBid())).to.equal(true); + }); + }); + + describe('buildRequests', () => { + it('builds POST request with endpoint and JSON content type', () => { + const bid = makeBid(); + const reqs = spec.buildRequests([bid], {}); + expect(reqs).to.be.an('array').with.lengthOf(1); + const req = reqs[0]; + expect(req.method).to.equal('POST'); + expect(req.url).to.include('us'); + expect(req.url).to.include('abcdefghij'); + expect(req).to.not.have.property('options'); + expect(req).to.have.property('data'); + }); + + it('adds imp.ext.clydo and bidfloor when available', () => { + const bid = makeBid({ + getFloor: ({ currency }) => ({ floor: 1.5, currency }) + }); + const req = spec.buildRequests([bid], {})[0]; + const data = req.data; + expect(data.imp[0].ext.clydo).to.deep.equal(bid.params); + expect(data.imp[0].bidfloor).to.equal(1.5); + expect(data.imp[0].bidfloorcur).to.equal('USD'); + }); + + describe('banner', () => { + it('builds banner imp when mediaTypes.banner present', () => { + const bid = makeBid({ mediaTypes: { banner: { sizes: [[300, 250]] } } }); + const data = spec.buildRequests([bid], {})[0].data; + expect(data.imp[0]).to.have.property('banner'); + }); + }); + }); + + describe('interpretResponse', () => { + it('returns empty when body is null', () => { + const bid = makeBid(); + const req = spec.buildRequests([bid], {})[0]; + const res = spec.interpretResponse({ body: null }, req); + expect(res).to.be.an('array').that.is.empty; + }); + }); +});