diff --git a/modules/akceloBidAdapter.js b/modules/akceloBidAdapter.js
new file mode 100644
index 00000000000..bfada1cc2eb
--- /dev/null
+++ b/modules/akceloBidAdapter.js
@@ -0,0 +1,148 @@
+import { registerBidder } from '../src/adapters/bidderFactory.js';
+import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js';
+import { deepSetValue, getParameterByName, logError } from '../src/utils.js';
+import { ortbConverter } from '../libraries/ortbConverter/converter.js';
+import { ORTB_MTYPES } from '../libraries/ortbConverter/processors/mediaType.js';
+
+/**
+ * @typedef {import('../src/adapters/bidderFactory.js').BidRequest} BidRequest
+ * @typedef {import('../src/adapters/bidderFactory.js').BidderRequest} BidderRequest
+ * @typedef {import('../src/adapters/bidderFactory.js').Bid} Bid
+ * @typedef {import('../src/adapters/bidderFactory.js').ServerResponse} ServerResponse
+ * @typedef {import('../src/adapters/bidderFactory.js').SyncOptions} SyncOptions
+ * @typedef {import('../src/adapters/bidderFactory.js').UserSync} UserSync
+ */
+
+const BIDDER_CODE = 'akcelo';
+const COOKIE_SYNC_ENDPOINT = 'akcelo';
+
+const AUCTION_URL = 'https://s2s.sportslocalmedia.com/openrtb2/auction';
+const IFRAME_SYNC_URL = 'https://ads.sportslocalmedia.com/load-cookie.html';
+
+const DEFAULT_TTL = 300;
+
+const akceloDemoIsOn = () => getParameterByName('akcelo_demo') === 'true';
+
+export const converter = ortbConverter({
+ context: {
+ netRevenue: true,
+ ttl: DEFAULT_TTL,
+ },
+ imp(buildImp, bidRequest, context) {
+ const imp = buildImp(bidRequest, context);
+
+ if (bidRequest.params.siteId) {
+ deepSetValue(imp, 'ext.akcelo.siteId', bidRequest.params.siteId);
+ } else {
+ logError('Missing parameter : siteId');
+ }
+
+ if (bidRequest.params.adUnitId) {
+ deepSetValue(imp, 'ext.akcelo.adUnitId', bidRequest.params.adUnitId);
+ } else {
+ logError('Missing parameter : adUnitId');
+ }
+
+ if (akceloDemoIsOn()) {
+ deepSetValue(imp, 'ext.akcelo.test', 1);
+ }
+
+ return imp;
+ },
+ request(buildRequest, imps, bidderRequest, context) {
+ const request = buildRequest(imps, bidderRequest, context);
+
+ deepSetValue(request, 'test', akceloDemoIsOn() ? 1 : 0);
+
+ const siteId = bidderRequest.bids.map((bid) => bid.params.siteId).find(Boolean);
+ deepSetValue(request, 'site.publisher.ext.prebid.parentAccount', siteId);
+
+ return request;
+ },
+ bidResponse(buildBidResponse, bid, context) {
+ // In ORTB 2.5, bid responses do not specify their mediatype, which is something Prebid.js requires
+ context.mediaType = bid.mtype && ORTB_MTYPES[bid.mtype]
+ ? ORTB_MTYPES[bid.mtype]
+ : bid.ext?.prebid?.type;
+
+ return buildBidResponse(bid, context);
+ },
+});
+
+export const spec = {
+ code: BIDDER_CODE,
+ supportedMediaTypes: [BANNER, NATIVE, VIDEO],
+
+ /**
+ * Determines whether the given bid request is valid.
+ *
+ * @param {Bid} bid The bid to validate.
+ * @return boolean True if this is a valid bid, and false otherwise.
+ */
+ isBidRequestValid(bid) {
+ if (!bid?.params?.adUnitId) {
+ logError("Missing required parameter 'adUnitId'");
+ return false;
+ }
+ if (!bid?.params?.siteId) {
+ logError("Missing required parameter 'siteId'");
+ return false;
+ }
+ return true;
+ },
+ /**
+ * Make a server request from the list of BidRequests.
+ *
+ * @param {BidRequest[]} bidRequests A non-empty list of bid requests which should be sent to the Server.
+ * @param {BidderRequest} bidderRequest bidder request object.
+ * @return ServerRequest Info describing the request to the server.
+ */
+ buildRequests(bidRequests, bidderRequest) {
+ const data = converter.toORTB({ bidRequests, bidderRequest });
+
+ return [{ method: 'POST', url: AUCTION_URL, data }];
+ },
+
+ /**
+ * Unpack the response from the server into a list of bids.
+ *
+ * @param {ServerResponse} serverResponse A successful response from the server.
+ * @param {BidRequest} bidRequest The bid request sent to the server.
+ * @return {Bid[]} An array of bids which were nested inside the server.
+ */
+ interpretResponse(serverResponse, bidRequest) {
+ const { bids } = converter.fromORTB({
+ response: serverResponse.body,
+ request: bidRequest.data,
+ });
+
+ return bids;
+ },
+
+ /**
+ * Register the user sync pixels which should be dropped after the auction.
+ *
+ * @param {SyncOptions} syncOptions Which user syncs are allowed?
+ * @param {ServerResponse[]} serverResponses List of server's responses.
+ * @param {*} gdprConsent
+ * @param {*} uspConsent
+ * @return {UserSync[]} The user syncs which should be dropped.
+ */
+ getUserSyncs(syncOptions, serverResponses, gdprConsent, uspConsent) {
+ if (syncOptions.iframeEnabled) {
+ let syncParams = `?endpoint=${COOKIE_SYNC_ENDPOINT}`;
+ if (gdprConsent) {
+ syncParams += `&gdpr=${gdprConsent.gdprApplies ? 1 : 0}`;
+ syncParams += `&gdpr_consent=${encodeURIComponent(gdprConsent.consentString || '')}`;
+ }
+ if (uspConsent) {
+ syncParams += `&us_privacy=${encodeURIComponent(uspConsent)}`;
+ }
+
+ return [{ type: 'iframe', url: IFRAME_SYNC_URL + syncParams }];
+ }
+ return [];
+ },
+};
+
+registerBidder(spec);
diff --git a/modules/akceloBidAdapter.md b/modules/akceloBidAdapter.md
new file mode 100644
index 00000000000..02881ede2e1
--- /dev/null
+++ b/modules/akceloBidAdapter.md
@@ -0,0 +1,57 @@
+# Overview
+
+**Module Name**: Akcelo Bidder Adapter
+**Module Type**: Bidder Adapter
+**Maintainer**: tech@akcelo.io
+
+# Description
+
+A module that connects to the Akcelo network for bids
+
+## AdUnits configuration example
+
+```javascript
+const adUnits = [
+ {
+ code: 'div-123',
+ mediaTypes: {
+ banner: {
+ sizes: [
+ [300, 250],
+ [300, 600],
+ ],
+ },
+ video: {
+ context: "outstream",
+ playerSize: [[640, 480]],
+ mimes: ['video/mp4'],
+ protocols: [1, 2, 3, 4, 5, 6, 7, 8],
+ playbackmethod: [1],
+ skip: 1,
+ api: [2],
+ minbitrate: 1000,
+ maxbitrate: 3000,
+ minduration: 3,
+ maxduration: 10,
+ startdelay: 2,
+ placement: 4,
+ linearity: 1
+ },
+ },
+ bids: [
+ {
+ bidder: 'akcelo',
+ params: {
+ siteId: 763, // required
+ adUnitId: 7965, // required
+ test: 1, // optional, use 0 to disable test creatives
+ },
+ },
+ ],
+ },
+];
+
+pbjs.que.push(function () {
+ pbjs.addAdUnits(adUnits);
+});
+```
diff --git a/test/spec/modules/akceloBidAdapter_spec.js b/test/spec/modules/akceloBidAdapter_spec.js
new file mode 100644
index 00000000000..5c519ea9834
--- /dev/null
+++ b/test/spec/modules/akceloBidAdapter_spec.js
@@ -0,0 +1,263 @@
+import { converter, spec } from 'modules/akceloBidAdapter.js';
+import * as utils from '../../../src/utils.js';
+import { deepClone } from '../../../src/utils.js';
+import sinon from 'sinon';
+
+describe('Akcelo bid adapter tests', () => {
+ let sandBox;
+
+ beforeEach(() => {
+ sandBox = sinon.createSandbox();
+ sandBox.stub(utils, 'logError');
+ });
+
+ afterEach(() => sandBox.restore());
+
+ const DEFAULT_BANNER_BID_REQUESTS = [
+ {
+ adUnitCode: 'div-banner-id',
+ bidId: 'bid-123',
+ mediaTypes: {
+ banner: {
+ sizes: [
+ [300, 250],
+ [300, 600],
+ ],
+ },
+ },
+ bidder: 'akcelo',
+ params: {
+ siteId: 123,
+ adUnitId: 456,
+ },
+ requestId: 'request-123',
+ }
+ ];
+
+ const DEFAULT_BANNER_BIDDER_REQUEST = {
+ bidderCode: 'akcelo',
+ bids: DEFAULT_BANNER_BID_REQUESTS,
+ };
+
+ const SAMPLE_RESPONSE = {
+ body: {
+ id: '12h712u7-k22g-8124-ab7a-h268s22dy271',
+ seatbid: [
+ {
+ bid: [
+ {
+ id: '1bh7jku7-ko2g-8654-ab72-h268abcde271',
+ impid: 'bid-123',
+ price: 0.6565,
+ adm: '
AD
',
+ adomain: ['abc.com'],
+ cid: '1242512',
+ crid: '535231',
+ w: 300,
+ h: 600,
+ mtype: 1,
+ ext: {
+ prebid: {
+ type: 'banner',
+ }
+ }
+ },
+ ],
+ seat: '4212',
+ },
+ ],
+ cur: 'EUR',
+ },
+ };
+
+ describe('isBidRequestValid', () => {
+ it('should return true if params.siteId and params.adUnitId are set', () => {
+ const bidRequest = {
+ params: {
+ siteId: 123,
+ adUnitId: 456,
+ },
+ };
+ expect(spec.isBidRequestValid(bidRequest)).to.equal(true);
+ });
+
+ it('should return false if params.siteId is missing', () => {
+ const bidRequest = {
+ params: {
+ adUnitId: 456,
+ },
+ };
+ expect(spec.isBidRequestValid(bidRequest)).to.equal(false);
+ });
+
+ it('should return false if params.adUnitId is missing', () => {
+ const bidRequest = {
+ params: {
+ siteId: 123,
+ },
+ };
+ expect(spec.isBidRequestValid(bidRequest)).to.equal(false);
+ });
+ });
+
+ describe('buildRequests', () => {
+ it('should build correct requests using ORTB converter', () => {
+ const request = spec.buildRequests(
+ DEFAULT_BANNER_BID_REQUESTS,
+ DEFAULT_BANNER_BIDDER_REQUEST
+ );
+ const dataFromConverter = converter.toORTB({
+ bidderRequest: DEFAULT_BANNER_BIDDER_REQUEST,
+ bidRequests: DEFAULT_BANNER_BID_REQUESTS,
+ });
+ expect(request[0]).to.deep.equal({
+ data: { ...dataFromConverter, id: request[0].data.id },
+ method: 'POST',
+ url: 'https://s2s.sportslocalmedia.com/openrtb2/auction',
+ });
+ });
+
+ it('should add site.publisher.ext.prebid.parentAccount to request object when siteId is defined', () => {
+ const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0];
+ expect(request.data.site.publisher.ext.prebid.parentAccount).to.equal(123);
+ });
+
+ it('should not add site.publisher.ext.prebid.parentAccount to request object when siteId is not defined', () => {
+ const bidRequests = [
+ { ...DEFAULT_BANNER_BID_REQUESTS[0], params: { adUnitId: 456 } },
+ ];
+ const bidderRequest = { ...DEFAULT_BANNER_BIDDER_REQUEST, bids: bidRequests };
+ const request = spec.buildRequests(bidRequests, bidderRequest)[0];
+ expect(request.data.site.publisher.ext.prebid.parentAccount).to.be.undefined;
+ });
+
+ it('should add ext.akcelo to imp object when siteId and adUnitId are defined', () => {
+ const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0];
+ expect(request.data.imp[0].ext.akcelo).to.deep.equal({
+ siteId: 123,
+ adUnitId: 456,
+ });
+ });
+
+ it('should not add ext.akcelo.siteId to imp object when siteId is not defined', () => {
+ const bidRequests = [
+ { ...DEFAULT_BANNER_BID_REQUESTS[0], params: { adUnitId: 456 } },
+ ];
+ const bidderRequest = { ...DEFAULT_BANNER_BIDDER_REQUEST, bids: bidRequests };
+ const request = spec.buildRequests(bidRequests, bidderRequest)[0];
+ expect(request.data.imp[0].ext.akcelo.siteId).to.be.undefined;
+ expect(utils.logError.calledOnce).to.equal(true);
+ expect(utils.logError.args[0][0]).to.equal('Missing parameter : siteId')
+ });
+
+ it('should not add ext.akcelo.adUnitId to imp object when adUnitId is not defined', () => {
+ const bidRequests = [
+ { ...DEFAULT_BANNER_BID_REQUESTS[0], params: { siteId: 123 } },
+ ];
+ const bidderRequest = { ...DEFAULT_BANNER_BIDDER_REQUEST, bids: bidRequests };
+ const request = spec.buildRequests(bidRequests, bidderRequest)[0];
+ expect(request.data.imp[0].ext.akcelo.adUnitId).to.be.undefined;
+ expect(utils.logError.calledOnce).to.equal(true);
+ expect(utils.logError.args[0][0]).to.equal('Missing parameter : adUnitId')
+ });
+
+ it('should add ext.akcelo.test=1 to imp object when param akcelo_demo is true', () => {
+ sandBox.stub(utils, 'getParameterByName').callsFake(() => 'true');
+ const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0];
+ expect(request.data.imp[0].ext.akcelo.test).to.equal(1);
+ });
+
+ it('should not add ext.akcelo.test to imp object when param akcelo_demo is not true', () => {
+ sandBox.stub(utils, 'getParameterByName').callsFake(() => 'something_else');
+ const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0];
+ expect(request.data.imp[0].ext.akcelo.test).to.be.undefined;
+ });
+
+ it('should not add ext.akcelo.test to imp object when param akcelo_demo is not defined', () => {
+ const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0];
+ expect(request.data.imp[0].ext.akcelo.test).to.be.undefined;
+ });
+ });
+
+ describe('interpretResponse', () => {
+ it('should return data returned by ORTB converter', () => {
+ const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0];
+ const bids = spec.interpretResponse(SAMPLE_RESPONSE, request);
+ const responseFromConverter = converter.fromORTB({
+ request: request.data,
+ response: SAMPLE_RESPONSE.body,
+ });
+ expect(bids).to.deep.equal(responseFromConverter.bids);
+ });
+
+ it('should find the media type from bid.mtype if possible', () => {
+ const serverResponse = deepClone(SAMPLE_RESPONSE);
+ serverResponse.body.seatbid[0].bid[0].mtype = 2;
+ const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0];
+ const bids = spec.interpretResponse(serverResponse, request);
+ expect(bids[0].mediaType).to.equal('video');
+ });
+
+ it('should find the media type from bid.ext.prebid.type if mtype is not defined', () => {
+ const serverResponse = deepClone(SAMPLE_RESPONSE);
+ delete serverResponse.body.seatbid[0].bid[0].mtype;
+ const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0];
+ const bids = spec.interpretResponse(serverResponse, request);
+ expect(bids[0].mediaType).to.equal('banner');
+ });
+
+ it('should skip the bid if bid.mtype and bid.ext.prebid.type are not defined', () => {
+ const serverResponse = deepClone(SAMPLE_RESPONSE);
+ delete serverResponse.body.seatbid[0].bid[0].mtype;
+ delete serverResponse.body.seatbid[0].bid[0].ext.prebid.type;
+ const request = spec.buildRequests(DEFAULT_BANNER_BID_REQUESTS, DEFAULT_BANNER_BIDDER_REQUEST)[0];
+ const bids = spec.interpretResponse(serverResponse, request);
+ expect(bids).to.be.empty;
+ });
+ });
+
+ describe('getUserSyncs', () => {
+ it('should return an empty array if iframe sync is not enabled', () => {
+ const syncs = spec.getUserSyncs({}, [SAMPLE_RESPONSE], null, null);
+ expect(syncs).to.deep.equal([]);
+ });
+
+ it('should return an array with iframe url', () => {
+ const syncs = spec.getUserSyncs({ iframeEnabled: true }, [SAMPLE_RESPONSE], null, null);
+ expect(syncs).to.deep.equal([{
+ type: 'iframe',
+ url: 'https://ads.sportslocalmedia.com/load-cookie.html?endpoint=akcelo'
+ }]);
+ });
+
+ it('should return an array with iframe URL and GDPR parameters', () => {
+ const gdprConsent = { gdprApplies: true, consentString: 'the_gdpr_consent' };
+ const syncs = spec.getUserSyncs({ iframeEnabled: true }, [SAMPLE_RESPONSE], gdprConsent, null);
+ expect(syncs[0].url).to.contain('?endpoint=akcelo&gdpr=1&gdpr_consent=the_gdpr_consent');
+ });
+
+ it('should return an array with iframe URL containing empty GDPR parameters when GDPR does not apply', () => {
+ const gdprConsent = { gdprApplies: false };
+ const syncs = spec.getUserSyncs({ iframeEnabled: true }, [SAMPLE_RESPONSE], gdprConsent, null);
+ expect(syncs[0].url).to.contain('?endpoint=akcelo&gdpr=0&gdpr_consent=');
+ });
+
+ it('should URI encode the GDPR consent string', () => {
+ const gdprConsent = { gdprApplies: true, consentString: 'the_gdpr_consent==' };
+ const syncs = spec.getUserSyncs({ iframeEnabled: true }, [SAMPLE_RESPONSE], gdprConsent, null);
+ expect(syncs[0].url).to.contain('?endpoint=akcelo&gdpr=1&gdpr_consent=the_gdpr_consent%3D%3D');
+ });
+
+ it('should return an array with iframe URL containing USP parameters when USP is defined', () => {
+ const uspConsent = 'the_usp_consent';
+ const syncs = spec.getUserSyncs({ iframeEnabled: true }, [SAMPLE_RESPONSE], null, uspConsent);
+ expect(syncs[0].url).to.contain('?endpoint=akcelo&us_privacy=the_usp_consent');
+ });
+
+ it('should URI encode the USP consent string', () => {
+ const uspConsent = 'the_usp_consent==';
+ const syncs = spec.getUserSyncs({ iframeEnabled: true }, [SAMPLE_RESPONSE], null, uspConsent);
+ expect(syncs[0].url).to.contain('?endpoint=akcelo&us_privacy=the_usp_consent%3D%3D');
+ });
+ });
+});