diff --git a/integrationExamples/gpt/unruly_example.html b/integrationExamples/gpt/unruly_example.html
new file mode 100644
index 00000000000..77a9b02b3dd
--- /dev/null
+++ b/integrationExamples/gpt/unruly_example.html
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ test
+
+
+
+
+
+
+
+
+
diff --git a/modules/unrulyBidAdapter.js b/modules/unrulyBidAdapter.js
new file mode 100644
index 00000000000..94fa716799a
--- /dev/null
+++ b/modules/unrulyBidAdapter.js
@@ -0,0 +1,101 @@
+import * as utils from 'src/utils'
+import { Renderer } from 'src/Renderer'
+import { registerBidder } from 'src/adapters/bidderFactory'
+import { VIDEO } from 'src/mediaTypes'
+
+function configureUniversalTag (exchangeRenderer) {
+ parent.window.unruly = parent.window.unruly || {};
+ parent.window.unruly['native'] = parent.window.unruly['native'] || {};
+ parent.window.unruly['native'].siteId = parent.window.unruly['native'].siteId || exchangeRenderer.siteId;
+ parent.window.unruly['native'].supplyMode = 'prebid';
+}
+
+function configureRendererQueue () {
+ parent.window.unruly['native'].prebid = parent.window.unruly['native'].prebid || {};
+ parent.window.unruly['native'].prebid.uq = parent.window.unruly['native'].prebid.uq || [];
+}
+
+function notifyRenderer (bidResponseBid) {
+ parent.window.unruly['native'].prebid.uq.push(['render', bidResponseBid]);
+}
+
+const serverResponseToBid = (bid, rendererInstance) => ({
+ requestId: bid.bidId,
+ cpm: bid.cpm,
+ width: bid.width,
+ height: bid.height,
+ vastUrl: bid.vastUrl,
+ netRevenue: true,
+ creativeId: bid.bidId,
+ ttl: 360,
+ currency: 'USD',
+ renderer: rendererInstance
+});
+
+const buildPrebidResponseAndInstallRenderer = bids =>
+ bids
+ .filter(serverBid => !!utils.deepAccess(serverBid, 'ext.renderer'))
+ .map(serverBid => {
+ const exchangeRenderer = utils.deepAccess(serverBid, 'ext.renderer');
+ configureUniversalTag(exchangeRenderer);
+ configureRendererQueue();
+
+ const rendererInstance = Renderer.install(Object.assign({}, exchangeRenderer, { callback: () => {} }));
+ return { rendererInstance, serverBid };
+ })
+ .map(
+ ({rendererInstance, serverBid}) => {
+ const prebidBid = serverResponseToBid(serverBid, rendererInstance);
+
+ const rendererConfig = Object.assign(
+ {},
+ prebidBid,
+ {
+ renderer: rendererInstance,
+ adUnitCode: serverBid.ext.adUnitCode
+ }
+ );
+
+ rendererInstance.setRender(() => { notifyRenderer(rendererConfig) });
+
+ return prebidBid;
+ }
+ );
+
+export const adapter = {
+ code: 'unruly',
+ supportedMediaTypes: [ VIDEO ],
+ isBidRequestValid: function(bid) {
+ if (!bid) return false;
+
+ const context = utils.deepAccess(bid, 'mediaTypes.video.context');
+
+ return bid.mediaType === 'video' || context === 'outstream';
+ },
+
+ buildRequests: function(validBidRequests) {
+ const url = 'https://targeting.unrulymedia.com/prebid';
+ const method = 'POST';
+ const data = { bidRequests: validBidRequests };
+ const options = { contentType: 'application/json' };
+
+ return {
+ url,
+ method,
+ data,
+ options,
+ };
+ },
+
+ interpretResponse: function(serverResponse = {}) {
+ const serverResponseBody = serverResponse.body;
+ const noBidsResponse = [];
+ const isInvalidResponse = !serverResponseBody || !serverResponseBody.bids;
+
+ return isInvalidResponse
+ ? noBidsResponse
+ : buildPrebidResponseAndInstallRenderer(serverResponseBody.bids);
+ }
+};
+
+registerBidder(adapter);
diff --git a/modules/unrulyBidAdapter.md b/modules/unrulyBidAdapter.md
new file mode 100644
index 00000000000..fc3c6c264be
--- /dev/null
+++ b/modules/unrulyBidAdapter.md
@@ -0,0 +1,31 @@
+# Overview
+
+**Module Name**: Unruly Bid Adapter
+**Module Type**: Bidder Adapter
+**Maintainer**: prodev@unrulymedia.com
+
+# Description
+
+Module that connects to UnrulyX for bids.
+
+# Test Parameters
+
+```js
+ const adUnits = [{
+ code: 'ad-slot',
+ sizes: [[728, 90], [300, 250]],
+ mediaTypes: {
+ video: {
+ context: 'outstream'
+ }
+ },
+ bids: [{
+ bidder: 'unruly',
+ params: {
+ targetingUUID: '6f15e139-5f18-49a1-b52f-87e5e69ee65e',
+ siteId: 1081534
+ }
+ }
+ ]
+ }];
+```
diff --git a/test/spec/modules/unrulyBidAdapter_spec.js b/test/spec/modules/unrulyBidAdapter_spec.js
new file mode 100644
index 00000000000..e4eaa35d662
--- /dev/null
+++ b/test/spec/modules/unrulyBidAdapter_spec.js
@@ -0,0 +1,206 @@
+/* globals describe, it, beforeEach, afterEach, sinon */
+import { expect } from 'chai'
+import * as utils from 'src/utils'
+import { STATUS } from 'src/constants'
+import { VIDEO } from 'src/mediaTypes'
+import { Renderer } from 'src/Renderer'
+import { adapter } from 'modules/unrulyBidAdapter'
+
+describe('UnrulyAdapter', () => {
+ function createOutStreamExchangeBid({
+ adUnitCode = 'placement2',
+ statusCode = 1,
+ bidId = 'foo',
+ vastUrl = 'https://targeting.unrulymedia.com/in_article?uuid=74544e00-d43b-4f3a-a799-69d22ce979ce&supported_mime_type=application/javascript&supported_mime_type=video/mp4&tj=%7B%22site%22%3A%7B%22lang%22%3A%22en-GB%22%2C%22ref%22%3A%22%22%2C%22page%22%3A%22http%3A%2F%2Fdemo.unrulymedia.com%2FinArticle%2Finarticle_nypost_upbeat%2Ftravel_magazines.html%22%2C%22domain%22%3A%22demo.unrulymedia.com%22%7D%2C%22user%22%3A%7B%22profile%22%3A%7B%22quantcast%22%3A%7B%22segments%22%3A%5B%7B%22id%22%3A%22D%22%7D%2C%7B%22id%22%3A%22T%22%7D%5D%7D%7D%7D%7D&video_width=618&video_height=347'
+ }) {
+ return {
+ 'ext': {
+ 'statusCode': statusCode,
+ 'renderer': {
+ 'id': 'unruly_inarticle',
+ 'config': {},
+ 'url': 'https://video.unrulymedia.com/native/prebid-loader.js'
+ },
+ 'adUnitCode': adUnitCode
+ },
+ 'cpm': 20,
+ 'bidderCode': 'unruly',
+ 'width': 323,
+ 'vastUrl': vastUrl,
+ 'bidId': bidId,
+ 'height': 323
+ }
+ }
+
+ const createExchangeResponse = (...bids) => ({
+ body: { bids }
+ });
+
+ let sandbox;
+ let fakeRenderer;
+
+ beforeEach(() => {
+ sandbox = sinon.sandbox.create();
+ sandbox.stub(utils, 'logError');
+ sandbox.stub(Renderer, 'install');
+
+ fakeRenderer = {
+ setRender: sinon.stub()
+ };
+ Renderer.install.returns(fakeRenderer)
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ delete parent.window.unruly
+ });
+
+ it('should expose Unruly Bidder code', () => {
+ expect(adapter.code).to.equal('unruly')
+ });
+
+ it('should contain the VIDEO mediaType', function () {
+ expect(adapter.supportedMediaTypes).to.deep.equal([ VIDEO ])
+ });
+
+ describe('isBidRequestValid', () => {
+ it('should be a function', () => {
+ expect(typeof adapter.isBidRequestValid).to.equal('function')
+ });
+
+ it('should return false if bid is falsey', () => {
+ expect(adapter.isBidRequestValid()).to.be.false;
+ });
+
+ it('should return true if bid.mediaType is "video"', () => {
+ const mockBid = { mediaType: 'video' };
+
+ expect(adapter.isBidRequestValid(mockBid)).to.be.true;
+ });
+
+ it('should return true if bid.mediaTypes.video.context is "outstream"', () => {
+ const mockBid = {
+ mediaTypes: {
+ video: {
+ context: 'outstream'
+ }
+ }
+ };
+
+ expect(adapter.isBidRequestValid(mockBid)).to.be.true;
+ });
+ });
+
+ describe('buildRequests', () => {
+ it('should be a function', () => {
+ expect(typeof adapter.buildRequests).to.equal('function');
+ });
+ it('should return an object', () => {
+ const mockBidRequests = ['mockBid'];
+ expect(typeof adapter.buildRequests(mockBidRequests)).to.equal('object')
+ });
+ it('should return a server request with a valid exchange url', () => {
+ const mockBidRequests = ['mockBid'];
+ expect(adapter.buildRequests(mockBidRequests).url).to.equal('https://targeting.unrulymedia.com/prebid')
+ });
+ it('should return a server request with method === POST', () => {
+ const mockBidRequests = ['mockBid'];
+ expect(adapter.buildRequests(mockBidRequests).method).to.equal('POST');
+ });
+ it('should ensure contentType is `application/json`', function () {
+ const mockBidRequests = ['mockBid'];
+ expect(adapter.buildRequests(mockBidRequests).options).to.deep.equal({
+ contentType: 'application/json'
+ });
+ });
+ it('should return a server request with valid payload', () => {
+ const mockBidRequests = ['mockBid'];
+ expect(adapter.buildRequests(mockBidRequests).data).to.deep.equal({bidRequests: mockBidRequests})
+ })
+ });
+
+ describe('interpretResponse', () => {
+ it('should be a function', () => {
+ expect(typeof adapter.interpretResponse).to.equal('function');
+ });
+ it('should return empty array when serverResponse is undefined', () => {
+ expect(adapter.interpretResponse()).to.deep.equal([]);
+ });
+ it('should return empty array when serverResponse has no bids', () => {
+ const mockServerResponse = { body: { bids: [] } };
+ expect(adapter.interpretResponse(mockServerResponse)).to.deep.equal([])
+ });
+ it('should return array of bids when receive a successful response from server', () => {
+ const mockExchangeBid = createOutStreamExchangeBid({adUnitCode: 'video1', bidId: 'mockBidId'});
+ const mockServerResponse = createExchangeResponse(mockExchangeBid);
+ expect(adapter.interpretResponse(mockServerResponse)).to.deep.equal([
+ {
+ requestId: 'mockBidId',
+ cpm: 20,
+ width: 323,
+ height: 323,
+ vastUrl: 'https://targeting.unrulymedia.com/in_article?uuid=74544e00-d43b-4f3a-a799-69d22ce979ce&supported_mime_type=application/javascript&supported_mime_type=video/mp4&tj=%7B%22site%22%3A%7B%22lang%22%3A%22en-GB%22%2C%22ref%22%3A%22%22%2C%22page%22%3A%22http%3A%2F%2Fdemo.unrulymedia.com%2FinArticle%2Finarticle_nypost_upbeat%2Ftravel_magazines.html%22%2C%22domain%22%3A%22demo.unrulymedia.com%22%7D%2C%22user%22%3A%7B%22profile%22%3A%7B%22quantcast%22%3A%7B%22segments%22%3A%5B%7B%22id%22%3A%22D%22%7D%2C%7B%22id%22%3A%22T%22%7D%5D%7D%7D%7D%7D&video_width=618&video_height=347',
+ netRevenue: true,
+ creativeId: 'mockBidId',
+ ttl: 360,
+ currency: 'USD',
+ renderer: fakeRenderer
+ }
+ ])
+ });
+
+ it('should initialize and set the renderer', () => {
+ expect(Renderer.install).not.to.have.been.called;
+ expect(fakeRenderer.setRender).not.to.have.been.called;
+
+ const mockReturnedBid = createOutStreamExchangeBid({adUnitCode: 'video1', bidId: 'mockBidId'});
+ const mockRenderer = { url: 'value: mockRendererURL' };
+ mockReturnedBid.ext.renderer = mockRenderer;
+ const mockServerResponse = createExchangeResponse(mockReturnedBid);
+
+ adapter.interpretResponse(mockServerResponse);
+
+ expect(Renderer.install).to.have.been.calledOnce;
+ sinon.assert.calledWithExactly(
+ Renderer.install,
+ Object.assign({}, mockRenderer, {callback: sinon.match.func})
+ );
+
+ sinon.assert.calledOnce(fakeRenderer.setRender);
+ sinon.assert.calledWithExactly(fakeRenderer.setRender, sinon.match.func)
+ });
+
+ it('bid is placed on the bid queue when render is called', () => {
+ const exchangeBid = createOutStreamExchangeBid({ adUnitCode: 'video', vastUrl: 'value: vastUrl' });
+ const exchangeResponse = createExchangeResponse(exchangeBid);
+
+ adapter.interpretResponse(exchangeResponse);
+
+ sinon.assert.calledOnce(fakeRenderer.setRender);
+ fakeRenderer.setRender.firstCall.args[0]();
+
+ expect(window.top).to.have.deep.property('unruly.native.prebid.uq');
+
+ const uq = window.top.unruly.native.prebid.uq;
+ const sentRendererConfig = uq[0][1];
+
+ expect(uq[0][0]).to.equal('render');
+ expect(sentRendererConfig.vastUrl).to.equal('value: vastUrl');
+ expect(sentRendererConfig.renderer).to.equal(fakeRenderer);
+ expect(sentRendererConfig.adUnitCode).to.equal('video')
+ })
+
+ it('should ensure that renderer is placed in Prebid supply mode', () => {
+ const mockExchangeBid = createOutStreamExchangeBid({adUnitCode: 'video1', bidId: 'mockBidId'});
+ const mockServerResponse = createExchangeResponse(mockExchangeBid);
+
+ expect('unruly' in window.parent).to.equal(false);
+
+ adapter.interpretResponse(mockServerResponse);
+
+ const supplyMode = window.parent.unruly.native.supplyMode;
+
+ expect(supplyMode).to.equal('prebid');
+ });
+ });
+});