Skip to content

Commit fa1a153

Browse files
mastap150Alex
authored andcommitted
New Bidder: PGAM Direct (prebid#14763)
* Add PGAM Direct bid adapter New SSP bidder (server-to-server, canonical OpenRTB 2.6) operated by PGAM Media LLC. Distinct from pgamssp (our legacy TeqBlaze-hosted adapter); we plan to migrate publishers from pgamssp to pgamdirect over 2026 and will submit a deprecation PR for pgamssp once migration completes. Both are actively maintained. GVL ID: 1353 Endpoint: https://rtb.pgammedia.com/rtb/v1/auction Media types: banner, video, native Supports: schain, TCF v2, USP, GPP, COPPA, EIDs, GPID, floors module, deals, first-party data 37 spec cases covering isBidRequestValid, buildRequests across banner video and native, multi-imp, consent forwarding for GDPR/USP/GPP/COPPA, EIDs and schain passthrough, interpretResponse across all media types including VAST XML/URL variants and native JSON parsing, malformed- input rejection, ext.meta merge, and metadata assertions. Coverage: 100% functions, 100% lines, 82% branches. * pgamdirect: address Codex review Three P1s from the Codex automated review: 1. Floors module integration — imp.bidfloor now comes from bid.getFloor() when the Floors module is enabled, falling back to params.bidfloor and then 0. Currency is carried from the Floors-returned currency, not hard-coded to USD. Resolves the regression where publishers on floor rules were silently sending their static params.bidfloor. 2. schain lookup precedence — buildSource() now reads ortb2.source.ext.schain first (the modern FPD path), falling back to the legacy bid.schain. Resolves the drop for publishers who configure schain through FPD rather than per-bidder params. 3. CORS preflight — removed the x-openrtb-version custom header. The request is now CORS-simple enough to avoid a browser preflight on every auction, reducing perceived auction latency by one RTT. Server accepts OpenRTB 2.6 as default regardless. Tests updated: 6 new cases covering the two P1 behaviour changes (getFloor used, fallback to params, fallback when getFloor throws, fallback to 0, currency preserved, ortb2 schain wins over bid.schain, bid.schain fallback when ortb2 empty). The removed "forwards schain from first bid" test is covered by the new "falls back to bid.schain" case. Existing CORS assertion updated to expect absent customHeaders. 43/43 tests green in Karma/ChromeHeadless. Lint clean. * pgamdirect: convert to TypeScript, use ortbConverter Addresses patmmccann review on prebid#14763: - Convert pgamdirectBidAdapter.js → .ts with typed public interface. - Replace the hand-rolled imp/device/user/source builders with libraries/ortbConverter, keeping only the pgam-specific fields (imp.ext.pgam.orgId, imp.tagid from params.placementId, meta.networkName from seatbid.seat) as converter hooks. - Drop the contentType: 'application/json' header entirely — JSON is NOT a CORS-simple content-type, so setting it forces a browser preflight on every auction. Omitting it keeps the POST preflight-free (saves one RTT per request). The converter inherits Prebid's standard handling of: - priceFloors module (imp.bidfloor / bidfloorcur via bid.getFloor) - schain via FPD normalisation (source.ext.schain) - source.tid, user.eids, site/device/regs (GDPR, USP, GPP, COPPA) - media types (banner sizes → format[], video player size passthrough, native request serialisation) - bidResponse cpm/currency/ttl/netRevenue/mediaType/advertiserDomains So this change: - Removes ~200 lines of custom plumbing. - Means future ORTB spec updates (2.7, new privacy fields) flow through automatically when Prebid updates the converter. Also: - Register GVL ID 1353 on spec (PGAM Media LLC, registered with IAB). - Keep params.bidfloor as a legacy fallback when the priceFloors module has not populated imp.bidfloor. - Force at=1 (first-price) and cur=['USD'] via a request hook. Spec tests: trimmed to cover only the adapter-owned behaviour (isBidRequestValid, CORS-simple options, imp hook custom fields, request hook, bidResponse.meta.networkName, onBidWon). Library-owned behaviour (floors / schain / GDPR / media types / VAST parsing) is not re-asserted here; those are covered by ortbConverter's own specs and by every other adapter that uses it. 26/26 pass locally.
1 parent 6c7e0a4 commit fa1a153

2 files changed

Lines changed: 477 additions & 0 deletions

File tree

modules/pgamdirectBidAdapter.ts

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/**
2+
* PGAM Direct — Prebid.js bid adapter.
3+
*
4+
* Speaks canonical OpenRTB 2.6 directly to our bidder at
5+
* https://rtb.pgammedia.com/rtb/v1/auction
6+
*
7+
* Intentionally NOT a fork of pgamsspBidAdapter — that adapter uses a
8+
* custom TeqBlaze envelope shape, while our bidder speaks real OpenRTB.
9+
* Sharing code would mean adding compat paths on both sides; cleaner to
10+
* have a purpose-built adapter that's 1:1 with our server contract.
11+
*
12+
* Publisher-facing params shape:
13+
*
14+
* pbjs.addAdUnits([{
15+
* code: 'ad-slot-1',
16+
* mediaTypes: { banner: { sizes: [[300, 250], [728, 90]] } },
17+
* bids: [{
18+
* bidder: 'pgamdirect',
19+
* params: {
20+
* orgId: 'pgam-acme-publisher', // REQUIRED — we issue this to the publisher
21+
* placementId: 'leaderboard-728x90', // optional — maps to imp.tagid
22+
* }
23+
* }]
24+
* }]);
25+
*
26+
* Built on top of libraries/ortbConverter so we inherit Prebid's
27+
* standard handling of media types, floors (priceFloors module),
28+
* schain (FPD normalisation → source.ext.schain), user.eids, GDPR/
29+
* USP/GPP/COPPA, site/device enrichment, and bidResponse mapping.
30+
* All we layer in is our distinctive shape:
31+
*
32+
* - imp.ext.pgam.orgId (publisher tenant, required)
33+
* - imp.tagid from params.placementId
34+
* - bidResponse.meta.networkName from seatbid.seat
35+
*
36+
* GVL ID 1353 = PGAM Media LLC (registered with IAB Europe).
37+
*/
38+
import { BidderSpec, registerBidder } from '../src/adapters/bidderFactory.js';
39+
import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js';
40+
import { ortbConverter } from '../libraries/ortbConverter/converter.js';
41+
import { deepSetValue } from '../src/utils.js';
42+
43+
import type { BidRequest } from '../src/adapterManager.js';
44+
import type { ORTBImp, ORTBRequest } from '../src/prebid.public.js';
45+
import type { BidResponse } from '../src/bidfactory.js';
46+
47+
const BIDDER_CODE = 'pgamdirect';
48+
const ENDPOINT_URL = 'https://rtb.pgammedia.com/rtb/v1/auction';
49+
const GVLID = 1353;
50+
const DEFAULT_TTL = 300;
51+
52+
/**
53+
* Public params interface — contracted with publishers.
54+
* Only orgId is required; placementId and bidfloor are optional
55+
* conveniences. Floors module is the preferred path for dynamic
56+
* bidfloor; params.bidfloor remains as a fallback for publishers on
57+
* the legacy per-bidder floor shape.
58+
*/
59+
type PgamDirectBidParams = {
60+
orgId: string;
61+
placementId?: string;
62+
bidfloor?: number;
63+
};
64+
65+
declare module '../src/adUnits' {
66+
interface BidderParams {
67+
[BIDDER_CODE]: PgamDirectBidParams;
68+
}
69+
}
70+
71+
const converter = ortbConverter({
72+
context: {
73+
netRevenue: true,
74+
ttl: DEFAULT_TTL,
75+
},
76+
/**
77+
* Imp hook — layer our pgam-specific fields on top of the stock imp.
78+
* The stock converter already populates id, secure, banner/video/
79+
* native, bidfloor (via priceFloors when enabled), and the ortb2Imp
80+
* FPD merge (gpid, etc.).
81+
*/
82+
imp(buildImp, bidRequest: BidRequest<typeof BIDDER_CODE>, context) {
83+
const imp: ORTBImp = buildImp(bidRequest, context);
84+
// imp.ext.pgam.orgId routes the request to the publisher tenant on
85+
// our side. Without it, the bidder returns publisher_not_found.
86+
deepSetValue(imp, 'ext.pgam.orgId', bidRequest.params.orgId);
87+
if (bidRequest.params.placementId) {
88+
imp.tagid = String(bidRequest.params.placementId);
89+
}
90+
// Legacy params.bidfloor fallback — only applied when the
91+
// priceFloors module DIDN'T populate imp.bidfloor. Keeps
92+
// publishers who haven't adopted the floors module working.
93+
if (
94+
(imp.bidfloor === undefined || imp.bidfloor === 0) &&
95+
typeof bidRequest.params.bidfloor === 'number' &&
96+
bidRequest.params.bidfloor >= 0
97+
) {
98+
imp.bidfloor = bidRequest.params.bidfloor;
99+
imp.bidfloorcur = imp.bidfloorcur || 'USD';
100+
}
101+
return imp;
102+
},
103+
/**
104+
* Request hook — first-price auction (at: 1) and USD currency
105+
* passthrough. Everything else (tmax, source.tid, schain,
106+
* user.eids, site, device, regs) is handled by the stock
107+
* converter + FPD/ortb2 pipeline.
108+
*/
109+
request(buildRequest, imps, bidderRequest, context) {
110+
const request: ORTBRequest = buildRequest(imps, bidderRequest, context);
111+
request.at = 1;
112+
if (!request.cur || request.cur.length === 0) {
113+
request.cur = ['USD'];
114+
}
115+
return request;
116+
},
117+
/**
118+
* bidResponse hook — forward seatbid.seat into bidResponse.meta.networkName
119+
* so publishers see which of our downstream DSPs cleared the auction.
120+
* Everything else (cpm, currency, width/height, creativeId, ttl,
121+
* netRevenue, advertiserDomains, mediaType + media-type-specific
122+
* payload) is handled by the stock bidResponse processor.
123+
*/
124+
bidResponse(buildBidResponse, bid, context) {
125+
const bidResponse: BidResponse = buildBidResponse(bid, context);
126+
if (context.seatbid?.seat) {
127+
bidResponse.meta = bidResponse.meta || {};
128+
bidResponse.meta.networkName = context.seatbid.seat;
129+
}
130+
return bidResponse;
131+
},
132+
});
133+
134+
export const spec: BidderSpec<typeof BIDDER_CODE> = {
135+
code: BIDDER_CODE,
136+
gvlid: GVLID,
137+
supportedMediaTypes: [BANNER, VIDEO, NATIVE],
138+
139+
/**
140+
* Minimum validation: orgId must be a non-empty string. That's the
141+
* identifier we use to route the request to a publisher/tenant on
142+
* our side; without it the bidder returns publisher_not_found.
143+
*/
144+
isBidRequestValid(bid) {
145+
const orgId = bid?.params?.orgId;
146+
return typeof orgId === 'string' && orgId.length > 0;
147+
},
148+
149+
buildRequests(bidRequests, bidderRequest) {
150+
if (!bidRequests || bidRequests.length === 0) return [];
151+
const data = converter.toORTB({ bidRequests, bidderRequest });
152+
return {
153+
method: 'POST',
154+
url: ENDPOINT_URL,
155+
data,
156+
options: {
157+
// Deliberately omit `contentType`. A content-type of
158+
// `application/json` is NOT a CORS-simple header (only
159+
// text/plain, application/x-www-form-urlencoded, and
160+
// multipart/form-data qualify), so setting it would force a
161+
// browser preflight on every auction. Leaving it unset keeps
162+
// the POST CORS-simple, which saves one RTT per request —
163+
// meaningful at auction timeouts of 300ms.
164+
withCredentials: false,
165+
},
166+
};
167+
},
168+
169+
interpretResponse(serverResponse, request) {
170+
if (!serverResponse?.body) return [];
171+
// `fromORTB` returns an ExtendedResponse wrapper (`{ bids: [...] }`)
172+
// in practice; unwrap to the flat BidResponse[] that publishers
173+
// expect from interpretResponse. `AdapterResponse` is a union
174+
// type so TS can't statically prove `.bids` is there — assert.
175+
const result = converter.fromORTB({
176+
response: serverResponse.body,
177+
request: request.data,
178+
}) as { bids?: BidResponse[] };
179+
return result.bids || [];
180+
},
181+
182+
/**
183+
* Win notice — our bidder fires burl + nurl server-side on every
184+
* winning auction, so Prebid doesn't need to do anything. Keeping
185+
* this hook as a no-op so older Prebid.js versions that expect the
186+
* method don't crash.
187+
*/
188+
onBidWon(_bid) {
189+
// no-op — burl/nurl handled server-side by bidder-edge
190+
},
191+
};
192+
193+
registerBidder(spec);

0 commit comments

Comments
 (0)