diff --git a/.github/workflows/scripts/codepath-notification b/.github/workflows/scripts/codepath-notification index 371c86fc652..7884219ef5b 100644 --- a/.github/workflows/scripts/codepath-notification +++ b/.github/workflows/scripts/codepath-notification @@ -24,3 +24,4 @@ medianet|Medianet: prebid@media.net thetradedesk|TheTradeDesk: Prebid-Maintainers@thetradedesk.com gumgum|GumGum: prebid@gumgum.com kargo|Kargo: kraken@kargo.com +bidwave|Bidwave: prebid@bidwave.net diff --git a/extra/bundle/pom.xml b/extra/bundle/pom.xml index a25d8772e45..6fe71abadd1 100644 --- a/extra/bundle/pom.xml +++ b/extra/bundle/pom.xml @@ -5,7 +5,7 @@ org.prebid prebid-server-aggregator - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT ../../extra/pom.xml @@ -75,6 +75,11 @@ pb-rule-engine ${project.version} + + org.prebid.server.hooks.modules + intentiq-identity + ${project.version} + diff --git a/extra/modules/confiant-ad-quality/pom.xml b/extra/modules/confiant-ad-quality/pom.xml index 55466521500..d98cab57e7c 100644 --- a/extra/modules/confiant-ad-quality/pom.xml +++ b/extra/modules/confiant-ad-quality/pom.xml @@ -5,7 +5,7 @@ org.prebid.server.hooks.modules all-modules - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT confiant-ad-quality diff --git a/extra/modules/fiftyone-devicedetection/pom.xml b/extra/modules/fiftyone-devicedetection/pom.xml index ebbad623041..ddb71e78e20 100644 --- a/extra/modules/fiftyone-devicedetection/pom.xml +++ b/extra/modules/fiftyone-devicedetection/pom.xml @@ -5,7 +5,7 @@ org.prebid.server.hooks.modules all-modules - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT fiftyone-devicedetection diff --git a/extra/modules/greenbids-real-time-data/pom.xml b/extra/modules/greenbids-real-time-data/pom.xml index d9a6e40c3f1..8226bd099b0 100644 --- a/extra/modules/greenbids-real-time-data/pom.xml +++ b/extra/modules/greenbids-real-time-data/pom.xml @@ -4,7 +4,7 @@ org.prebid.server.hooks.modules all-modules - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT greenbids-real-time-data diff --git a/extra/modules/intentiq-identity/README.md b/extra/modules/intentiq-identity/README.md new file mode 100644 index 00000000000..fc7d50e5d4a --- /dev/null +++ b/extra/modules/intentiq-identity/README.md @@ -0,0 +1,220 @@ +## Overview + +The IntentIQ Identity module enriches an incoming OpenRTB request by adding resolved IDs to +`user.eids`. At the `processed-auction-request` stage it calls the IntentIQ Bid Enhancement S2S API +(`ProfilesEngineServlet`) and merges the eids from the response into `user.eids` before the request +is sent to bidders. Please contact your IntentIQ account manager to get a partner token. + +See the [S2S integration docs](https://s2s.documents.intentiq.com/) for the full API contract. + +## Setup + +### Execution Plan + +This module runs at two stages: `processed-auction-request` (enrich `user.eids`) and, optionally, +`auction-response` (report winning bids as impressions to `reports-endpoint`). Enable the module and +add the hook(s) to the execution plan: + +```yaml +hooks: + intentiq-identity: + enabled: true + host-execution-plan: > + { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "processed-auction-request": { + "groups": [ + { "timeout": 100, "hook-sequence": [ + { "module-code": "intentiq-identity", + "hook-impl-code": "intentiq-identity-processed-auction-request-hook" } ] } ] + }, + "auction-response": { + "groups": [ + { "timeout": 100, "hook-sequence": [ + { "module-code": "intentiq-identity", + "hook-impl-code": "intentiq-identity-auction-response-hook" } ] } ] + } + } + } + } + } + modules: + intentiq-identity: + api-endpoint: https://be-api-s2s.intentiq.com/profiles_engine/ProfilesEngineServlet + reports-endpoint: https://reports-s2s.intentiq.com/profiles_engine/ProfilesEngineServlet + partner-id: "1234567890" + timeout: 1000 + cache-max-size: 100000 + cache: + enabled: true + ttlseconds: 43200 + max-keys: 10 + ttl-ceiling-first-party-seconds: 86400 + ttl-ceiling-third-party-seconds: 43200 + ttl-ceiling-device-seconds: 3600 + negative-ttl-seconds: 120 + in-progress-ttl-seconds: 1800 + redis: + host: localhost + port: 6379 +``` + +Use the region-specific `api-endpoint`: US `be-api-s2s.intentiq.com`, EU `be-api-s2s-gdpr.intentiq.com`, +APAC `be-api-s2s-apac.intentiq.com`. When `api-endpoint` is empty the hook is a no-op. + +### Account-level config + +Host-level config (above) provides defaults. Account-specific values can be set under the account's +`hooks.modules.intentiq-identity` config and are merged over the host defaults per request — so +`partner-id`, `timeout`, and `cache.*` can be tuned per account. + +## Module Configuration Parameters + +| Param Name | Level | Required | Type | Default | Description | +|:------------------|:--------|:---------|:--------|:---------|:---------------------------------------------------------------------| +| api-endpoint | host | yes | string | none | Bid Enhancement `ProfilesEngineServlet` URL (region-specific) | +| reports-endpoint | host | no | string | none | Impression-reporting `ProfilesEngineServlet` URL; blank disables it | +| partner-id | account | yes | string | none | Partner token from IntentIQ, sent as the `dpi` query parameter | +| timeout | account | no | long | 1000 | HTTP timeout (ms) for the identity-resolution call | +| cache.enabled | account | no | boolean | false | Use the Caffeine + Redis cache (host must configure `redis.*`) | +| cache.ttlseconds | account | no | int | 43200 | Fallback TTL (seconds) when the response omits `cttl` (12h) | +| cache.max-keys | account | no | int | 10 | Max alias keys derived per request (caps eid-stuffed requests) | +| cache.ttl-ceiling-first-party-seconds | account | no | int | 86400 | Upper bound on TTL for first-party id keys (pubcid, MAID, other eids) | +| cache.ttl-ceiling-third-party-seconds | account | no | int | 43200 | Upper bound on TTL for third-party id keys (`intentiq.com`) | +| cache.ttl-ceiling-device-seconds | account | no | int | 3600 | Upper bound on TTL for the probabilistic device-composite key | +| cache.negative-ttl-seconds | account | no | int | 120 | TTL for the negative (unresolvable id) sentinel | +| cache.in-progress-ttl-seconds | account | no | int | 1800 | TTL for the IN_PROGRESS marker that dedups concurrent resolution calls (matches the backend's 30m window) | +| cache-max-size | host | no | long | 100000 | Max entries in the local (Caffeine) layer | +| metrics-enabled | host | no | boolean | true | Record the module's `custom.*` metrics; set `false` to opt out | +| redis.host | host | cond. | string | none | Redis host (required when caching) | +| redis.port | host | cond. | int | none | Redis port (required when caching) | +| redis.password | host | no | string | none | Redis password | + +The hook sends `at/mi/pt/dpn/srvrReq` constants plus `dpi` (= `partner-id`), and — when present on +the request — `ip`, `ipv6`, `uas`, `ref` (site domain / app name), `iiquid` (an existing +`intentiq.com` eid), and `pcid`+`idtype` from `device.ifa` (`idtype 4` for MAID/AAID, `idtype 8` for +CTV with the id upper-cased; skipped when `device.lmt = 1`). The response `data.eids` are merged into +`user.eids`; on any failure the hook takes no action and the auction proceeds unchanged. + +### Caching + +When caching is enabled, resolved eids are cached in two layers: **Caffeine** (L1, in-process) backed +by an `IdentityStore` (L2, shared) — **Redis** by default. L2 failures are non-fatal: the hook falls +through to a live API call. A partner can use a non-Redis backend by supplying their own +`IdentityStore` bean (the default Redis store is `@ConditionalOnMissingBean`). + +**Multi-key (alias) caching.** Every relevant first-party id on the request becomes a namespaced +alias key, ordered by priority: `iiq:` (`intentiq.com`), `pubcid:` (`pubcid.org` / +`sharedid.org`), `maid:` (`device.ifa`; upper-cased for CTV, skipped when `device.lmt = 1`), +`:` for any other eid (e.g. `uidapi.com`), and a probabilistic `dev:` composite +as last resort. Keys are de-duplicated and capped at `cache.max-keys`. On a lookup the +highest-priority key with a live entry wins, and that entry is **back-filled** under every other key +that missed — so the alias graph grows over time and a later request carrying any of those ids hits. +Differing resolutions are never merged (only the single winning entry propagates). + +**TTL.** The response `cttl` (or `cache.ttlseconds` when omitted) always wins, but is capped per id +class by `cache.ttl-ceiling-{first-party,third-party,device}-seconds` — the cache holds the volatile +resolved eids, so ceilings are upper bounds, not the IntentIQ backend's long mapping TTLs. + +**Negative caching.** When the API resolves no eids for a request, a negative sentinel is written +under all candidate keys so unresolvable ids do not re-hit the S2S API on every request; a negative +cache hit makes the hook take no action and skip the call. The suppression window honors the response +`cttl` when present (the backend signals how long to suppress for empty/invalid traffic, capped at the +first-party ceiling), falling back to `cache.negative-ttl-seconds` when `cttl` is absent. + +**In-progress dedup.** On a full miss, before the live call an `IN_PROGRESS` marker +(`cache.in-progress-ttl-seconds`) is written under all candidate keys. A concurrent request for the +same id reads the marker and skips firing a duplicate call (it proceeds without enrichment); the +in-flight call populates the cache for subsequent requests. The marker is overwritten by the resolved +(or negative) entry when the call completes, or expires if it never does. A resolved entry is always +preferred over the marker on read. + +### Impression reporting + +When `reports-endpoint` is set and the `auction-response` hook is in the execution plan, the module +reports each winning `seatbid[].bid[]` to the IntentIQ impression API — a fire-and-forget GET to +`?at=45&rtype=1&dpi=&rdata=`. The `rdata` +carries `bidderCode` (seat), `cpm`, `currency`, `placementId` (imp id), `biddingPlatformId=4`, +`vrref` (site domain / app), `ip`, `ua`, `prebidAuctionId`, and `abTestUuid`. The `abTestUuid` is the +one returned by the resolution response — the resolution hook stashes it in the module context and +this hook reads it (omitted on a cache hit, since no fresh IIQ response was produced). With +`reports-endpoint` blank the hook is a no-op. The bid response is never modified. + +### Metrics + +The hook framework already emits per-module `call` / `success.*` / `failure` / `timeout` / +`execution-error` / `duration` for free under +`modules.module.intentiq-identity.stage..hook..…` (`` is `procauction` for the +request hook, `auctionresponse` for the response hook). The granularity is driven by what each hook +returns (`update` when enriched, `noAction` otherwise, `failed` on error). + +In addition the module emits the custom counters below (implemented in `IntentiqIdentityMetrics`). +Recording is **on by default**; set `metrics-enabled: false` (host-level) to disable it entirely. +**Per-partner** metrics are suffixed with `_` (the `partner-id`, never an internal backend id), +following the per-partner Graphite naming convention so the same per-partner Grafana templating applies; the +suffix is omitted when no partner id is configured. The `cache.*` outcome counters are additionally +broken down **by layer** (`l1` = Caffeine in-process, `l2` = Redis) and by `` — `third_party` +(`intentiq.com`), `first_party` (pubcid / MAID / other eids), or `device` (the probabilistic UA+IP +composite); on a HIT/NEGATIVE/IN_PROGRESS the keytype is the key that matched, on a full miss the +request's highest-priority candidate. + +``` +modules.module.intentiq-identity.custom.cache.l1.hit._ # positive entry served from L1 (Caffeine) +modules.module.intentiq-identity.custom.cache.l2.hit._ # positive entry served from L2 (Redis) +modules.module.intentiq-identity.custom.cache.l1.negative.hit._ # negative sentinel from L1; counted as miss, no API call +modules.module.intentiq-identity.custom.cache.l2.negative.hit._ # negative sentinel from L2; counted as miss, no API call +modules.module.intentiq-identity.custom.cache.l1.in_progress._ # in-flight marker from L1; duplicate API call skipped +modules.module.intentiq-identity.custom.cache.l2.in_progress._ # in-flight marker from L2; duplicate API call skipped +modules.module.intentiq-identity.custom.cache.miss._ # full miss (neither L1 nor L2) -> API called +modules.module.intentiq-identity.custom.api.success_ # resolution API responded and parsed OK +modules.module.intentiq-identity.custom.api.error_ # resolution API failed/timed out/unparseable +modules.module.intentiq-identity.custom.api.latency_ # resolution API call duration (timer; every call) +modules.module.intentiq-identity.custom.flow.latency_ # whole-flow latency: enrich hook -> bid release (timer; per auction) +modules.module.intentiq-identity.custom.enriched_ # eids added to user.eids (a match) +modules.module.intentiq-identity.custom.eids.none_ # resolution produced no eids (pairs with enriched for match rate) +modules.module.intentiq-identity.custom.skip.no_endpoint_ # no api-endpoint configured; resolution skipped before any API call +modules.module.intentiq-identity.custom.tc._ # one counter per enumerated termination-cause id +modules.module.intentiq-identity.custom.impression.reported_ # winning bid reported to reports-endpoint (overall) +modules.module.intentiq-identity.custom.impression.error_ # impression report call failed +``` + +Shared L1 (Caffeine) / L2 (Redis) health is process-wide, so these are emitted **globally — without +the `_` suffix** (one series each): + +``` +modules.module.intentiq-identity.custom.l1.size # current L1 entry count (gauge; vs cache-max-size) +modules.module.intentiq-identity.custom.l1.eviction # cumulative L1 evictions (gauge) +modules.module.intentiq-identity.custom.l1.get.error # L1 read threw (≈never; treated as miss) +modules.module.intentiq-identity.custom.l1.put.error # L1 write threw (≈never) +modules.module.intentiq-identity.custom.l2.get.latency # L2 GET duration (timer; every probe) +modules.module.intentiq-identity.custom.l2.put.latency # L2 PUT duration (timer; every write) +modules.module.intentiq-identity.custom.l2.get.error # L2 GET failed -> fell through to live API (fail-open) +modules.module.intentiq-identity.custom.l2.put.error # L2 PUT failed (entry still in L1, not in shared store) +modules.module.intentiq-identity.custom.l2.size # Redis DBSIZE (gauge; polled ~30s; INSTANCE-WIDE) +modules.module.intentiq-identity.custom.l2.eviction # Redis INFO evicted_keys (gauge; polled ~30s; INSTANCE-WIDE) +``` + +JVM / system health (free memory, GC) is provided by **prebid-server core**, not this module — enable +`metrics.jmx.enabled: true` and it registers `jvm.memory.*` and `jvm.gc.*` into the same registry. + +> **Prometheus / scrape gotcha:** if these are scraped via the Prometheus `/metrics` endpoint, set +> `metrics.metricType: counter` — **not** the default `flushingCounter`, which resets after each +> report (correct for Graphite/InfluxDB push, wrong for scrape) and would make the counters read as +> near-zero on every scrape. The server logs a warning when Prometheus is enabled with +> `flushingCounter`. + +## Running the demo + +1. Build the bundle: `mvn clean package --file extra/pom.xml` +2. Set `api-endpoint` and `partner-id` in `sample/configs/prebid-config-with-intentiq.yaml`. +3. Run: + `java -jar target/prebid-server-bundle.jar --spring.config.additional-location=sample/configs/prebid-config-with-intentiq.yaml` +4. POST a request to `/openrtb2/auction` and observe `user.eids` enriched in `ext.debug.resolvedrequest`. + +## Maintainer contacts + +Any suggestions or questions can be directed to the IntentIQ team. Alternatively please open a new +[issue](https://github.com/prebid/prebid-server-java/issues/new) or +[pull request](https://github.com/prebid/prebid-server-java/pulls) in this repository. diff --git a/extra/modules/intentiq-identity/pom.xml b/extra/modules/intentiq-identity/pom.xml new file mode 100644 index 00000000000..043a5ed3ddc --- /dev/null +++ b/extra/modules/intentiq-identity/pom.xml @@ -0,0 +1,32 @@ + + + 4.0.0 + + + org.prebid.server.hooks.modules + all-modules + 4.2.0-SNAPSHOT + + + intentiq-identity + + intentiq-identity + IntentIQ server-side identity resolution module + + + 1.6.1 + + + + + io.vertx + vertx-redis-client + + + com.github.ua-parser + uap-java + ${uap-java.version} + + + diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheKey.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheKey.java new file mode 100644 index 00000000000..b004f9347bf --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheKey.java @@ -0,0 +1,9 @@ +package org.prebid.server.hooks.modules.intentiq.identity.cache; + +/** + * A single namespaced cache key derived from a first-party identifier on the bid request, together + * with its {@link KeyType} (used to pick the TTL ceiling). A request yields an ordered list of these; + * the resolved identity is aliased across all of them. + */ +public record CacheKey(String key, KeyType type) { +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheResult.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheResult.java new file mode 100644 index 00000000000..07e50ee7373 --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheResult.java @@ -0,0 +1,51 @@ +package org.prebid.server.hooks.modules.intentiq.identity.cache; + +import com.iab.openrtb.request.Eid; + +import java.util.List; + +/** + * Outcome of a multi-key cache lookup: + *
    + *
  • {@link State#HIT} — a positive entry was found; {@link #eids()} carries the resolved identity.
  • + *
  • {@link State#NEGATIVE} — a negative sentinel was found (the id is known-unresolvable); skip the + * upstream call and do not enrich.
  • + *
  • {@link State#IN_PROGRESS} — a resolution call for this id is already in flight; skip the + * upstream call (do not fire a duplicate) and do not enrich.
  • + *
  • {@link State#MISS} — nothing cached; fetch from the API.
  • + *
+ */ +public record CacheResult(State state, List eids, KeyType keyType, Layer layer) { + public enum State { + HIT, + NEGATIVE, + IN_PROGRESS, + MISS + } + + /** Which cache layer served the outcome: {@code L1} (in-process Caffeine) or {@code L2} (Redis). */ + public enum Layer { + L1, + L2 + } + + // keyType is the type of the candidate key that produced the outcome (HIT/NEGATIVE/IN_PROGRESS); + // both keyType and layer are null for MISS, where no key/layer matched. + private static final CacheResult MISS = new CacheResult(State.MISS, List.of(), null, null); + + public static CacheResult hit(List eids, KeyType keyType, Layer layer) { + return new CacheResult(State.HIT, eids, keyType, layer); + } + + public static CacheResult negative(KeyType keyType, Layer layer) { + return new CacheResult(State.NEGATIVE, List.of(), keyType, layer); + } + + public static CacheResult inProgress(KeyType keyType, Layer layer) { + return new CacheResult(State.IN_PROGRESS, List.of(), keyType, layer); + } + + public static CacheResult miss() { + return MISS; + } +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheTtlPolicy.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheTtlPolicy.java new file mode 100644 index 00000000000..b1e571dd682 --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/CacheTtlPolicy.java @@ -0,0 +1,41 @@ +package org.prebid.server.hooks.modules.intentiq.identity.cache; + +/** + * TTL policy for cached identity entries. The IntentIQ API {@code cttl} (or the configured default + * when absent) always wins, but is capped by a per-{@link KeyType} ceiling — we cache the volatile + * resolved eids, not the stable cookie mapping, so ceilings are upper bounds only and deliberately + * far shorter than the IntentIQ backend's mapping TTLs. Negative (unresolvable) entries and the + * in-progress marker each use a separate short TTL. + */ +public record CacheTtlPolicy(long defaultTtlMs, + long firstPartyCeilingMs, + long thirdPartyCeilingMs, + long deviceCeilingMs, + long negativeTtlMs, + long inProgressTtlMs) { + public long ceilingFor(KeyType type) { + return switch (type) { + case FIRST_PARTY -> firstPartyCeilingMs; + case THIRD_PARTY -> thirdPartyCeilingMs; + case DEVICE -> deviceCeilingMs; + }; + } + + /** + * Effective positive TTL for a key: {@code min(cttl-or-default, ceiling(type))}. + */ + public long effectiveTtlMs(KeyType type, long cttlMs) { + final long base = cttlMs > 0 ? cttlMs : defaultTtlMs; + return Math.min(base, ceilingFor(type)); + } + + /** + * Suppression TTL for a negative (unresolvable) entry. On an empty/invalid response the IntentIQ + * backend signals how long to suppress re-querying this user via {@code cttl}; honor it when present + * (bounded by the first-party ceiling as a safety cap against absurd values), else fall back to the + * configured default negative TTL. + */ + public long negativeTtlMs(long cttlMs) { + return cttlMs > 0 ? Math.min(cttlMs, firstPartyCeilingMs) : negativeTtlMs; + } +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityCache.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityCache.java new file mode 100644 index 00000000000..1027acc34d5 --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityCache.java @@ -0,0 +1,269 @@ +package org.prebid.server.hooks.modules.intentiq.identity.cache; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.iab.openrtb.request.Eid; +import io.vertx.core.CompositeFuture; +import io.vertx.core.Future; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.jspecify.annotations.NonNull; +import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.log.Logger; +import org.prebid.server.log.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Dual-layer, multi-key (alias) cache for resolved eids: Caffeine (L1, in-process) backed by a + * pluggable {@link IdentityStore} (L2, shared; Redis by default). + * + *

A request yields an ordered list of {@link CacheKey}s (one per first-party id present). On read, + * the highest-priority key with a live entry wins and that entry is back-filled under every other key + * that missed, so the alias graph grows over time and a later request carrying any of those ids hits + * (strategy B). On a full miss the caller fetches once and writes the entry under all keys. + * + *

Entries carry the IntentIQ {@code cttl} capped by a per-{@link KeyType} ceiling (see + * {@link CacheTtlPolicy}). Unresolvable ids are cached as a short-lived negative sentinel so they do + * not re-hit the upstream API. L2 failures are swallowed (fail-open) so the auction can fall through + * to a live call. Differing resolutions are never merged — only the single winning entry propagates. + */ +public class IdentityCache { + + private static final Logger logger = LoggerFactory.getLogger(IdentityCache.class); + + private final Cache local; + private final IdentityStore store; + private final JacksonMapper mapper; + private final CacheTtlPolicy ttlPolicy; + private final IntentiqIdentityMetrics metrics; + + public IdentityCache(long maxSize, CacheTtlPolicy ttlPolicy, IdentityStore store, JacksonMapper mapper, + IntentiqIdentityMetrics metrics) { + this.local = Caffeine.newBuilder() + .maximumSize(maxSize) + .expireAfter(new CacheEntryExpiry()) + .recordStats() + .build(); + this.ttlPolicy = Objects.requireNonNull(ttlPolicy); + this.store = Objects.requireNonNull(store); + this.mapper = Objects.requireNonNull(mapper); + this.metrics = Objects.requireNonNull(metrics); + // L1 capacity gauges: current size (vs cache-max-size) and cumulative evictions. + metrics.registerL1Gauges(local::estimatedSize, () -> local.stats().evictionCount()); + } + + public Future get(List keys) { + if (keys == null || keys.isEmpty()) { + return Future.succeededFuture(CacheResult.miss()); + } + + // L1 sweep in priority order; Caffeine evicts expired entries, so a present entry is live. + // A resolved entry always wins; an in-progress marker is a fallback that short-circuits the + // L2 probe (this instance already knows a call is in flight) without firing a duplicate. + KeyType inProgressType = null; + for (int i = 0; i < keys.size(); i++) { + final CacheEntry entry = l1Get(keys.get(i).key()); + if (entry == null) { + continue; + } + if (entry.isInProgress()) { + if (inProgressType == null) { + inProgressType = keys.get(i).type(); + } + continue; + } + backfill(keys, i, entry); + return Future.succeededFuture(toResult(entry, keys.get(i).type(), CacheResult.Layer.L1)); + } + if (inProgressType != null) { + return Future.succeededFuture(CacheResult.inProgress(inProgressType, CacheResult.Layer.L1)); + } + + // Full L1 miss: probe all keys in L2 concurrently. Prefer the highest-priority resolved entry; + // fall back to an in-progress marker only if no resolved entry is found under any key. + return l2GetAll(keys).map(entries -> { + KeyType l2InProgressType = null; + for (int i = 0; i < entries.size(); i++) { + final CacheEntry entry = entries.get(i); + if (entry == null) { + continue; + } + if (entry.isInProgress()) { + if (l2InProgressType == null) { + l1Put(keys.get(i).key(), entry); + l2InProgressType = keys.get(i).type(); + } + continue; + } + l1Put(keys.get(i).key(), entry); + backfill(keys, i, entry); + return toResult(entry, keys.get(i).type(), CacheResult.Layer.L2); + } + return l2InProgressType != null + ? CacheResult.inProgress(l2InProgressType, CacheResult.Layer.L2) + : CacheResult.miss(); + }); + } + + public void put(List keys, List eids, long cttlMs) { + for (CacheKey key : keys) { + final long ttl = ttlPolicy.effectiveTtlMs(key.type(), cttlMs); + writeBoth(key.key(), CacheEntry.of(eids, false, false, System.currentTimeMillis() + ttl), ttl); + } + } + + public void putNegative(List keys, long cttlMs) { + final long ttl = ttlPolicy.negativeTtlMs(cttlMs); + for (CacheKey key : keys) { + writeBoth(key.key(), CacheEntry.of(List.of(), true, false, System.currentTimeMillis() + ttl), ttl); + } + } + + /** + * Mark a resolution as in flight: write an IN_PROGRESS sentinel under every key with the short + * in-progress TTL, so a concurrent request for the same id reads it and skips firing a duplicate + * upstream call. Overwritten by {@link #put} / {@link #putNegative} when the call completes; if the + * call never completes the marker simply expires. + */ + public void putInProgress(List keys) { + final long ttl = ttlPolicy.inProgressTtlMs(); + for (CacheKey key : keys) { + writeBoth(key.key(), CacheEntry.of(List.of(), false, true, System.currentTimeMillis() + ttl), ttl); + } + } + + /** Propagate the winning entry under every other key that missed, capped by each key's ceiling. */ + private void backfill(List keys, int hitIndex, CacheEntry hit) { + final long remaining = hit.getExp() - System.currentTimeMillis(); + if (remaining <= 0) { + return; + } + + for (int i = 0; i < keys.size(); i++) { + if (i == hitIndex) { + continue; + } + final CacheKey key = keys.get(i); + if (l1Get(key.key()) != null) { + continue; + } + final long ttl = Math.min(remaining, ttlPolicy.ceilingFor(key.type())); + writeBoth(key.key(), CacheEntry.of(hit.getEids(), hit.isNegative(), hit.isInProgress(), + System.currentTimeMillis() + ttl), ttl); + } + } + + private void writeBoth(String key, CacheEntry entry, long ttlMs) { + l1Put(key, entry); + final long startNanos = System.nanoTime(); + store.put(key, mapper.encodeToString(entry), ttlMs) + .onComplete(ignored -> metrics.l2PutLatency(System.nanoTime() - startNanos)) + .onFailure(throwable -> { + metrics.l2PutError(); + logger.warn("IntentIQ identity cache L2 PUT failed", throwable); + }); + } + + // L1 (Caffeine) is in-process and effectively never fails, but wrap it so a pathological failure + // (e.g. expiry/weigher throwing, OOM) is counted and swallowed (fail open) rather than aborting. + private void l1Put(String key, CacheEntry entry) { + try { + local.put(key, entry); + } catch (RuntimeException e) { + metrics.l1PutError(); + logger.warn("IntentIQ identity cache L1 PUT failed", e); + } + } + + private CacheEntry l1Get(String key) { + try { + return local.getIfPresent(key); + } catch (RuntimeException e) { + metrics.l1GetError(); + logger.warn("IntentIQ identity cache L1 GET failed", e); + return null; + } + } + + private Future> l2GetAll(List keys) { + final List> futures = new ArrayList<>(); + for (CacheKey key : keys) { + final long startNanos = System.nanoTime(); + futures.add(store.get(key.key()) + .onComplete(ignored -> metrics.l2GetLatency(System.nanoTime() - startNanos)) + .map(this::decodeValid) + .otherwise(throwable -> { + // L2 read failed: we fall through to a live API call (fail open). Count it so the + // otherwise-swallowed failure is visible for alerting. + metrics.l2GetError(); + logger.warn("IntentIQ identity cache L2 GET failed", throwable); + return null; + })); + } + return Future.join(futures).map(CompositeFuture::list); + } + + private CacheEntry decodeValid(String value) { + if (value == null) { + return null; + } + final CacheEntry entry = mapper.decodeValue(value, CacheEntry.class); + if (entry == null || entry.getExp() <= System.currentTimeMillis()) { + return null; + } + return entry; + } + + private static CacheResult toResult(CacheEntry entry, KeyType keyType, CacheResult.Layer layer) { + if (entry.isInProgress()) { + return CacheResult.inProgress(keyType, layer); + } + return entry.isNegative() + ? CacheResult.negative(keyType, layer) + : CacheResult.hit(entry.getEids(), keyType, layer); + } + + @Data + @NoArgsConstructor + @AllArgsConstructor(staticName = "of") + static class CacheEntry { + + List eids; + + boolean negative; + + boolean inProgress; + + long exp; + } + + private static class CacheEntryExpiry implements Expiry { + + @Override + public long expireAfterCreate(@NonNull String key, @NonNull CacheEntry value, long currentTime) { + return remainingNanos(value); + } + + @Override + public long expireAfterUpdate(@NonNull String key, @NonNull CacheEntry value, + long currentTime, long currentDuration) { + return remainingNanos(value); + } + + @Override + public long expireAfterRead(@NonNull String key, @NonNull CacheEntry value, + long currentTime, long currentDuration) { + return currentDuration; + } + + private static long remainingNanos(CacheEntry value) { + return Math.max(0, value.getExp() - System.currentTimeMillis()) * 1_000_000L; + } + } +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityStore.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityStore.java new file mode 100644 index 00000000000..dd589e02629 --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityStore.java @@ -0,0 +1,16 @@ +package org.prebid.server.hooks.modules.intentiq.identity.cache; + +import io.vertx.core.Future; + +/** + * Generic, backend-agnostic key/value store used as the shared (L2) layer of {@link IdentityCache}. + * The default implementation is Redis ({@link RedisIdentityStore}); a partner can provide a different + * backend by supplying another implementation. Values are opaque strings (the cache handles + * serialization), so the store stays decoupled from the eid model. + */ +public interface IdentityStore { + + Future get(String key); + + Future put(String key, String value, long ttlMs); +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/KeyType.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/KeyType.java new file mode 100644 index 00000000000..4a3517c2b14 --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/KeyType.java @@ -0,0 +1,14 @@ +package org.prebid.server.hooks.modules.intentiq.identity.cache; + +/** + * Classifies a cache key by the kind of identifier it carries, so the TTL ceiling can be applied + * per id class (see {@link CacheTtlPolicy}). First-party ids are treated as longer-lived than + * third-party / probabilistic ones. Note {@code intentiq.com} is treated as {@link #THIRD_PARTY}, + * matching how the IntentIQ backend classifies the IntentIQ cookie id. + */ +public enum KeyType { + + FIRST_PARTY, + THIRD_PARTY, + DEVICE +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisIdentityStore.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisIdentityStore.java new file mode 100644 index 00000000000..5ee618c3ac3 --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisIdentityStore.java @@ -0,0 +1,55 @@ +package org.prebid.server.hooks.modules.intentiq.identity.cache; + +import io.vertx.core.Future; +import io.vertx.redis.client.RedisAPI; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +/** + * Redis-backed {@link IdentityStore} (the default L2 backend). Stores values with a per-entry TTL + * via {@code SET key value PX }. + */ +@RequiredArgsConstructor +public class RedisIdentityStore implements IdentityStore { + + private static final String EVICTED_KEYS_FIELD = "evicted_keys:"; + + private final RedisAPI redis; + + @Override + public Future get(String key) { + return redis.get(key).map(response -> response != null ? response.toString() : null); + } + + @Override + public Future put(String key, String value, long ttlMs) { + return redis.set(List.of(key, value, "PX", Long.toString(ttlMs))).mapEmpty(); + } + + /** Current key count of the selected DB ({@code DBSIZE}). Instance-wide, not module-scoped. */ + public Future dbSize() { + return redis.dbsize().map(response -> response != null ? response.toLong() : 0L); + } + + /** Cumulative {@code evicted_keys} from {@code INFO stats}. Instance-wide. */ + public Future evictedKeys() { + return redis.info(List.of("stats")).map(RedisIdentityStore::parseEvictedKeys); + } + + private static Long parseEvictedKeys(io.vertx.redis.client.Response response) { + if (response == null) { + return 0L; + } + for (final String line : response.toString().split("\\r?\\n")) { + if (line.startsWith(EVICTED_KEYS_FIELD)) { + try { + return Long.parseLong(line.substring(EVICTED_KEYS_FIELD.length()).trim()); + } catch (NumberFormatException e) { + return 0L; + } + } + } + return 0L; + } +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisStatsReporter.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisStatsReporter.java new file mode 100644 index 00000000000..b03748be73f --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisStatsReporter.java @@ -0,0 +1,53 @@ +package org.prebid.server.hooks.modules.intentiq.identity.cache; + +import io.vertx.core.Vertx; +import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics; +import org.prebid.server.log.Logger; +import org.prebid.server.log.LoggerFactory; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Periodically polls Redis (L2) {@code DBSIZE} and {@code INFO stats} {@code evicted_keys} and exposes + * them as the global {@code l2.size} / {@code l2.eviction} gauges. Redis stats are asynchronous and + * instance-wide, so (unlike Caffeine's in-process L1 counters) they can't be read inside a synchronous + * gauge — this caches the latest poll into atomics the gauges read. Both values are Redis-instance-wide, + * not module-scoped. + */ +public class RedisStatsReporter { + + private static final Logger logger = LoggerFactory.getLogger(RedisStatsReporter.class); + + private final RedisIdentityStore store; + private final Vertx vertx; + private final long pollIntervalMs; + private final AtomicLong size = new AtomicLong(); + private final AtomicLong evictions = new AtomicLong(); + + public RedisStatsReporter(RedisIdentityStore store, + Vertx vertx, + IntentiqIdentityMetrics metrics, + long pollIntervalMs) { + this.store = Objects.requireNonNull(store); + this.vertx = Objects.requireNonNull(vertx); + this.pollIntervalMs = pollIntervalMs; + metrics.registerL2Gauges(size::get, evictions::get); + } + + /** Poll once immediately, then on a fixed interval. Returns this for fluent wiring. */ + public RedisStatsReporter start() { + poll(); + vertx.setPeriodic(pollIntervalMs, id -> poll()); + return this; + } + + private void poll() { + store.dbSize() + .onSuccess(size::set) + .onFailure(t -> logger.warn("IntentIQ identity L2 DBSIZE poll failed", t)); + store.evictedKeys() + .onSuccess(evictions::set) + .onFailure(t -> logger.warn("IntentIQ identity L2 evicted_keys poll failed", t)); + } +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/config/IntentiqIdentityConfig.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/config/IntentiqIdentityConfig.java new file mode 100644 index 00000000000..87183d02546 --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/config/IntentiqIdentityConfig.java @@ -0,0 +1,146 @@ +package org.prebid.server.hooks.modules.intentiq.identity.config; + +import com.codahale.metrics.MetricRegistry; +import io.vertx.core.Vertx; +import io.vertx.redis.client.Redis; +import io.vertx.redis.client.RedisAPI; +import io.vertx.redis.client.RedisOptions; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.hooks.modules.intentiq.identity.cache.CacheTtlPolicy; +import org.prebid.server.hooks.modules.intentiq.identity.cache.IdentityCache; +import org.prebid.server.hooks.modules.intentiq.identity.cache.IdentityStore; +import org.prebid.server.hooks.modules.intentiq.identity.cache.RedisIdentityStore; +import org.prebid.server.hooks.modules.intentiq.identity.cache.RedisStatsReporter; +import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics; +import org.prebid.server.hooks.modules.intentiq.identity.metric.NoopIntentiqIdentityMetrics; +import org.prebid.server.hooks.modules.intentiq.identity.model.config.CacheProperties; +import org.prebid.server.hooks.modules.intentiq.identity.model.config.IntentiqIdentityProperties; +import org.prebid.server.hooks.modules.intentiq.identity.model.config.RedisProperties; +import org.prebid.server.hooks.modules.intentiq.identity.v1.IntentiqIdentityAuctionResponseHook; +import org.prebid.server.hooks.modules.intentiq.identity.v1.IntentiqIdentityModule; +import org.prebid.server.hooks.modules.intentiq.identity.v1.IntentiqIdentityProcessedAuctionRequestHook; +import org.prebid.server.hooks.modules.intentiq.identity.v1.core.ConfigResolver; +import org.prebid.server.hooks.modules.intentiq.identity.v1.core.FirstPartyKeyExtractor; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.json.JsonMerger; +import org.prebid.server.json.ObjectMapperProvider; +import org.prebid.server.vertx.httpclient.HttpClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@ConditionalOnProperty(prefix = "hooks." + IntentiqIdentityModule.CODE, name = "enabled", havingValue = "true") +@Configuration +public class IntentiqIdentityConfig { + + // How often the L2 (Redis) size/eviction gauges are refreshed from DBSIZE / INFO. + private static final long REDIS_STATS_POLL_MS = 30_000L; + + @Bean + @ConfigurationProperties(prefix = "hooks.modules." + IntentiqIdentityModule.CODE) + IntentiqIdentityProperties intentiqIdentityProperties() { + return new IntentiqIdentityProperties(); + } + + @Bean + ConfigResolver intentiqIdentityConfigResolver(JsonMerger jsonMerger, IntentiqIdentityProperties properties) { + return new ConfigResolver(ObjectMapperProvider.mapper(), jsonMerger, properties); + } + + /** + * Default L2 store; can override caching by supplying your own {@link IdentityStore} bean. + */ + @Bean + @ConditionalOnMissingBean(IdentityStore.class) + @ConditionalOnProperty(prefix = "hooks.modules." + IntentiqIdentityModule.CODE + ".cache", + name = "enabled", havingValue = "true") + IdentityStore intentiqIdentityStore(IntentiqIdentityProperties properties, Vertx vertx) { + return new RedisIdentityStore(createRedisApi(properties.getRedis(), vertx)); + } + + @Bean + @ConditionalOnProperty(prefix = "hooks.modules." + IntentiqIdentityModule.CODE + ".cache", + name = "enabled", havingValue = "true") + IdentityCache intentiqIdentityCache(IntentiqIdentityProperties properties, + IdentityStore identityStore, + JacksonMapper mapper, + IntentiqIdentityMetrics metrics) { + final CacheProperties cache = properties.getCache(); + final CacheTtlPolicy ttlPolicy = new CacheTtlPolicy( + cache.getTtlseconds() * 1000L, + cache.getTtlCeilingFirstPartySeconds() * 1000L, + cache.getTtlCeilingThirdPartySeconds() * 1000L, + cache.getTtlCeilingDeviceSeconds() * 1000L, + cache.getNegativeTtlSeconds() * 1000L, + cache.getInProgressTtlSeconds() * 1000L); + + return new IdentityCache(properties.getCacheMaxSize(), ttlPolicy, identityStore, mapper, metrics); + } + + /** + * Polls Redis (L2) DBSIZE / evicted_keys into the {@code l2.size} / {@code l2.eviction} gauges. + * Only when the L2 store is the default Redis backend (a custom {@link IdentityStore} has no such + * stats). Returns {@code null} otherwise — Spring simply does not register the bean. + */ + @Bean + @ConditionalOnProperty(prefix = "hooks.modules." + IntentiqIdentityModule.CODE + ".cache", + name = "enabled", havingValue = "true") + RedisStatsReporter intentiqIdentityRedisStatsReporter(IdentityStore identityStore, + Vertx vertx, + IntentiqIdentityMetrics metrics) { + return identityStore instanceof RedisIdentityStore redisStore + ? new RedisStatsReporter(redisStore, vertx, metrics, REDIS_STATS_POLL_MS).start() + : null; + } + + @Bean + @ConditionalOnProperty(prefix = "hooks.modules." + IntentiqIdentityModule.CODE, + name = "metrics-enabled", havingValue = "true", matchIfMissing = true) + IntentiqIdentityMetrics intentiqIdentityMetrics(MetricRegistry metricRegistry) { + return new IntentiqIdentityMetrics(metricRegistry); + } + + @Bean + @ConditionalOnMissingBean(IntentiqIdentityMetrics.class) + IntentiqIdentityMetrics noopIntentiqIdentityMetrics() { + return new NoopIntentiqIdentityMetrics(); + } + + @Bean + IntentiqIdentityModule intentiqIdentityModule(ConfigResolver configResolver, + HttpClient httpClient, + JacksonMapper mapper, + IntentiqIdentityMetrics metrics, + IntentiqIdentityProperties properties, + @Autowired(required = false) IdentityCache identityCache) { + final FirstPartyKeyExtractor keyExtractor = + new FirstPartyKeyExtractor(properties.getCache().getMaxKeys()); + + return new IntentiqIdentityModule(List.of( + new IntentiqIdentityProcessedAuctionRequestHook( + configResolver, httpClient, mapper, identityCache, keyExtractor, metrics), + new IntentiqIdentityAuctionResponseHook(configResolver, httpClient, mapper, metrics))); + } + + private static RedisAPI createRedisApi(RedisProperties redis, Vertx vertx) { + if (redis == null || StringUtils.isBlank(redis.getHost())) { + throw new IllegalArgumentException("hooks.modules." + IntentiqIdentityModule.CODE + + ".redis.host is required when cache is enabled"); + } + if (redis.getPort() == null) { + throw new IllegalArgumentException("hooks.modules." + IntentiqIdentityModule.CODE + + ".redis.port is required when cache is enabled"); + } + + final String credentials = StringUtils.isNotBlank(redis.getPassword()) ? ":" + redis.getPassword() + "@" : ""; + final RedisOptions options = new RedisOptions() + .setConnectionString("redis://" + credentials + redis.getHost() + ":" + redis.getPort()); + + return RedisAPI.api(Redis.createClient(vertx, options)); + } +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/metric/IntentiqIdentityMetrics.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/metric/IntentiqIdentityMetrics.java new file mode 100644 index 00000000000..2ed890e73ff --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/metric/IntentiqIdentityMetrics.java @@ -0,0 +1,183 @@ +package org.prebid.server.hooks.modules.intentiq.identity.metric; + +import com.codahale.metrics.Gauge; +import com.codahale.metrics.MetricRegistry; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.hooks.modules.intentiq.identity.v1.IntentiqIdentityModule; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; + +/** + * Module-specific counters for dashboards, namespaced under {@code modules.module..custom.*} + * (grouped with the framework's {@code modules.module..*} tree, see the README Metrics section). + * Call/success/failure/execution-time are already emitted by the hook framework and are not duplicated here. + * + *

Each metric is suffixed with the partner's {@code dpi} (the partner-facing data-provider id, never an + * internal backend id) as {@code _}, following the per-partner naming convention so the same + * Grafana per-partner templating applies. The suffix is omitted when no dpi is configured. + */ +public class IntentiqIdentityMetrics { + + private static final String PREFIX = "modules.module." + IntentiqIdentityModule.CODE + ".custom."; + + private final MetricRegistry metricRegistry; + + public IntentiqIdentityMetrics(MetricRegistry metricRegistry) { + this.metricRegistry = Objects.requireNonNull(metricRegistry); + } + + /** For the no-op subclass wired when {@code metrics-enabled} is false; never touches the registry. */ + protected IntentiqIdentityMetrics() { + this.metricRegistry = null; + } + + /** Positive entry served from cache; {@code layer} is {@code l1} (Caffeine) or {@code l2} (Redis). */ + public void cacheHit(String layer, String keyType, String dpi) { + inc("cache." + layer + ".hit." + keyType, dpi); + } + + /** Full miss — neither L1 nor L2 had the id; the API is called. Not layer-specific. */ + public void cacheMiss(String keyType, String dpi) { + inc("cache.miss." + keyType, dpi); + } + + public void cacheNegativeHit(String layer, String keyType, String dpi) { + inc("cache." + layer + ".negative.hit." + keyType, dpi); + } + + public void cacheInProgress(String layer, String keyType, String dpi) { + inc("cache." + layer + ".in_progress." + keyType, dpi); + } + + public void apiSuccess(String dpi) { + inc("api.success", dpi); + } + + public void apiError(String dpi) { + inc("api.error", dpi); + } + + public void enriched(String dpi) { + inc("enriched", dpi); + } + + /** The resolution produced no eids (no match); pairs with {@link #enriched} for a match rate. */ + public void eidsNone(String dpi) { + inc("eids.none", dpi); + } + + /** Resolution skipped before any API call because no api-endpoint is configured for the partner. */ + public void skipNoEndpoint(String dpi) { + inc("skip.no_endpoint", dpi); + } + + public void terminationCause(long tc, String dpi) { + if (tc >= 0 && tc < 200) { + inc("tc." + tc, dpi); + } + } + + /** Records the latency of a single identity-resolution API call, regardless of outcome. */ + public void apiLatency(long timeNanos, String dpi) { + time("api.latency", dpi, timeNanos); + } + + /** + * Wall-clock latency of the whole module flow within one auction: from the enrich hook + * (processed-auction-request) entry to the auction-response hook (bid release). Recorded once + * per auction the module participated in. + */ + public void flowLatency(long timeNanos, String dpi) { + time("flow.latency", dpi, timeNanos); + } + + public void impressionReported(String dpi) { + inc("impression.reported", dpi); + } + + public void impressionError(String dpi) { + inc("impression.error", dpi); + } + + // --- Backpressure / capacity (shared L1/L2 health) --------------------------------------------- + // Caffeine (L1) and Redis (L2) are process-wide singletons shared across all partners, so their + // saturation is not attributable to a single dpi. These are emitted WITHOUT the _ suffix + // (global), unlike the per-partner business counters above; this also keeps their cardinality at + // exactly one series each. See the README Metrics section. + + /** An L1 (Caffeine) read threw; treated as a miss for that key. Should be ~never. */ + public void l1GetError() { + inc("l1.get.error", null); + } + + /** An L1 (Caffeine) write threw; the entry did not land in L1. Should be ~never. */ + public void l1PutError() { + inc("l1.put.error", null); + } + + /** Latency of a single L2 (Redis) GET, recorded on every probe regardless of outcome. */ + public void l2GetLatency(long timeNanos) { + time("l2.get.latency", null, timeNanos); + } + + /** Latency of a single L2 (Redis) PUT, recorded on every write regardless of outcome. */ + public void l2PutLatency(long timeNanos) { + time("l2.put.latency", null, timeNanos); + } + + /** + * An L2 GET failed/timed out (connection refused, pool exhaustion, command timeout). The cache + * fails open — it falls through to a live API call — so without this counter the failure is + * invisible. Each fall-through-on-read is one increment. + */ + public void l2GetError() { + inc("l2.get.error", null); + } + + /** An L2 PUT failed/timed out; the entry still lives in L1 but did not reach the shared store. */ + public void l2PutError() { + inc("l2.put.error", null); + } + + /** + * Register the L1 (Caffeine) capacity gauges. Idempotent and global (no dpi). {@code size} + * tracks current entry count against the configured {@code cache-max-size}; {@code evictions} + * is Caffeine's cumulative eviction count (rate it in the dashboard). No-op when recording is + * disabled. ({@code load.failure} is intentionally not exposed — this cache uses manual + * {@code put} with no {@code CacheLoader}, so load failures cannot occur.) + */ + public void registerL1Gauges(LongSupplier size, LongSupplier evictions) { + gauge("l1.size", size); + gauge("l1.eviction", evictions); + } + + /** + * Register the L2 (Redis) capacity gauges, fed by a periodic poller (Redis stats can't be read + * synchronously). Global (no dpi). {@code size} is {@code DBSIZE}, {@code evictions} is the + * cumulative {@code evicted_keys} from {@code INFO stats} — both Redis-instance-wide, not + * module-scoped. No-op when recording is disabled. + */ + public void registerL2Gauges(LongSupplier size, LongSupplier evictions) { + gauge("l2.size", size); + gauge("l2.eviction", evictions); + } + + protected void inc(String name, String dpi) { + metricRegistry.counter(withDpi(name, dpi)).inc(); + } + + protected void time(String name, String dpi, long timeNanos) { + metricRegistry.timer(withDpi(name, dpi)).update(timeNanos, TimeUnit.NANOSECONDS); + } + + protected void gauge(String name, LongSupplier supplier) { + metricRegistry.gauge(withDpi(name, null), () -> (Gauge) supplier::getAsLong); + } + + private static String withDpi(String name, String dpi) { + final String full = PREFIX + name; + return StringUtils.isNotBlank(dpi) ? full + "_" + dpi : full; + } +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/metric/NoopIntentiqIdentityMetrics.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/metric/NoopIntentiqIdentityMetrics.java new file mode 100644 index 00000000000..f6e3a2e931a --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/metric/NoopIntentiqIdentityMetrics.java @@ -0,0 +1,26 @@ +package org.prebid.server.hooks.modules.intentiq.identity.metric; + +import java.util.function.LongSupplier; + +/** + * Wired in place of {@link IntentiqIdentityMetrics} when {@code metrics-enabled} is false (see + * {@code IntentiqIdentityConfig}). Every recording path is a no-op, so callers need no flag check + * and the shared {@code MetricRegistry} is never touched. + */ +public class NoopIntentiqIdentityMetrics extends IntentiqIdentityMetrics { + + @Override + protected void inc(String name, String dpi) { + // no-op + } + + @Override + protected void time(String name, String dpi, long timeNanos) { + // no-op + } + + @Override + protected void gauge(String name, LongSupplier supplier) { + // no-op + } +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/IntentiqIdentityModuleContext.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/IntentiqIdentityModuleContext.java new file mode 100644 index 00000000000..2a50a46b277 --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/IntentiqIdentityModuleContext.java @@ -0,0 +1,13 @@ +package org.prebid.server.hooks.modules.intentiq.identity.model; + +/** + * State carried from the processed-auction-request (enrich) hook to the auction-response + * (impression report) hook via {@code InvocationResult#moduleContext()} within one auction. + * + * @param startNanos {@link System#nanoTime()} captured at enrich-hook entry; lets the + * auction-response hook record the whole-flow latency (enrich -> bid release) + * @param abTestUuid IIQ A/B test id returned by the resolution response, echoed back on the report + * @param terminationCause IIQ termination cause ({@code tc}) from the resolution response, if any + */ +public record IntentiqIdentityModuleContext(long startNanos, String abTestUuid, Long terminationCause) { +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/CacheProperties.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/CacheProperties.java new file mode 100644 index 00000000000..a2241ffc241 --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/CacheProperties.java @@ -0,0 +1,43 @@ +package org.prebid.server.hooks.modules.intentiq.identity.model.config; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class CacheProperties { + + private boolean enabled; + + /** Positive TTL used when the IntentIQ API omits {@code cttl}. */ + private int ttlseconds = 43_200; + + /** Max number of alias keys derived per request (guards against eid-stuffed requests). */ + @JsonProperty("max-keys") + private int maxKeys = 10; + + /** Upper bound on positive TTL for first-party id keys (pubcid, MAID, other eids). */ + @JsonProperty("ttl-ceiling-first-party-seconds") + private int ttlCeilingFirstPartySeconds = 86_400; + + /** Upper bound on positive TTL for third-party id keys (intentiq.com). */ + @JsonProperty("ttl-ceiling-third-party-seconds") + private int ttlCeilingThirdPartySeconds = 43_200; + + /** Upper bound on positive TTL for the probabilistic device-composite key. */ + @JsonProperty("ttl-ceiling-device-seconds") + private int ttlCeilingDeviceSeconds = 3_600; + + /** Short TTL for the negative (unresolvable id) sentinel. */ + @JsonProperty("negative-ttl-seconds") + private int negativeTtlSeconds = 120; + + /** + * TTL for the IN_PROGRESS marker written while a resolution call is in flight, after which the + * marker expires even if the call never completed. Defaults to 30 minutes to match the IntentIQ + * backend's in-progress window; lower it if you want a failed resolution to be retried sooner. + */ + @JsonProperty("in-progress-ttl-seconds") + private int inProgressTtlSeconds = 1800; +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/IntentiqIdentityProperties.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/IntentiqIdentityProperties.java new file mode 100644 index 00000000000..1fad945c41f --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/IntentiqIdentityProperties.java @@ -0,0 +1,32 @@ +package org.prebid.server.hooks.modules.intentiq.identity.model.config; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public final class IntentiqIdentityProperties { + + @JsonProperty("api-endpoint") + String apiEndpoint; + + @JsonProperty("reports-endpoint") + String reportsEndpoint; + + @JsonProperty("partner-id") + String partnerId; + + Long timeout; + + CacheProperties cache = new CacheProperties(); + + RedisProperties redis; + + @JsonProperty("cache-max-size") + long cacheMaxSize = 100_000L; + + // Module metrics are recorded by default; can opt out by setting this to false. + @JsonProperty("metrics-enabled") + boolean metricsEnabled = true; +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/RedisProperties.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/RedisProperties.java new file mode 100644 index 00000000000..6d2f678a01a --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/model/config/RedisProperties.java @@ -0,0 +1,13 @@ +package org.prebid.server.hooks.modules.intentiq.identity.model.config; + +import lombok.Data; + +@Data +public final class RedisProperties { + + String host; + + Integer port; + + String password; +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityAuctionResponseHook.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityAuctionResponseHook.java new file mode 100644 index 00000000000..bdbc9154d1d --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityAuctionResponseHook.java @@ -0,0 +1,250 @@ +package org.prebid.server.hooks.modules.intentiq.identity.v1; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.App; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Site; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import io.vertx.core.Future; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.hooks.execution.v1.InvocationResultImpl; +import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics; +import org.prebid.server.hooks.modules.intentiq.identity.model.IntentiqIdentityModuleContext; +import org.prebid.server.hooks.modules.intentiq.identity.model.config.IntentiqIdentityProperties; +import org.prebid.server.hooks.modules.intentiq.identity.v1.core.ConfigResolver; +import org.prebid.server.hooks.modules.intentiq.identity.v1.core.IiqParam; +import org.prebid.server.hooks.v1.InvocationAction; +import org.prebid.server.hooks.v1.InvocationResult; +import org.prebid.server.hooks.v1.InvocationStatus; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionResponseHook; +import org.prebid.server.hooks.v1.auction.AuctionResponsePayload; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.log.Logger; +import org.prebid.server.log.LoggerFactory; +import org.prebid.server.util.HttpUtil; +import org.prebid.server.vertx.httpclient.HttpClient; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Runs at the {@code auction-response} stage and reports each winning bid to the IntentIQ S2S + * impression-reporting API (fire-and-forget GET to {@code reports-endpoint}). The {@code abTestUuid} + * produced by the resolution hook is read from the module context. The bid response is never modified. + */ +public class IntentiqIdentityAuctionResponseHook implements AuctionResponseHook { + + private static final Logger logger = LoggerFactory.getLogger(IntentiqIdentityAuctionResponseHook.class); + + public static final String CODE = "intentiq-identity-auction-response-hook"; + + private static final long DEFAULT_TIMEOUT_MS = 1000L; + private static final String BIDDING_PLATFORM_OPENRTB = "4"; + private static final String DEFAULT_CURRENCY = "USD"; + + // Identifies the request source to the IntentIQ S2S API as prebid-server-java. + private static final String SOURCE_PBJV = "pbjv"; + + private final ConfigResolver configResolver; + private final HttpClient httpClient; + private final JacksonMapper mapper; + private final IntentiqIdentityMetrics metrics; + + public IntentiqIdentityAuctionResponseHook(ConfigResolver configResolver, + HttpClient httpClient, + JacksonMapper mapper, + IntentiqIdentityMetrics metrics) { + this.configResolver = Objects.requireNonNull(configResolver); + this.httpClient = Objects.requireNonNull(httpClient); + this.mapper = Objects.requireNonNull(mapper); + this.metrics = Objects.requireNonNull(metrics); + } + + @Override + public Future> call(AuctionResponsePayload auctionResponsePayload, + AuctionInvocationContext invocationContext) { + final IntentiqIdentityProperties properties = configResolver.resolve(invocationContext.accountConfig()); + final IntentiqIdentityModuleContext context = + invocationContext.moduleContext() instanceof IntentiqIdentityModuleContext ctx ? ctx : null; + // Whole-flow latency: enrich hook entry (startNanos) -> here (bid release), recorded once + // per auction regardless of whether an impression report is configured/sent. + if (context != null) { + metrics.flowLatency(System.nanoTime() - context.startNanos(), properties.getPartnerId()); + } + + final BidResponse bidResponse = auctionResponsePayload.bidResponse(); + if (StringUtils.isBlank(properties.getReportsEndpoint()) || bidResponse == null) { + return Future.succeededFuture(noAction()); + } + + final BidRequest bidRequest = bidRequest(invocationContext); + final String abTestUuid = context != null ? context.abTestUuid() : null; + final Long terminationCause = context != null ? context.terminationCause() : null; + final String currency = bidResponse.getCur() != null ? bidResponse.getCur() : DEFAULT_CURRENCY; + + for (final SeatBid seatBid : nullSafe(bidResponse.getSeatbid())) { + for (final Bid bid : nullSafe(seatBid.getBid())) { + report(properties, bidRequest, seatBid.getSeat(), bid, currency, abTestUuid, terminationCause); + } + } + + return Future.succeededFuture(noAction()); + } + + private void report(IntentiqIdentityProperties properties, + BidRequest bidRequest, + String bidderCode, + Bid bid, + String currency, + String abTestUuid, + Long terminationCause) { + final Map rdata = new LinkedHashMap<>(); + put(rdata, RdataField.BIDDER_CODE, bidderCode); + put(rdata, RdataField.PARTNER_ID, properties.getPartnerId()); + put(rdata, RdataField.CPM, bid.getPrice()); + put(rdata, RdataField.CURRENCY, currency); + appendOriginalBid(rdata, bid); + put(rdata, RdataField.PLACEMENT_ID, bid.getImpid()); + put(rdata, RdataField.BIDDING_PLATFORM_ID, BIDDING_PLATFORM_OPENRTB); + putIfPresent(rdata, RdataField.VRREF, resolveRef(bidRequest)); + final String auctionId = bidRequest != null ? bidRequest.getId() : null; + putIfPresent(rdata, RdataField.PREBID_AUCTION_ID, auctionId); + putIfPresent(rdata, RdataField.PARTNER_AUCTION_ID, auctionId); + putIfPresent(rdata, RdataField.AB_TEST_UUID, abTestUuid); + if (terminationCause != null) { + put(rdata, RdataField.TERMINATION_CAUSE, terminationCause); + } + final Device device = bidRequest != null ? bidRequest.getDevice() : null; + if (device != null) { + putIfPresent(rdata, RdataField.IP, + StringUtils.isNotBlank(device.getIp()) ? device.getIp() : device.getIpv6()); + putIfPresent(rdata, RdataField.UA, device.getUa()); + } + + final String dpi = properties.getPartnerId(); + + final long timeout = properties.getTimeout() != null ? properties.getTimeout() : DEFAULT_TIMEOUT_MS; + httpClient.get(reportUrl(properties, rdata), HttpUtil.headers(), timeout) + .onSuccess(response -> metrics.impressionReported(dpi)) + .onFailure(throwable -> { + metrics.impressionError(dpi); + logger.warn("IntentIQ impression report failed", throwable); + }); + } + + private String reportUrl(IntentiqIdentityProperties properties, Map rdata) { + final String endpoint = properties.getReportsEndpoint(); + return endpoint + + (endpoint.contains("?") ? "&" : "?") + + IiqParam.AT.key() + "=45" + + "&" + IiqParam.RTYPE.key() + "=1" + + "&" + IiqParam.SOURCE.key() + "=" + SOURCE_PBJV + + "&" + IiqParam.DPI.key() + "=" + encodeComponent(StringUtils.defaultString(properties.getPartnerId())) + + "&" + IiqParam.RDATA.key() + "=" + encodeComponent(mapper.encodeToString(rdata)); + } + + private static String resolveRef(BidRequest bidRequest) { + if (bidRequest == null) { + return null; + } + final Site site = bidRequest.getSite(); + if (site != null) { + return StringUtils.isNotBlank(site.getDomain()) ? site.getDomain() : site.getPage(); + } + final App app = bidRequest.getApp(); + if (app != null) { + return StringUtils.isNotBlank(app.getBundle()) ? app.getBundle() : app.getName(); + } + return null; + } + + private static BidRequest bidRequest(AuctionInvocationContext invocationContext) { + final AuctionContext auctionContext = invocationContext.auctionContext(); + return auctionContext != null ? auctionContext.getBidRequest() : null; + } + + private static void appendOriginalBid(Map rdata, Bid bid) { + final ObjectNode ext = bid.getExt(); + if (ext == null) { + return; + } + final JsonNode originalCpm = ext.get("origbidcpm"); + if (originalCpm != null && originalCpm.isNumber()) { + put(rdata, RdataField.ORIGINAL_CPM, originalCpm.decimalValue()); + } + final JsonNode originalCurrency = ext.get("origbidcur"); + if (originalCurrency != null && StringUtils.isNotBlank(originalCurrency.asText())) { + put(rdata, RdataField.ORIGINAL_CURRENCY, originalCurrency.asText()); + } + } + + private static void put(Map map, RdataField field, Object value) { + map.put(field.key(), value); + } + + private static void putIfPresent(Map map, RdataField field, String value) { + if (StringUtils.isNotBlank(value)) { + map.put(field.key(), value); + } + } + + private static List nullSafe(List list) { + return list != null ? list : List.of(); + } + + private static String encodeComponent(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private static InvocationResult noAction() { + return InvocationResultImpl.builder() + .status(InvocationStatus.success) + .action(InvocationAction.no_action) + .build(); + } + + @Override + public String code() { + return CODE; + } + + /** Field names of the impression-report {@code rdata} JSON object sent to the IntentIQ S2S API. */ + private enum RdataField { + + BIDDER_CODE("bidderCode"), + PARTNER_ID("partnerId"), + CPM("cpm"), + CURRENCY("currency"), + ORIGINAL_CPM("originalCpm"), + ORIGINAL_CURRENCY("originalCurrency"), + PLACEMENT_ID("placementId"), + BIDDING_PLATFORM_ID("biddingPlatformId"), + VRREF("vrref"), + PREBID_AUCTION_ID("prebidAuctionId"), + PARTNER_AUCTION_ID("partnerAuctionId"), + AB_TEST_UUID("abTestUuid"), + TERMINATION_CAUSE("terminationCause"), + IP("ip"), + UA("ua"); + + private final String key; + + RdataField(String key) { + this.key = key; + } + + String key() { + return key; + } + } +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityModule.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityModule.java new file mode 100644 index 00000000000..ca2ba2ec5b4 --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityModule.java @@ -0,0 +1,17 @@ +package org.prebid.server.hooks.modules.intentiq.identity.v1; + +import org.prebid.server.hooks.v1.Hook; +import org.prebid.server.hooks.v1.InvocationContext; +import org.prebid.server.hooks.v1.Module; + +import java.util.Collection; + +public record IntentiqIdentityModule( + Collection> hooks) implements Module { + public static final String CODE = "intentiq-identity"; + + @Override + public String code() { + return CODE; + } +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityProcessedAuctionRequestHook.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityProcessedAuctionRequestHook.java new file mode 100644 index 00000000000..a4cb1e6edd0 --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityProcessedAuctionRequestHook.java @@ -0,0 +1,478 @@ +package org.prebid.server.hooks.modules.intentiq.identity.v1; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.iab.openrtb.request.App; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.BrandVersion; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.Regs; +import com.iab.openrtb.request.Site; +import com.iab.openrtb.request.Uid; +import com.iab.openrtb.request.User; +import com.iab.openrtb.request.UserAgent; +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.hooks.execution.v1.InvocationResultImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl; +import org.prebid.server.hooks.modules.intentiq.identity.cache.CacheKey; +import org.prebid.server.hooks.modules.intentiq.identity.cache.CacheResult; +import org.prebid.server.hooks.modules.intentiq.identity.cache.IdentityCache; +import org.prebid.server.hooks.modules.intentiq.identity.cache.KeyType; +import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics; +import org.prebid.server.hooks.modules.intentiq.identity.model.IntentiqIdentityModuleContext; +import org.prebid.server.hooks.modules.intentiq.identity.model.config.IntentiqIdentityProperties; +import org.prebid.server.hooks.modules.intentiq.identity.v1.core.ConfigResolver; +import org.prebid.server.hooks.modules.intentiq.identity.v1.core.FirstPartyKeyExtractor; +import org.prebid.server.hooks.modules.intentiq.identity.v1.core.IiqParam; +import org.prebid.server.hooks.v1.InvocationAction; +import org.prebid.server.hooks.v1.InvocationResult; +import org.prebid.server.hooks.v1.InvocationStatus; +import org.prebid.server.hooks.v1.PayloadUpdate; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.hooks.v1.auction.ProcessedAuctionRequestHook; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.log.Logger; +import org.prebid.server.log.LoggerFactory; +import org.prebid.server.util.HttpUtil; +import org.prebid.server.vertx.httpclient.HttpClient; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.stream.Collectors; + +public class IntentiqIdentityProcessedAuctionRequestHook implements ProcessedAuctionRequestHook { + + private static final Logger logger = + LoggerFactory.getLogger(IntentiqIdentityProcessedAuctionRequestHook.class); + + public static final String CODE = "intentiq-identity-processed-auction-request-hook"; + + private static final String IIQ_SOURCE = "intentiq.com"; + + // Identifies the request source to the IntentIQ S2S API as prebid-server-java. + private static final String SOURCE_PBJV = "pbjv"; + + // Per the GDPR S2S guide, the TCF consent string is passed via the `gdpr-consent` request header, + // not as a query parameter. + private static final String GDPR_CONSENT_HEADER = "gdpr-consent"; + + private static final long DEFAULT_TIMEOUT_MS = 1000L; + + private final ConfigResolver configResolver; + private final HttpClient httpClient; + private final JacksonMapper mapper; + private final IdentityCache cache; + private final FirstPartyKeyExtractor keyExtractor; + private final IntentiqIdentityMetrics metrics; + + public IntentiqIdentityProcessedAuctionRequestHook(ConfigResolver configResolver, + HttpClient httpClient, + JacksonMapper mapper, + IdentityCache cache, + FirstPartyKeyExtractor keyExtractor, + IntentiqIdentityMetrics metrics) { + this.configResolver = Objects.requireNonNull(configResolver); + this.httpClient = Objects.requireNonNull(httpClient); + this.mapper = Objects.requireNonNull(mapper); + this.cache = cache; + this.keyExtractor = Objects.requireNonNull(keyExtractor); + this.metrics = Objects.requireNonNull(metrics); + } + + @Override + public Future> call(AuctionRequestPayload auctionRequestPayload, + AuctionInvocationContext invocationContext) { + final long startNanos = System.nanoTime(); + final IntentiqIdentityProperties properties = configResolver.resolve(invocationContext.accountConfig()); + final String dpi = properties.getPartnerId(); + if (StringUtils.isBlank(properties.getApiEndpoint())) { + metrics.skipNoEndpoint(dpi); + return Future.succeededFuture(noAction(new IntentiqIdentityModuleContext(startNanos, null, null))); + } + + return resolveEids(properties, auctionRequestPayload.bidRequest()) + .map(resolution -> { + final List eids = resolution.eids(); + final IntentiqIdentityModuleContext context = new IntentiqIdentityModuleContext( + startNanos, resolution.abTestUuid(), resolution.terminationCause()); + if (eids == null || eids.isEmpty()) { + metrics.eidsNone(dpi); + return noAction(context); + } + metrics.enriched(dpi); + return update(payload -> enrichUserEids(payload, eids), context); + }) + .otherwise(throwable -> { + logger.warn("IntentIQ identity resolution failed, proceeding without enrichment", throwable); + metrics.apiError(dpi); + return noAction(new IntentiqIdentityModuleContext(startNanos, null, null)); + }); + } + + private Future resolveEids(IntentiqIdentityProperties properties, BidRequest bidRequest) { + final boolean cacheEnabled = cache != null && properties.getCache().isEnabled(); + final List keys = cacheEnabled ? keyExtractor.candidateKeys(bidRequest) : List.of(); + if (keys.isEmpty()) { + return fetch(properties, bidRequest) + .map(response -> new Resolution( + extractEids(response), response.getAbTestUuid(), response.getTc())); + } + + final String dpi = properties.getPartnerId(); + // Cache counters are broken down by key type: the type of the key that actually matched for + // HIT/NEGATIVE/IN_PROGRESS, and the request's primary (highest-priority) candidate type for a + // full MISS, where no key matched. + final String primaryType = keyTypeToken(keys.getFirst().type()); + return cache.get(keys).compose(result -> switch (result.state()) { + case HIT -> { + metrics.cacheHit(layerToken(result.layer()), keyTypeToken(result.keyType()), dpi); + yield Future.succeededFuture(new Resolution(result.eids(), null, null)); + } + case NEGATIVE -> { + // A negative sentinel is a cached miss (id is known-unresolvable): no identity to + // serve, so it counts toward cache.miss, not cache.hit. The negative-specific counter + // distinguishes it from a true miss (no API call is made here) and is tagged by layer. + final String type = keyTypeToken(result.keyType()); + metrics.cacheMiss(type, dpi); + metrics.cacheNegativeHit(layerToken(result.layer()), type, dpi); + yield Future.succeededFuture(new Resolution(null, null, null)); + } + case IN_PROGRESS -> { + // A resolution call for this id is already in flight; skip firing a duplicate and + // proceed without enrichment (the in-flight call will populate the cache). + metrics.cacheInProgress(layerToken(result.layer()), keyTypeToken(result.keyType()), dpi); + yield Future.succeededFuture(new Resolution(null, null, null)); + } + case MISS -> { + metrics.cacheMiss(primaryType, dpi); + cache.putInProgress(keys); + yield fetchAndCache(properties, bidRequest, keys); + } + }); + } + + // Short, stable, lowercase metric token for a cache key type (e.g. THIRD_PARTY -> "third_party"). + private static String keyTypeToken(KeyType type) { + return type != null ? type.name().toLowerCase(Locale.ROOT) : "unknown"; + } + + // Cache layer that served the outcome: "l1" (Caffeine) or "l2" (Redis). + private static String layerToken(CacheResult.Layer layer) { + return layer != null ? layer.name().toLowerCase(Locale.ROOT) : "unknown"; + } + + private Future fetchAndCache(IntentiqIdentityProperties properties, + BidRequest bidRequest, + List keys) { + return fetch(properties, bidRequest).map(response -> { + final List eids = extractEids(response); + if (eids != null && !eids.isEmpty()) { + cache.put(keys, eids, response.getCttl() != null ? response.getCttl() : 0L); + } else { + cache.putNegative(keys, response.getCttl() != null ? response.getCttl() : 0L); + } + return new Resolution(eids, response.getAbTestUuid(), response.getTc()); + }); + } + + private Future fetch(IntentiqIdentityProperties properties, BidRequest bidRequest) { + final long timeout = properties.getTimeout() != null ? properties.getTimeout() : DEFAULT_TIMEOUT_MS; + final long startNanos = System.nanoTime(); + return httpClient.get(resolveUrl(properties, bidRequest), resolveHeaders(bidRequest), timeout) + .map(response -> { + final IiqResponse parsed = mapper.decodeValue(response.getBody(), IiqResponse.class); + metrics.apiSuccess(properties.getPartnerId()); + if (parsed != null && parsed.getTc() != null) { + metrics.terminationCause(parsed.getTc(), properties.getPartnerId()); + } + return parsed; + }) + .onComplete(ignored -> metrics.apiLatency(System.nanoTime() - startNanos, properties.getPartnerId())); + } + + private static List extractEids(IiqResponse response) { + return response != null && response.getData() != null ? response.getData().getEids() : null; + } + + private String resolveUrl(IntentiqIdentityProperties properties, BidRequest bidRequest) { + final String apiEndpoint = properties.getApiEndpoint(); + final StringBuilder url = new StringBuilder(apiEndpoint); + url.append(apiEndpoint.contains("?") ? '&' : '?').append(IiqParam.AT.key()).append("=39"); + appendIfPresent(url, IiqParam.MI, "10"); + appendIfPresent(url, IiqParam.DPI, properties.getPartnerId()); + appendIfPresent(url, IiqParam.PT, "17"); + appendIfPresent(url, IiqParam.DPN, "1"); + appendIfPresent(url, IiqParam.SRVR_REQ, "true"); + appendIfPresent(url, IiqParam.SOURCE, SOURCE_PBJV); + + final Device device = bidRequest.getDevice(); + if (device != null) { + appendIfPresent(url, IiqParam.IP, device.getIp()); + appendIfPresent(url, IiqParam.IPV6, device.getIpv6()); + appendIfPresent(url, IiqParam.UAS, device.getUa()); + appendIfPresent(url, IiqParam.UH, buildUaHints(device.getSua())); + appendDeviceId(url, device); + } + appendIfPresent(url, IiqParam.REF, resolveRef(bidRequest)); + appendIfPresent(url, IiqParam.IIQUID, resolveIiqUid(bidRequest.getUser())); + appendConsent(url, bidRequest); + + return url.toString(); + } + + private static void appendConsent(StringBuilder url, BidRequest bidRequest) { + final Regs regs = bidRequest.getRegs(); + if (regs != null) { + appendIfPresent(url, IiqParam.GDPR, resolveGdpr(regs)); + appendIfPresent(url, IiqParam.US_PRIVACY, resolveUsPrivacy(regs)); + appendIfPresent(url, IiqParam.GPP, regs.getGpp()); + // Forwarded ahead of backend support so the GPP section ids are available if the backend adds gpp_sid. + appendIfPresent(url, IiqParam.GPP_SID, StringUtils.join(regs.getGppSid(), ',')); + } + } + + private static MultiMap resolveHeaders(BidRequest bidRequest) { + final MultiMap headers = HttpUtil.headers(); + HttpUtil.addHeaderIfValueIsNotEmpty(headers, GDPR_CONSENT_HEADER, resolveConsent(bidRequest.getUser())); + return headers; + } + + private static String resolveGdpr(Regs regs) { + final Integer gdpr = regs.getGdpr() != null ? regs.getGdpr() + : regs.getExt() != null ? regs.getExt().getGdpr() : null; + return gdpr != null ? String.valueOf(gdpr) : null; + } + + private static String resolveUsPrivacy(Regs regs) { + if (StringUtils.isNotBlank(regs.getUsPrivacy())) { + return regs.getUsPrivacy(); + } + return regs.getExt() != null ? regs.getExt().getUsPrivacy() : null; + } + + private static String resolveConsent(User user) { + if (user == null) { + return null; + } + if (StringUtils.isNotBlank(user.getConsent())) { + return user.getConsent(); + } + return user.getExt() != null ? user.getExt().getConsent() : null; + } + + private static void appendIfPresent(StringBuilder url, IiqParam param, String value) { + if (StringUtils.isNotBlank(value)) { + url.append('&').append(param.key()).append('=').append(encodeComponent(value)); + } + } + + // Builds the `uh` UA client-hints JSON from OpenRTB device.sua, matching the IntentIQ backend's + // UA-CH hint format: numeric-keyed (0-8) UA-CH values, brands sorted, major vs full version. + private String buildUaHints(UserAgent sua) { + // The IntentIQ backend consumes hints only for high-entropy client hints (sua.source == 2). + if (sua == null || !Integer.valueOf(2).equals(sua.getSource())) { + return null; + } + final Map hints = new LinkedHashMap<>(); + appendBrowserHints(hints, sua.getBrowsers()); + if (sua.getMobile() != null) { + hints.put("1", "?" + sua.getMobile()); + } + appendPlatformHints(hints, sua.getPlatform()); + putQuoted(hints, "3", sua.getArchitecture()); + putQuoted(hints, "4", sua.getBitness()); + putQuoted(hints, "5", sua.getModel()); + return hints.isEmpty() ? null : mapper.encodeToString(hints); + } + + private static void appendBrowserHints(Map hints, List browsers) { + if (browsers == null) { + return; + } + // Sorted by brand to match the IntentIQ backend's hint formatting (deterministic hint string). + final TreeMap majorByBrand = new TreeMap<>(); + final TreeMap fullByBrand = new TreeMap<>(); + for (BrandVersion browser : browsers) { + if (browser == null || StringUtils.isBlank(browser.getBrand()) + || browser.getVersion() == null || browser.getVersion().isEmpty()) { + continue; + } + final String fullVersion = String.join(".", browser.getVersion()); + final int dot = fullVersion.indexOf('.'); + majorByBrand.put(browser.getBrand(), dot > 0 ? fullVersion.substring(0, dot) : fullVersion); + fullByBrand.put(browser.getBrand(), fullVersion); + } + putBrandList(hints, "0", majorByBrand); + putBrandList(hints, "8", fullByBrand); + } + + private static void putBrandList(Map hints, String key, TreeMap brandToVersion) { + if (brandToVersion.isEmpty()) { + return; + } + hints.put(key, brandToVersion.entrySet().stream() + .map(entry -> quote(entry.getKey()) + ";v=" + quote(entry.getValue())) + .collect(Collectors.joining(", "))); + } + + private static void appendPlatformHints(Map hints, BrandVersion platform) { + if (platform == null || StringUtils.isBlank(platform.getBrand())) { + return; + } + hints.put("2", quote(platform.getBrand())); + if (platform.getVersion() != null && !platform.getVersion().isEmpty()) { + hints.put("6", quote(String.join(".", platform.getVersion()))); + } + } + + private static void putQuoted(Map hints, String key, String value) { + if (StringUtils.isNotBlank(value)) { + hints.put(key, quote(value)); + } + } + + private static String quote(String value) { + return "\"" + value + "\""; + } + + private static void appendDeviceId(StringBuilder url, Device device) { + final String ifa = device.getIfa(); + if (StringUtils.isBlank(ifa) || Integer.valueOf(1).equals(device.getLmt())) { + return; + } + + final Integer deviceType = device.getDevicetype(); + final boolean ctv = deviceType != null && (deviceType == 3 || deviceType == 7); + // CTV ids (idtype 8) must be uppercase; MAID/AAID (idtype 4) is case-insensitive. + final String pcid = ctv ? ifa.toUpperCase(Locale.ROOT) : ifa; + url.append('&').append(IiqParam.PCID.key()).append('=').append(encodeComponent(pcid)) + .append('&').append(IiqParam.IDTYPE.key()).append('=').append(ctv ? 8 : 4); + } + + private static String resolveRef(BidRequest bidRequest) { + final Site site = bidRequest.getSite(); + if (site != null) { + return StringUtils.isNotBlank(site.getDomain()) ? site.getDomain() : site.getPage(); + } + final App app = bidRequest.getApp(); + if (app != null) { + return StringUtils.isNotBlank(app.getBundle()) ? app.getBundle() : app.getName(); + } + return null; + } + + private static String resolveIiqUid(User user) { + if (user == null || user.getEids() == null) { + return null; + } + return user.getEids().stream() + .filter(eid -> eid != null && IIQ_SOURCE.equals(eid.getSource())) + .map(Eid::getUids) + .filter(Objects::nonNull) + .flatMap(List::stream) + .filter(Objects::nonNull) + .map(Uid::getId) + .filter(StringUtils::isNotBlank) + .findFirst() + .orElse(null); + } + + private static String encodeComponent(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + private static AuctionRequestPayload enrichUserEids(AuctionRequestPayload payload, List resolvedEids) { + final BidRequest bidRequest = payload.bidRequest(); + final User existingUser = bidRequest.getUser() != null ? bidRequest.getUser() : User.builder().build(); + + final List mergedEids = new ArrayList<>(); + if (existingUser.getEids() != null) { + mergedEids.addAll(existingUser.getEids()); + } + mergedEids.addAll(resolvedEids); + + final User enrichedUser = existingUser.toBuilder().eids(mergedEids).build(); + return AuctionRequestPayloadImpl.of(bidRequest.toBuilder().user(enrichedUser).build()); + } + + private static InvocationResult update(PayloadUpdate payloadUpdate, + IntentiqIdentityModuleContext context) { + return InvocationResultImpl.builder() + .status(InvocationStatus.success) + .action(InvocationAction.update) + .payloadUpdate(payloadUpdate) + .moduleContext(context) + .build(); + } + + private static InvocationResult noAction(IntentiqIdentityModuleContext context) { + return InvocationResultImpl.builder() + .status(InvocationStatus.success) + .action(InvocationAction.no_action) + .moduleContext(context) + .build(); + } + + @Override + public String code() { + return CODE; + } + + private record Resolution(List eids, String abTestUuid, Long terminationCause) { + } + + @Data + @NoArgsConstructor + static class IiqResponse { + + @JsonDeserialize(using = LenientIiqDataDeserializer.class) + IiqData data; + + Long cttl; + + String abTestUuid; + + Long tc; + } + + @Data + @NoArgsConstructor + static class IiqData { + + List eids; + } + + /** + * IntentIQ returns {@code data} as an object on a hit but as an empty string ({@code ""}) on an + * empty or invalid response. Tolerate the non-object form by treating it as absent rather than + * failing the whole parse (which would mask a valid empty response as an API error). + */ + static class LenientIiqDataDeserializer extends JsonDeserializer { + + @Override + public IiqData deserialize(JsonParser parser, DeserializationContext context) throws IOException { + final JsonNode node = parser.readValueAsTree(); + if (node == null || !node.isObject()) { + return null; + } + return parser.getCodec().treeToValue(node, IiqData.class); + } + } +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/ConfigResolver.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/ConfigResolver.java new file mode 100644 index 00000000000..c3b4fa95214 --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/ConfigResolver.java @@ -0,0 +1,50 @@ +package org.prebid.server.hooks.modules.intentiq.identity.v1.core; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.prebid.server.hooks.modules.intentiq.identity.model.config.IntentiqIdentityProperties; +import org.prebid.server.json.JsonMerger; + +import java.util.Objects; +import java.util.Optional; + +/** + * Resolves effective module properties for a request by merging the account-level config + * (from {@code AuctionInvocationContext.accountConfig()}) over the host-level (global) properties. + * Mirrors the optable-targeting module's resolver. + */ +public class ConfigResolver { + + private final ObjectMapper mapper; + private final JsonMerger jsonMerger; + private final IntentiqIdentityProperties globalProperties; + private final JsonNode globalPropertiesNode; + + public ConfigResolver(ObjectMapper mapper, JsonMerger jsonMerger, IntentiqIdentityProperties globalProperties) { + this.mapper = Objects.requireNonNull(mapper); + this.jsonMerger = Objects.requireNonNull(jsonMerger); + this.globalProperties = Objects.requireNonNull(globalProperties); + this.globalPropertiesNode = Objects.requireNonNull(mapper.valueToTree(globalProperties)); + } + + public IntentiqIdentityProperties resolve(ObjectNode accountConfig) { + // With host-level-only config (no account override) accountConfig is null; JsonMergePatch + // rejects a null patch ("input cannot be null"), so short-circuit to the global properties. + if (accountConfig == null || accountConfig.isEmpty()) { + return globalProperties; + } + final JsonNode merged = jsonMerger.merge(accountConfig, globalPropertiesNode); + return parse(merged).orElse(globalProperties); + } + + private Optional parse(JsonNode node) { + try { + return Optional.ofNullable(node) + .filter(it -> !it.isEmpty()) + .map(it -> mapper.convertValue(it, IntentiqIdentityProperties.class)); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/DeviceUserAgent.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/DeviceUserAgent.java new file mode 100644 index 00000000000..73c0ed7d61a --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/DeviceUserAgent.java @@ -0,0 +1,58 @@ +package org.prebid.server.hooks.modules.intentiq.identity.v1.core; + +import org.apache.commons.lang3.StringUtils; +import ua_parser.Client; +import ua_parser.Parser; + +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Normalizes a raw User-Agent string into a compact, deterministic cache-key segment. + * + *

The IntentIQ caching guidance asks integrators to key on normalized UA fields + * (OS family/version, browser family/major, device family) rather than the full raw UA string, + * which would fragment the cache. The resulting segment looks like {@code iOS17_MobileSafari17_iPhone}. + * + *

Parsing is deterministic, so the same device always yields the same segment — which is all the + * module's self-contained cache requires. (Rendering engine and app-vs-browser, shown in some guide + * samples, are not exposed by this parser and are intentionally omitted.) + */ +public final class DeviceUserAgent { + + private static final Parser UA_PARSER = new Parser(); + private static final String UNKNOWN = "Other"; + + private DeviceUserAgent() { + } + + /** + * Returns the normalized UA segment, or an empty string when {@code ua} is blank or yields no + * recognizable fields. + */ + public static String normalize(String ua) { + if (StringUtils.isBlank(ua)) { + return StringUtils.EMPTY; + } + + final Client client = UA_PARSER.parse(ua); + final String os = client.os != null ? token(client.os.family, client.os.major) : null; + final String browser = client.userAgent != null + ? token(client.userAgent.family, client.userAgent.major) + : null; + final String device = client.device != null ? token(client.device.family, null) : null; + + return Stream.of(os, browser, device) + .filter(StringUtils::isNotBlank) + .collect(Collectors.joining("_")); + } + + private static String token(String family, String version) { + if (StringUtils.isBlank(family) || UNKNOWN.equals(family)) { + return null; + } + final String value = family + StringUtils.defaultString(version); + return Optional.of(value.replaceAll("\\s+", "")).filter(StringUtils::isNotBlank).orElse(null); + } +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/FirstPartyKeyExtractor.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/FirstPartyKeyExtractor.java new file mode 100644 index 00000000000..412ee620069 --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/FirstPartyKeyExtractor.java @@ -0,0 +1,130 @@ +package org.prebid.server.hooks.modules.intentiq.identity.v1.core; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.Uid; +import com.iab.openrtb.request.User; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.hooks.modules.intentiq.identity.cache.CacheKey; +import org.prebid.server.hooks.modules.intentiq.identity.cache.KeyType; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Derives the ordered list of alias cache keys for a bid request. Each relevant first-party id present + * becomes one namespaced key; the resolved identity is aliased across all of them so a later request + * carrying any of those ids hits the cache. + * + *

Priority (highest first): IIQ eid, shared id / pubcid, device MAID, any other eid source, then a + * probabilistic device composite as last resort. Keys are lower-cased, de-duplicated (first occurrence + * wins) and capped at {@code maxKeys}. {@code device.lmt == 1} suppresses the MAID key; CTV ifas are + * upper-cased to match the resolution request's {@code idtype 8} handling. + */ +public class FirstPartyKeyExtractor { + + private static final String IIQ_SOURCE = "intentiq.com"; + private static final Set SHARED_SOURCES = Set.of("pubcid.org", "sharedid.org"); + + private final int maxKeys; + + public FirstPartyKeyExtractor(int maxKeys) { + this.maxKeys = maxKeys; + } + + public List candidateKeys(BidRequest bidRequest) { + final List keys = new ArrayList<>(); + final User user = bidRequest.getUser(); + final List eids = user != null ? user.getEids() : null; + final Device device = bidRequest.getDevice(); + + addEidKeys(keys, eids, "iiq", KeyType.THIRD_PARTY, IIQ_SOURCE::equals); + addEidKeys(keys, eids, "pubcid", KeyType.FIRST_PARTY, SHARED_SOURCES::contains); + addMaidKey(keys, device); + addOtherEidKeys(keys, eids); + addDeviceComposite(keys, device); + + return dedupAndCap(keys); + } + + private static void addEidKeys(List keys, + List eids, + String namespace, + KeyType type, + java.util.function.Predicate sourceMatch) { + if (eids == null) { + return; + } + eids.stream() + .filter(eid -> eid != null && eid.getSource() != null && sourceMatch.test(eid.getSource())) + .map(Eid::getUids) + .filter(Objects::nonNull) + .flatMap(List::stream) + .filter(Objects::nonNull) + .map(Uid::getId) + .filter(StringUtils::isNotBlank) + .forEach(id -> keys.add(new CacheKey(namespace + ":" + id, type))); + } + + private static void addMaidKey(List keys, Device device) { + if (device == null || StringUtils.isBlank(device.getIfa()) || Integer.valueOf(1).equals(device.getLmt())) { + return; + } + final Integer deviceType = device.getDevicetype(); + final boolean ctv = deviceType != null && (deviceType == 3 || deviceType == 7); + final String ifa = ctv ? device.getIfa().toUpperCase(Locale.ROOT) : device.getIfa(); + keys.add(new CacheKey("maid:" + ifa, KeyType.FIRST_PARTY)); + } + + private static void addOtherEidKeys(List keys, List eids) { + if (eids == null) { + return; + } + for (Eid eid : eids) { + if (eid == null || eid.getSource() == null || eid.getUids() == null) { + continue; + } + final String source = eid.getSource(); + if (IIQ_SOURCE.equals(source) || SHARED_SOURCES.contains(source)) { + continue; + } + final String namespace = source.toLowerCase(Locale.ROOT); + for (Uid uid : eid.getUids()) { + if (uid != null && StringUtils.isNotBlank(uid.getId())) { + keys.add(new CacheKey(namespace + ":" + uid.getId(), KeyType.FIRST_PARTY)); + } + } + } + } + + private static void addDeviceComposite(List keys, Device device) { + if (device == null) { + return; + } + final String ip = StringUtils.isNotBlank(device.getIp()) ? device.getIp() : device.getIpv6(); + // Normalized UA fields (not the raw UA string) to avoid fragmenting the cache per request. + final String ua = DeviceUserAgent.normalize(device.getUa()); + final String composite = Stream.of(device.getIfa(), StringUtils.trimToNull(ua), ip) + .filter(StringUtils::isNotBlank) + .collect(Collectors.joining("_")); + if (!composite.isEmpty()) { + keys.add(new CacheKey("dev:" + composite, KeyType.DEVICE)); + } + } + + private List dedupAndCap(List keys) { + final Map unique = new LinkedHashMap<>(); + for (CacheKey key : keys) { + unique.putIfAbsent(key.key(), key); + } + return unique.values().stream().limit(maxKeys).collect(Collectors.toList()); + } +} diff --git a/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/IiqParam.java b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/IiqParam.java new file mode 100644 index 00000000000..1315368f985 --- /dev/null +++ b/extra/modules/intentiq-identity/src/main/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/IiqParam.java @@ -0,0 +1,45 @@ +package org.prebid.server.hooks.modules.intentiq.identity.v1.core; + +/** + * Query-parameter names of the IntentIQ S2S HTTP API, shared by both the identity-resolution + * request and the impression-report request. The enum constant carries the on-the-wire key; + * the value is request-specific and supplied at the call site (e.g. {@code at} is {@code 39} for the + * resolution request and {@code 45} for the impression report). + */ +public enum IiqParam { + + // Common / identity-resolution request + AT("at"), + MI("mi"), + DPI("dpi"), + PT("pt"), + DPN("dpn"), + SRVR_REQ("srvrReq"), + SOURCE("source"), + IP("ip"), + IPV6("ipv6"), + UAS("uas"), + UH("uh"), + PCID("pcid"), + IDTYPE("idtype"), + REF("ref"), + IIQUID("iiquid"), + GDPR("gdpr"), + US_PRIVACY("us_privacy"), + GPP("gpp"), + GPP_SID("gpp_sid"), + + // Impression-report request + RTYPE("rtype"), + RDATA("rdata"); + + private final String key; + + IiqParam(String key) { + this.key = key; + } + + public String key() { + return key; + } +} diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityCacheTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityCacheTest.java new file mode 100644 index 00000000000..5774bbaf14c --- /dev/null +++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/IdentityCacheTest.java @@ -0,0 +1,447 @@ +package org.prebid.server.hooks.modules.intentiq.identity.cache; + +import com.codahale.metrics.MetricRegistry; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.Uid; +import io.vertx.core.Future; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.json.ObjectMapperProvider; + +import java.util.List; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class IdentityCacheTest { + + private static final JacksonMapper MAPPER = new JacksonMapper(ObjectMapperProvider.mapper()); + + // defaultTtl 12h, firstParty ceiling 24h, thirdParty ceiling 12h, device ceiling 1h, negative 2min, + // in-progress 60s + private static final CacheTtlPolicy POLICY = + new CacheTtlPolicy(43_200_000L, 86_400_000L, 43_200_000L, 3_600_000L, 120_000L, 60_000L); + + private static final CacheKey IIQ = new CacheKey("iiq:uid-1", KeyType.THIRD_PARTY); + private static final CacheKey PUBCID = new CacheKey("pubcid:p1", KeyType.FIRST_PARTY); + private static final CacheKey DEV = new CacheKey("dev:ifa_ua_ip", KeyType.DEVICE); + + @Mock + private IdentityStore store; + + private IdentityCache target; + private MetricRegistry metricRegistry; + + private final List eids = singletonList(Eid.builder() + .source("intentiq.com") + .uids(singletonList(Uid.builder().id("uid-1").build())) + .build()); + + @BeforeEach + public void setUp() { + metricRegistry = new MetricRegistry(); + target = new IdentityCache(100L, POLICY, store, MAPPER, new IntentiqIdentityMetrics(metricRegistry)); + } + + @Test + public void getShouldReturnFromLocalCacheWithoutQueryingStoreAfterPut() { + // given + when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture()); + target.put(List.of(IIQ), eids, 1000L); + + // when + final CacheResult result = target.get(List.of(IIQ)).result(); + + // then + assertThat(result.state()).isEqualTo(CacheResult.State.HIT); + assertThat(result.eids()).isEqualTo(eids); + verify(store, never()).get(any()); + } + + @Test + public void putShouldRefreshExpiryWhenSameKeyWrittenAgain() { + // given + when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture()); + target.put(List.of(IIQ), eids, 1000L); + + // when — a second write to the same key updates the existing L1 entry (exercises expireAfterUpdate) + target.put(List.of(IIQ), eids, 5000L); + + // then + final CacheResult result = target.get(List.of(IIQ)).result(); + assertThat(result.state()).isEqualTo(CacheResult.State.HIT); + assertThat(result.eids()).isEqualTo(eids); + } + + @Test + public void putShouldWriteToStoreWithEffectiveTtlFromCttl() { + // given + when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture()); + + // when — cttl below every ceiling is used verbatim + target.put(List.of(IIQ), eids, 1000L); + + // then + verify(store).put(eq("iiq:uid-1"), any(), eq(1000L)); + } + + @Test + public void putShouldUseDefaultTtlWhenCttlIsNotPositive() { + // given + when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture()); + + // when — third-party ceiling (12h) equals default, so default applies + target.put(List.of(IIQ), eids, 0L); + + // then + verify(store).put(eq("iiq:uid-1"), any(), eq(43_200_000L)); + } + + @Test + public void putShouldCapCttlAtPerTypeCeiling() { + // given + when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture()); + + // when — huge cttl on a device key is capped at the 1h device ceiling + target.put(List.of(DEV), eids, 999_999_999L); + + // then + verify(store).put(eq("dev:ifa_ua_ip"), any(), eq(3_600_000L)); + } + + @Test + public void putShouldWriteEntryUnderEveryKey() { + // given + when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture()); + + // when + target.put(List.of(IIQ, PUBCID), eids, 1000L); + + // then + verify(store).put(eq("iiq:uid-1"), any(), eq(1000L)); + verify(store).put(eq("pubcid:p1"), any(), eq(1000L)); + } + + @Test + public void getShouldReturnMissOnFullMiss() { + // given + when(store.get(any())).thenReturn(Future.succeededFuture(null)); + + // when + final CacheResult result = target.get(List.of(IIQ)).result(); + + // then + assertThat(result.state()).isEqualTo(CacheResult.State.MISS); + verify(store).get("iiq:uid-1"); + } + + @Test + public void getShouldReturnMissForEmptyKeys() { + // when + final CacheResult result = target.get(List.of()).result(); + + // then + assertThat(result.state()).isEqualTo(CacheResult.State.MISS); + verify(store, never()).get(any()); + } + + @Test + public void getShouldFallOpenToMissWhenStoreFails() { + // given + when(store.get(any())).thenReturn(Future.failedFuture(new RuntimeException("down"))); + + // when + final Future future = target.get(List.of(IIQ)); + + // then + assertThat(future.succeeded()).isTrue(); + assertThat(future.result().state()).isEqualTo(CacheResult.State.MISS); + } + + @Test + public void getShouldReturnHitFromStoreWhenPresentAndNotExpired() { + // given + when(store.get("iiq:uid-1")) + .thenReturn(Future.succeededFuture(positiveEntry(System.currentTimeMillis() + 60_000L))); + + // when + final CacheResult result = target.get(List.of(IIQ)).result(); + + // then + assertThat(result.state()).isEqualTo(CacheResult.State.HIT); + assertThat(result.eids()).isEqualTo(eids); + } + + @Test + public void getShouldReturnMissWhenStoreEntryIsExpired() { + // given + when(store.get(any())).thenReturn(Future.succeededFuture(positiveEntry(System.currentTimeMillis() - 1L))); + + // when + final CacheResult result = target.get(List.of(IIQ)).result(); + + // then + assertThat(result.state()).isEqualTo(CacheResult.State.MISS); + } + + @Test + public void getShouldPromoteStoreHitToLocalCacheAndNotQueryStoreAgain() { + // given + when(store.get("iiq:uid-1")) + .thenReturn(Future.succeededFuture(positiveEntry(System.currentTimeMillis() + 60_000L))); + target.get(List.of(IIQ)).result(); + + // when — second lookup + final CacheResult result = target.get(List.of(IIQ)).result(); + + // then + assertThat(result.eids()).isEqualTo(eids); + verify(store).get("iiq:uid-1"); + } + + @Test + public void getShouldReturnHighestPriorityHitAndBackfillMissedKeys() { + // given — first (highest-priority) key misses, second hits + lenient().when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture()); + when(store.get("iiq:uid-1")).thenReturn(Future.succeededFuture(null)); + when(store.get("pubcid:p1")) + .thenReturn(Future.succeededFuture(positiveEntry(System.currentTimeMillis() + 60_000L))); + + // when + final CacheResult result = target.get(List.of(IIQ, PUBCID)).result(); + + // then — the hit is returned and the missed higher-priority key is back-filled + assertThat(result.state()).isEqualTo(CacheResult.State.HIT); + assertThat(result.eids()).isEqualTo(eids); + verify(store).put(eq("iiq:uid-1"), any(), anyLong()); + } + + @Test + public void getShouldBackfillFromLocalHitUnderOtherKeys() { + // given — IIQ already in local cache, PUBCID not present anywhere + when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture()); + target.put(List.of(IIQ), eids, 60_000L); + + // when — lookup with both keys hits IIQ in L1 and back-fills PUBCID + final CacheResult result = target.get(List.of(IIQ, PUBCID)).result(); + + // then + assertThat(result.state()).isEqualTo(CacheResult.State.HIT); + verify(store).put(eq("pubcid:p1"), any(), anyLong()); + } + + @Test + public void putNegativeShouldYieldNegativeResultWithoutQueryingStore() { + // given + when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture()); + target.putNegative(List.of(IIQ), 0L); + + // when + final CacheResult result = target.get(List.of(IIQ)).result(); + + // then + assertThat(result.state()).isEqualTo(CacheResult.State.NEGATIVE); + verify(store, never()).get(any()); + } + + @Test + public void putNegativeShouldWriteSentinelUnderAllKeysWithDefaultTtlWhenCttlAbsent() { + // given + when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture()); + + // when + target.putNegative(List.of(IIQ, PUBCID), 0L); + + // then + verify(store).put(eq("iiq:uid-1"), any(), eq(120_000L)); + verify(store).put(eq("pubcid:p1"), any(), eq(120_000L)); + } + + @Test + public void putNegativeShouldHonorResponseCttlAsSuppressionTtl() { + // given — the BE signals the suppression window via cttl on an empty/invalid response + when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture()); + + // when + target.putNegative(List.of(IIQ), 600_000L); + + // then + verify(store).put(eq("iiq:uid-1"), any(), eq(600_000L)); + } + + @Test + public void putNegativeShouldCapResponseCttlAtFirstPartyCeiling() { + // given + when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture()); + + // when — an absurd cttl is bounded by the first-party ceiling (24h) + target.putNegative(List.of(IIQ), 999_999_999_999L); + + // then + verify(store).put(eq("iiq:uid-1"), any(), eq(86_400_000L)); + } + + @Test + public void getShouldReturnNegativeFromStoreSentinel() { + // given + when(store.get("iiq:uid-1")) + .thenReturn(Future.succeededFuture(negativeEntry(System.currentTimeMillis() + 60_000L))); + + // when + final CacheResult result = target.get(List.of(IIQ)).result(); + + // then + assertThat(result.state()).isEqualTo(CacheResult.State.NEGATIVE); + } + + @Test + public void putShouldStoreLocallyEvenWhenStorePutFails() { + // given + when(store.put(any(), any(), anyLong())).thenReturn(Future.failedFuture(new RuntimeException("down"))); + + // when + target.put(List.of(IIQ), eids, 1000L); + + // then — local layer still serves the value without touching the store + assertThat(target.get(List.of(IIQ)).result().eids()).isEqualTo(eids); + verify(store, never()).get(any()); + } + + @Test + public void putInProgressShouldWriteSentinelUnderAllKeysWithInProgressTtl() { + // given + when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture()); + + // when + target.putInProgress(List.of(IIQ, PUBCID)); + + // then — marker written under every key with the in-progress TTL (60s) + verify(store).put(eq("iiq:uid-1"), any(), eq(60_000L)); + verify(store).put(eq("pubcid:p1"), any(), eq(60_000L)); + } + + @Test + public void getShouldReturnInProgressAfterPutInProgress() { + // given + when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture()); + target.putInProgress(List.of(IIQ)); + + // when + final CacheResult result = target.get(List.of(IIQ)).result(); + + // then — served from L1 without touching the store + assertThat(result.state()).isEqualTo(CacheResult.State.IN_PROGRESS); + assertThat(result.eids()).isEmpty(); + verify(store, never()).get(any()); + } + + @Test + public void getShouldReturnInProgressFromStoreSentinel() { + // given + when(store.get("iiq:uid-1")) + .thenReturn(Future.succeededFuture(inProgressEntry(System.currentTimeMillis() + 60_000L))); + + // when + final CacheResult result = target.get(List.of(IIQ)).result(); + + // then + assertThat(result.state()).isEqualTo(CacheResult.State.IN_PROGRESS); + } + + @Test + public void getShouldPreferResolvedHitOverInProgressMarker() { + // given — highest-priority key is in progress, a lower-priority key already resolved + lenient().when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture()); + when(store.get("iiq:uid-1")) + .thenReturn(Future.succeededFuture(inProgressEntry(System.currentTimeMillis() + 60_000L))); + when(store.get("pubcid:p1")) + .thenReturn(Future.succeededFuture(positiveEntry(System.currentTimeMillis() + 60_000L))); + + // when + final CacheResult result = target.get(List.of(IIQ, PUBCID)).result(); + + // then — the resolved entry wins over the in-progress marker + assertThat(result.state()).isEqualTo(CacheResult.State.HIT); + assertThat(result.eids()).isEqualTo(eids); + } + + @Test + public void getShouldRecordStoreGetErrorAndLatencyWhenL2GetFails() { + // given + when(store.get(any())).thenReturn(Future.failedFuture(new RuntimeException("down"))); + + // when + target.get(List.of(IIQ)).result(); + + // then — the otherwise-swallowed L2 failure is counted, and latency is recorded regardless + assertThat(counter("l2.get.error")).isEqualTo(1); + assertThat(timerCount("l2.get.latency")).isEqualTo(1); + } + + @Test + public void putShouldRecordStorePutErrorAndLatencyWhenL2PutFails() { + // given + when(store.put(any(), any(), anyLong())).thenReturn(Future.failedFuture(new RuntimeException("down"))); + + // when + target.put(List.of(IIQ), eids, 1000L); + + // then + assertThat(counter("l2.put.error")).isEqualTo(1); + assertThat(timerCount("l2.put.latency")).isEqualTo(1); + } + + @Test + public void shouldExposeL1SizeGaugeReflectingCachedEntries() { + // given + when(store.put(any(), any(), anyLong())).thenReturn(Future.succeededFuture()); + + // when + target.put(List.of(IIQ), eids, 60_000L); + + // then — the L1 size gauge is registered and reflects the written entry + assertThat(gauge("l1.size")).isEqualTo(1L); + assertThat(metricRegistry.getGauges()).containsKey(metricName("l1.eviction")); + } + + private long counter(String name) { + return metricRegistry.counter(metricName(name)).getCount(); + } + + private long timerCount(String name) { + return metricRegistry.timer(metricName(name)).getCount(); + } + + private Object gauge(String name) { + return metricRegistry.getGauges().get(metricName(name)).getValue(); + } + + private static String metricName(String name) { + return "modules.module.intentiq-identity.custom." + name; + } + + private String positiveEntry(long exp) { + return MAPPER.encodeToString(IdentityCache.CacheEntry.of(eids, false, false, exp)); + } + + private String negativeEntry(long exp) { + return MAPPER.encodeToString(IdentityCache.CacheEntry.of(List.of(), true, false, exp)); + } + + private String inProgressEntry(long exp) { + return MAPPER.encodeToString(IdentityCache.CacheEntry.of(List.of(), false, true, exp)); + } +} diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisIdentityStoreTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisIdentityStoreTest.java new file mode 100644 index 00000000000..0f65d37afae --- /dev/null +++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisIdentityStoreTest.java @@ -0,0 +1,128 @@ +package org.prebid.server.hooks.modules.intentiq.identity.cache; + +import io.vertx.core.Future; +import io.vertx.redis.client.RedisAPI; +import io.vertx.redis.client.Response; +import io.vertx.redis.client.impl.types.SimpleStringType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class RedisIdentityStoreTest { + + @Mock + private RedisAPI redis; + + @Captor + private ArgumentCaptor> setArgsCaptor; + + private RedisIdentityStore target; + + @BeforeEach + public void setUp() { + target = new RedisIdentityStore(redis); + } + + @Test + public void putShouldIssueSetWithPxTtl() { + // given + when(redis.set(any())).thenReturn(Future.succeededFuture()); + + // when + target.put("key", "value", 1000L); + + // then + verify(redis).set(setArgsCaptor.capture()); + assertThat(setArgsCaptor.getValue()).containsExactly("key", "value", "PX", "1000"); + } + + @Test + public void getShouldReturnResponseAsString() { + // given + when(redis.get("key")).thenReturn(Future.succeededFuture(SimpleStringType.create("value"))); + + // when and then + assertThat(target.get("key").result()).isEqualTo("value"); + } + + @Test + public void getShouldReturnNullWhenKeyAbsent() { + // given + when(redis.get("key")).thenReturn(Future.succeededFuture(null)); + + // when and then + assertThat(target.get("key").result()).isNull(); + } + + @Test + public void dbSizeShouldReturnResponseAsLong() { + // given + final Response response = mock(Response.class); + when(response.toLong()).thenReturn(42L); + when(redis.dbsize()).thenReturn(Future.succeededFuture(response)); + + // when and then + assertThat(target.dbSize().result()).isEqualTo(42L); + } + + @Test + public void dbSizeShouldReturnZeroWhenResponseNull() { + // given + when(redis.dbsize()).thenReturn(Future.succeededFuture(null)); + + // when and then + assertThat(target.dbSize().result()).isZero(); + } + + @Test + public void evictedKeysShouldParseValueFromInfoStats() { + // given + when(redis.info(List.of("stats"))).thenReturn(Future.succeededFuture( + SimpleStringType.create("# Stats\r\nexpired_keys:5\r\nevicted_keys:17\r\nkeyspace_hits:1\r\n"))); + + // when and then + assertThat(target.evictedKeys().result()).isEqualTo(17L); + } + + @Test + public void evictedKeysShouldReturnZeroWhenResponseNull() { + // given + when(redis.info(List.of("stats"))).thenReturn(Future.succeededFuture(null)); + + // when and then + assertThat(target.evictedKeys().result()).isZero(); + } + + @Test + public void evictedKeysShouldReturnZeroWhenFieldMissing() { + // given + when(redis.info(List.of("stats"))).thenReturn(Future.succeededFuture( + SimpleStringType.create("# Stats\r\nexpired_keys:5\r\nkeyspace_hits:1\r\n"))); + + // when and then + assertThat(target.evictedKeys().result()).isZero(); + } + + @Test + public void evictedKeysShouldReturnZeroWhenValueNotANumber() { + // given + when(redis.info(List.of("stats"))).thenReturn(Future.succeededFuture( + SimpleStringType.create("evicted_keys:not-a-number\r\n"))); + + // when and then + assertThat(target.evictedKeys().result()).isZero(); + } +} diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisStatsReporterTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisStatsReporterTest.java new file mode 100644 index 00000000000..37e69ca5857 --- /dev/null +++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/cache/RedisStatsReporterTest.java @@ -0,0 +1,133 @@ +package org.prebid.server.hooks.modules.intentiq.identity.cache; + +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics; + +import java.util.function.LongSupplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class RedisStatsReporterTest { + + @Mock + private RedisIdentityStore store; + + @Mock + private Vertx vertx; + + @Mock + private IntentiqIdentityMetrics metrics; + + @Captor + private ArgumentCaptor sizeSupplierCaptor; + + @Captor + private ArgumentCaptor evictionsSupplierCaptor; + + @Captor + private ArgumentCaptor> periodicHandlerCaptor; + + @Test + public void constructorShouldThrowWhenStoreIsNull() { + // when and then + assertThatNullPointerException() + .isThrownBy(() -> new RedisStatsReporter(null, vertx, metrics, 1000L)); + } + + @Test + public void constructorShouldThrowWhenVertxIsNull() { + // when and then + assertThatNullPointerException() + .isThrownBy(() -> new RedisStatsReporter(store, null, metrics, 1000L)); + } + + @Test + public void constructorShouldRegisterGaugesThatReadTheLatestPolledValues() { + // given + when(store.dbSize()).thenReturn(Future.succeededFuture(7L)); + when(store.evictedKeys()).thenReturn(Future.succeededFuture(3L)); + + // when + new RedisStatsReporter(store, vertx, metrics, 1000L).start(); + + // then + verify(metrics).registerL2Gauges(sizeSupplierCaptor.capture(), evictionsSupplierCaptor.capture()); + assertThat(sizeSupplierCaptor.getValue().getAsLong()).isEqualTo(7L); + assertThat(evictionsSupplierCaptor.getValue().getAsLong()).isEqualTo(3L); + } + + @Test + public void startShouldPollImmediatelyAndScheduleAPeriodicPoll() { + // given + when(store.dbSize()).thenReturn(Future.succeededFuture(1L)); + when(store.evictedKeys()).thenReturn(Future.succeededFuture(1L)); + + // when + new RedisStatsReporter(store, vertx, metrics, 1000L).start(); + + // then + verify(store).dbSize(); + verify(store).evictedKeys(); + verify(vertx).setPeriodic(eq(1000L), any()); + } + + @Test + public void startShouldReturnSelfForFluentWiring() { + // given + when(store.dbSize()).thenReturn(Future.succeededFuture(0L)); + when(store.evictedKeys()).thenReturn(Future.succeededFuture(0L)); + final RedisStatsReporter target = new RedisStatsReporter(store, vertx, metrics, 1000L); + + // when and then + assertThat(target.start()).isSameAs(target); + } + + @Test + public void periodicTickShouldPollAgain() { + // given + when(store.dbSize()).thenReturn(Future.succeededFuture(1L)); + when(store.evictedKeys()).thenReturn(Future.succeededFuture(1L)); + when(vertx.setPeriodic(eq(1000L), periodicHandlerCaptor.capture())).thenReturn(1L); + + new RedisStatsReporter(store, vertx, metrics, 1000L).start(); + + // when + periodicHandlerCaptor.getValue().handle(1L); + + // then + verify(store, times(2)).dbSize(); + verify(store, times(2)).evictedKeys(); + } + + @Test + public void pollShouldLeaveGaugesAtZeroWhenBothProbesFail() { + // given + lenient().when(vertx.setPeriodic(eq(1000L), any())).thenReturn(1L); + when(store.dbSize()).thenReturn(Future.failedFuture(new RuntimeException("dbsize down"))); + when(store.evictedKeys()).thenReturn(Future.failedFuture(new RuntimeException("info down"))); + + // when + new RedisStatsReporter(store, vertx, metrics, 1000L).start(); + + // then + verify(metrics).registerL2Gauges(sizeSupplierCaptor.capture(), evictionsSupplierCaptor.capture()); + assertThat(sizeSupplierCaptor.getValue().getAsLong()).isZero(); + assertThat(evictionsSupplierCaptor.getValue().getAsLong()).isZero(); + } +} diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/config/IntentiqIdentityConfigTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/config/IntentiqIdentityConfigTest.java new file mode 100644 index 00000000000..60c37592e77 --- /dev/null +++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/config/IntentiqIdentityConfigTest.java @@ -0,0 +1,175 @@ +package org.prebid.server.hooks.modules.intentiq.identity.config; + +import com.codahale.metrics.MetricRegistry; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.prebid.server.hooks.modules.intentiq.identity.cache.IdentityCache; +import org.prebid.server.hooks.modules.intentiq.identity.cache.IdentityStore; +import org.prebid.server.hooks.modules.intentiq.identity.cache.RedisIdentityStore; +import org.prebid.server.hooks.modules.intentiq.identity.cache.RedisStatsReporter; +import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics; +import org.prebid.server.hooks.modules.intentiq.identity.metric.NoopIntentiqIdentityMetrics; +import org.prebid.server.hooks.modules.intentiq.identity.model.config.IntentiqIdentityProperties; +import org.prebid.server.hooks.modules.intentiq.identity.model.config.RedisProperties; +import org.prebid.server.hooks.modules.intentiq.identity.v1.IntentiqIdentityModule; +import org.prebid.server.hooks.modules.intentiq.identity.v1.core.ConfigResolver; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.json.JsonMerger; +import org.prebid.server.json.ObjectMapperProvider; +import org.prebid.server.vertx.httpclient.HttpClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class IntentiqIdentityConfigTest { + + private static final JacksonMapper MAPPER = new JacksonMapper(ObjectMapperProvider.mapper()); + + @Mock + private HttpClient httpClient; + + @Mock + private Vertx vertx; + + private final IntentiqIdentityConfig target = new IntentiqIdentityConfig(); + + @Test + public void propertiesBeanShouldBeCreated() { + assertThat(target.intentiqIdentityProperties()).isInstanceOf(IntentiqIdentityProperties.class); + } + + @Test + public void configResolverBeanShouldBeCreated() { + assertThat(target.intentiqIdentityConfigResolver(new JsonMerger(MAPPER), new IntentiqIdentityProperties())) + .isInstanceOf(ConfigResolver.class); + } + + @Test + public void moduleBeanShouldExposeProcessedAuctionRequestHookUnderModuleCode() { + // given + final ConfigResolver configResolver = + new ConfigResolver(MAPPER.mapper(), new JsonMerger(MAPPER), new IntentiqIdentityProperties()); + + // when + final IntentiqIdentityModule module = target.intentiqIdentityModule( + configResolver, + httpClient, + MAPPER, + new IntentiqIdentityMetrics(new MetricRegistry()), + new IntentiqIdentityProperties(), + mock(IdentityCache.class)); + + // then + assertThat(module.code()).isEqualTo(IntentiqIdentityModule.CODE); + assertThat(module.hooks()).hasSize(2); + } + + @Test + public void metricsBeanShouldBeCreated() { + assertThat(target.intentiqIdentityMetrics(new MetricRegistry())) + .isInstanceOf(IntentiqIdentityMetrics.class); + } + + @Test + public void noopMetricsBeanShouldBeCreated() { + assertThat(target.noopIntentiqIdentityMetrics()) + .isInstanceOf(NoopIntentiqIdentityMetrics.class); + } + + @Test + public void identityStoreBeanShouldFailWhenRedisHostMissing() { + // given + final IntentiqIdentityProperties properties = new IntentiqIdentityProperties(); + properties.setRedis(new RedisProperties()); + + // when and then + assertThatThrownBy(() -> target.intentiqIdentityStore(properties, vertx)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void identityStoreBeanShouldFailWhenRedisPortMissing() { + // given + final RedisProperties redis = new RedisProperties(); + redis.setHost("localhost"); + final IntentiqIdentityProperties properties = new IntentiqIdentityProperties(); + properties.setRedis(redis); + + // when and then + assertThatThrownBy(() -> target.intentiqIdentityStore(properties, vertx)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void redisStatsReporterBeanShouldReturnReporterWhenStoreIsRedis() { + // given + final RedisIdentityStore store = mock(RedisIdentityStore.class); + when(store.dbSize()).thenReturn(Future.succeededFuture(0L)); + when(store.evictedKeys()).thenReturn(Future.succeededFuture(0L)); + + // when + final RedisStatsReporter reporter = target.intentiqIdentityRedisStatsReporter( + store, vertx, new IntentiqIdentityMetrics(new MetricRegistry())); + + // then + assertThat(reporter).isNotNull(); + } + + @Test + public void redisStatsReporterBeanShouldReturnNullWhenStoreIsNotRedis() { + // when and then — a custom (non-Redis) IdentityStore has no DBSIZE/INFO stats to poll + assertThat(target.intentiqIdentityRedisStatsReporter( + mock(IdentityStore.class), vertx, new IntentiqIdentityMetrics(new MetricRegistry()))) + .isNull(); + } + + @Test + public void identityStoreBeanShouldBeBuiltWhenRedisPasswordConfigured() { + // given — a real Vertx is required because the Redis client casts it to VertxInternal + final Vertx realVertx = Vertx.vertx(); + final RedisProperties redis = new RedisProperties(); + redis.setHost("redis.internal"); + redis.setPort(6390); + redis.setPassword("s3cret"); + final IntentiqIdentityProperties properties = new IntentiqIdentityProperties(); + properties.setRedis(redis); + + try { + // when and then + assertThat(target.intentiqIdentityStore(properties, realVertx)).isNotNull(); + } finally { + realVertx.close(); + } + } + + @Test + public void identityStoreAndCacheBeansShouldBeBuiltWhenRedisConfigured() { + // given — a real Vertx is required because the Redis client casts it to VertxInternal + final Vertx realVertx = Vertx.vertx(); + final RedisProperties redis = new RedisProperties(); + redis.setHost("redis.internal"); + redis.setPort(6390); + final IntentiqIdentityProperties properties = new IntentiqIdentityProperties(); + properties.setRedis(redis); + + try { + // when + final IdentityStore store = target.intentiqIdentityStore(properties, realVertx); + final IdentityCache cache = target.intentiqIdentityCache( + properties, store, MAPPER, new IntentiqIdentityMetrics(new MetricRegistry())); + + // then + assertThat(store).isNotNull(); + assertThat(cache).isNotNull(); + } finally { + realVertx.close(); + } + } +} diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/metric/IntentiqIdentityMetricsTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/metric/IntentiqIdentityMetricsTest.java new file mode 100644 index 00000000000..ebbeaad774e --- /dev/null +++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/metric/IntentiqIdentityMetricsTest.java @@ -0,0 +1,165 @@ +package org.prebid.server.hooks.modules.intentiq.identity.metric; + +import com.codahale.metrics.MetricRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class IntentiqIdentityMetricsTest { + + private static final String DPI = "15769"; + + private MetricRegistry metricRegistry; + private IntentiqIdentityMetrics target; + + @BeforeEach + public void setUp() { + metricRegistry = new MetricRegistry(); + target = new IntentiqIdentityMetrics(metricRegistry); + } + + @Test + public void shouldIncrementNamespacedCountersSuffixedWithDpi() { + // when + target.cacheHit("l1", "third_party", DPI); + target.cacheHit("l2", "third_party", DPI); + target.cacheNegativeHit("l2", "device", DPI); + target.cacheInProgress("l1", "device", DPI); + target.cacheMiss("third_party", DPI); + target.cacheMiss("device", DPI); + target.apiSuccess(DPI); + target.apiError(DPI); + target.enriched(DPI); + target.eidsNone(DPI); + target.skipNoEndpoint(DPI); + target.impressionReported(DPI); + target.impressionError(DPI); + + // then + assertThat(count("cache.l1.hit.third_party_" + DPI)).isEqualTo(1); + assertThat(count("cache.l2.hit.third_party_" + DPI)).isEqualTo(1); + assertThat(count("cache.l2.negative.hit.device_" + DPI)).isEqualTo(1); + assertThat(count("cache.l1.in_progress.device_" + DPI)).isEqualTo(1); + assertThat(count("cache.miss.third_party_" + DPI)).isEqualTo(1); + assertThat(count("cache.miss.device_" + DPI)).isEqualTo(1); + assertThat(count("api.success_" + DPI)).isEqualTo(1); + assertThat(count("api.error_" + DPI)).isEqualTo(1); + assertThat(count("enriched_" + DPI)).isEqualTo(1); + assertThat(count("eids.none_" + DPI)).isEqualTo(1); + assertThat(count("skip.no_endpoint_" + DPI)).isEqualTo(1); + assertThat(count("impression.reported_" + DPI)).isEqualTo(1); + assertThat(count("impression.error_" + DPI)).isEqualTo(1); + } + + @Test + public void shouldRecordNothingWhenDisabled() { + // given + final IntentiqIdentityMetrics disabled = new NoopIntentiqIdentityMetrics(); + + // when + disabled.cacheHit("l1", "third_party", DPI); + disabled.terminationCause(20, DPI); + disabled.apiLatency(1_000_000L, DPI); + + // then + assertThat(metricRegistry.getMetrics()).isEmpty(); + } + + @Test + public void shouldOmitDpiSuffixWhenDpiBlank() { + // when + target.enriched(null); + target.enriched(""); + + // then + assertThat(count("enriched")).isEqualTo(2); + } + + @Test + public void shouldIncrementTerminationCauseCounterByRawCauseId() { + // when + target.terminationCause(20, DPI); + target.terminationCause(20, DPI); + target.terminationCause(36, DPI); + + // then + assertThat(count("tc.20_" + DPI)).isEqualTo(2); + assertThat(count("tc.36_" + DPI)).isEqualTo(1); + } + + @Test + public void shouldDropOutOfRangeTerminationCauses() { + // when — out-of-range values emit no counter at all + target.terminationCause(3333, DPI); + target.terminationCause(120088, DPI); + target.terminationCause(49, DPI); + + // then + assertThat(count("tc.3333_" + DPI)).isZero(); + assertThat(count("tc.120088_" + DPI)).isZero(); + assertThat(count("tc.49_" + DPI)).isEqualTo(1); + } + + @Test + public void shouldRecordApiLatencyTimer() { + // when + target.apiLatency(1_000_000L, DPI); + target.apiLatency(3_000_000L, DPI); + + // then + assertThat(metricRegistry.timer("modules.module.intentiq-identity.custom.api.latency_" + DPI).getCount()) + .isEqualTo(2); + } + + @Test + public void shouldRecordL1L2HealthMetricsGloballyWithoutDpiSuffix() { + // when — shared L1/L2 health metrics carry no _ segment + target.l1GetError(); + target.l1PutError(); + target.l2GetError(); + target.l2PutError(); + target.l2PutError(); + target.l2GetLatency(1_000_000L); + target.l2PutLatency(2_000_000L); + target.registerL1Gauges(() -> 7L, () -> 3L); + target.registerL2Gauges(() -> 100L, () -> 9L); + + // then + assertThat(count("l1.get.error")).isEqualTo(1); + assertThat(count("l1.put.error")).isEqualTo(1); + assertThat(count("l2.get.error")).isEqualTo(1); + assertThat(count("l2.put.error")).isEqualTo(2); + assertThat(metricRegistry.timer("modules.module.intentiq-identity.custom.l2.get.latency").getCount()) + .isEqualTo(1); + assertThat(metricRegistry.timer("modules.module.intentiq-identity.custom.l2.put.latency").getCount()) + .isEqualTo(1); + assertThat(gauge("l1.size")).isEqualTo(7L); + assertThat(gauge("l1.eviction")).isEqualTo(3L); + assertThat(gauge("l2.size")).isEqualTo(100L); + assertThat(gauge("l2.eviction")).isEqualTo(9L); + } + + @Test + public void shouldRecordNoHealthMetricsWhenDisabled() { + // given + final IntentiqIdentityMetrics disabled = new NoopIntentiqIdentityMetrics(); + + // when + disabled.l2GetError(); + disabled.l1PutError(); + disabled.l2GetLatency(1_000_000L); + disabled.registerL1Gauges(() -> 7L, () -> 3L); + + // then + assertThat(metricRegistry.getMetrics()).isEmpty(); + } + + private Object gauge(String name) { + return metricRegistry.getGauges().get("modules.module.intentiq-identity.custom." + name).getValue(); + } + + private long count(String name) { + return metricRegistry.counter("modules.module.intentiq-identity.custom." + name).getCount(); + } +} diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityAuctionResponseHookTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityAuctionResponseHookTest.java new file mode 100644 index 00000000000..31b85ce4833 --- /dev/null +++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityAuctionResponseHookTest.java @@ -0,0 +1,233 @@ +package org.prebid.server.hooks.modules.intentiq.identity.v1; + +import com.codahale.metrics.MetricRegistry; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Site; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import io.vertx.core.Future; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.hooks.execution.v1.auction.AuctionResponsePayloadImpl; +import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics; +import org.prebid.server.hooks.modules.intentiq.identity.model.IntentiqIdentityModuleContext; +import org.prebid.server.hooks.modules.intentiq.identity.model.config.IntentiqIdentityProperties; +import org.prebid.server.hooks.modules.intentiq.identity.v1.core.ConfigResolver; +import org.prebid.server.hooks.v1.InvocationAction; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionResponsePayload; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.json.JsonMerger; +import org.prebid.server.json.ObjectMapperProvider; +import org.prebid.server.vertx.httpclient.HttpClient; +import org.prebid.server.vertx.httpclient.model.HttpClientResponse; + +import java.math.BigDecimal; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class IntentiqIdentityAuctionResponseHookTest { + + private static final JacksonMapper MAPPER = new JacksonMapper(ObjectMapperProvider.mapper()); + private static final JsonMerger MERGER = new JsonMerger(MAPPER); + private static final String REPORTS = "https://reports.example.com/report"; + + @Mock + private HttpClient httpClient; + + @Mock + private AuctionInvocationContext invocationContext; + + @Mock + private AuctionContext auctionContext; + + @Captor + private ArgumentCaptor urlCaptor; + + private final MetricRegistry metricRegistry = new MetricRegistry(); + private final IntentiqIdentityMetrics metrics = new IntentiqIdentityMetrics(metricRegistry); + + @BeforeEach + public void setUp() { + when(invocationContext.accountConfig()).thenReturn(MAPPER.mapper().createObjectNode()); + when(invocationContext.auctionContext()).thenReturn(auctionContext); + when(invocationContext.moduleContext()).thenReturn(null); + when(httpClient.get(urlCaptor.capture(), any(), anyLong())) + .thenReturn(Future.succeededFuture(HttpClientResponse.of(200, null, "{}"))); + } + + private IntentiqIdentityAuctionResponseHook target(String reportsEndpoint, String partnerId) { + final IntentiqIdentityProperties properties = new IntentiqIdentityProperties(); + properties.setReportsEndpoint(reportsEndpoint); + properties.setPartnerId(partnerId); + properties.setTimeout(1000L); + final ConfigResolver configResolver = new ConfigResolver(MAPPER.mapper(), MERGER, properties); + return new IntentiqIdentityAuctionResponseHook(configResolver, httpClient, MAPPER, metrics); + } + + private AuctionResponsePayload payload(BidResponse bidResponse) { + return AuctionResponsePayloadImpl.of(bidResponse); + } + + private void givenRequest(BidRequest bidRequest) { + when(auctionContext.getBidRequest()).thenReturn(bidRequest); + } + + private static BidResponse response(String seat, Bid... bids) { + return BidResponse.builder() + .cur("USD") + .seatbid(List.of(SeatBid.builder().seat(seat).bid(List.of(bids)).build())) + .build(); + } + + private static String rdataJson(String url) { + final String marker = "rdata="; + final String encoded = url.substring(url.indexOf(marker) + marker.length()); + return URLDecoder.decode(encoded, StandardCharsets.UTF_8); + } + + @Test + public void callShouldReportWinningBidWithRdataAndConstants() { + // given + givenRequest(BidRequest.builder() + .site(Site.builder().domain("test.com").build()) + .device(Device.builder().ip("1.2.3.4").ua("UA").build()) + .build()); + when(invocationContext.moduleContext()) + .thenReturn(new IntentiqIdentityModuleContext(System.nanoTime(), "ab-123", null)); + final Bid bid = Bid.builder().impid("imp1").price(BigDecimal.valueOf(1.18)).build(); + + // when + target(REPORTS, "999").call(payload(response("pubmatic", bid)), invocationContext); + + // then + final String url = urlCaptor.getValue(); + assertThat(url).startsWith(REPORTS + "?at=45&rtype=1&source=pbjv&dpi=999&rdata="); + assertThat(url).contains("pubmatic").contains("imp1").contains("ab-123").contains("test.com"); + assertThat(metricRegistry.counter("modules.module.intentiq-identity.custom.impression.reported_999") + .getCount()) + .isEqualTo(1); + } + + @Test + public void callShouldReportEachWinningBid() { + // given + givenRequest(BidRequest.builder().build()); + final Bid bid1 = Bid.builder().impid("a").price(BigDecimal.ONE).build(); + final Bid bid2 = Bid.builder().impid("b").price(BigDecimal.TEN).build(); + + // when + target(REPORTS, "999").call(payload(response("seat", bid1, bid2)), invocationContext); + + // then + verify(httpClient, times(2)).get(any(), any(), anyLong()); + } + + @Test + public void callShouldOmitAbTestUuidWhenAbsent() { + // given + givenRequest(BidRequest.builder().build()); + final Bid bid = Bid.builder().impid("a").price(BigDecimal.ONE).build(); + + // when + target(REPORTS, "999").call(payload(response("seat", bid)), invocationContext); + + // then + assertThat(urlCaptor.getValue()).doesNotContain("abTestUuid"); + } + + @Test + public void callShouldIncludeOriginalCpmAndCurrencyFromBidExt() { + // given + givenRequest(BidRequest.builder().build()); + final ObjectNode ext = MAPPER.mapper().createObjectNode(); + ext.put("origbidcpm", new BigDecimal("2.50")); + ext.put("origbidcur", "EUR"); + final Bid bid = Bid.builder().impid("a").price(BigDecimal.valueOf(1.18)).ext(ext).build(); + + // when + target(REPORTS, "999").call(payload(response("seat", bid)), invocationContext); + + // then + final String rdata = rdataJson(urlCaptor.getValue()); + assertThat(rdata).contains("\"originalCpm\":2.50").contains("\"originalCurrency\":\"EUR\""); + } + + @Test + public void callShouldIncludePartnerAuctionIdFromRequestId() { + // given + givenRequest(BidRequest.builder().id("auction-77").build()); + final Bid bid = Bid.builder().impid("a").price(BigDecimal.ONE).build(); + + // when + target(REPORTS, "999").call(payload(response("seat", bid)), invocationContext); + + // then + assertThat(rdataJson(urlCaptor.getValue())).contains("\"partnerAuctionId\":\"auction-77\""); + } + + @Test + public void callShouldIncludeTerminationCauseFromModuleContext() { + // given + givenRequest(BidRequest.builder().build()); + when(invocationContext.moduleContext()) + .thenReturn(new IntentiqIdentityModuleContext(System.nanoTime(), "ab-1", 7L)); + final Bid bid = Bid.builder().impid("a").price(BigDecimal.ONE).build(); + + // when + target(REPORTS, "999").call(payload(response("seat", bid)), invocationContext); + + // then + assertThat(rdataJson(urlCaptor.getValue())).contains("\"terminationCause\":7"); + } + + @Test + public void callShouldOmitTerminationCauseWhenModuleContextHasNone() { + // given + givenRequest(BidRequest.builder().build()); + when(invocationContext.moduleContext()) + .thenReturn(new IntentiqIdentityModuleContext(System.nanoTime(), "ab-1", null)); + final Bid bid = Bid.builder().impid("a").price(BigDecimal.ONE).build(); + + // when + target(REPORTS, "999").call(payload(response("seat", bid)), invocationContext); + + // then + assertThat(rdataJson(urlCaptor.getValue())).doesNotContain("terminationCause"); + } + + @Test + public void callShouldReturnNoActionAndSkipReportingWhenReportsEndpointBlank() { + // when + final var result = target(null, "999") + .call(payload(response("seat", Bid.builder().impid("a").price(BigDecimal.ONE).build())), + invocationContext) + .result(); + + // then + assertThat(result.action()).isEqualTo(InvocationAction.no_action); + verifyNoInteractions(httpClient); + } +} diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityModuleTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityModuleTest.java new file mode 100644 index 00000000000..81bde2b0a1d --- /dev/null +++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityModuleTest.java @@ -0,0 +1,55 @@ +package org.prebid.server.hooks.modules.intentiq.identity.v1; + +import com.codahale.metrics.MetricRegistry; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics; +import org.prebid.server.hooks.modules.intentiq.identity.model.config.IntentiqIdentityProperties; +import org.prebid.server.hooks.modules.intentiq.identity.v1.core.ConfigResolver; +import org.prebid.server.hooks.modules.intentiq.identity.v1.core.FirstPartyKeyExtractor; +import org.prebid.server.hooks.v1.Hook; +import org.prebid.server.hooks.v1.InvocationContext; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.json.JsonMerger; +import org.prebid.server.json.ObjectMapperProvider; +import org.prebid.server.vertx.httpclient.HttpClient; + +import java.util.List; + +import static java.util.Collections.emptyList; +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +public class IntentiqIdentityModuleTest { + + private static final JacksonMapper MAPPER = new JacksonMapper(ObjectMapperProvider.mapper()); + + @Mock + private HttpClient httpClient; + + @Test + public void codeShouldReturnExpectedValue() { + // given + final IntentiqIdentityModule target = new IntentiqIdentityModule(emptyList()); + + // when and then + assertThat(target.code()).isEqualTo("intentiq-identity"); + } + + @Test + public void hooksShouldReturnProvidedHooks() { + // given + final ConfigResolver configResolver = + new ConfigResolver(MAPPER.mapper(), new JsonMerger(MAPPER), new IntentiqIdentityProperties()); + final List> hooks = List.of( + new IntentiqIdentityProcessedAuctionRequestHook( + configResolver, httpClient, MAPPER, null, + new FirstPartyKeyExtractor(10), new IntentiqIdentityMetrics(new MetricRegistry()))); + final IntentiqIdentityModule target = new IntentiqIdentityModule(hooks); + + // when and then + assertThat(target.hooks()).isEqualTo(hooks); + } +} diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityProcessedAuctionRequestHookTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityProcessedAuctionRequestHookTest.java new file mode 100644 index 00000000000..241fcb36258 --- /dev/null +++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/IntentiqIdentityProcessedAuctionRequestHookTest.java @@ -0,0 +1,750 @@ +package org.prebid.server.hooks.modules.intentiq.identity.v1; + +import com.codahale.metrics.MetricRegistry; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.BrandVersion; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.Regs; +import com.iab.openrtb.request.Site; +import com.iab.openrtb.request.Uid; +import com.iab.openrtb.request.User; +import com.iab.openrtb.request.UserAgent; +import org.prebid.server.proto.openrtb.ext.request.ExtRegs; +import org.prebid.server.proto.openrtb.ext.request.ExtUser; +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl; +import org.prebid.server.hooks.modules.intentiq.identity.cache.CacheResult; +import org.prebid.server.hooks.modules.intentiq.identity.cache.IdentityCache; +import org.prebid.server.hooks.modules.intentiq.identity.cache.KeyType; +import org.prebid.server.hooks.modules.intentiq.identity.metric.IntentiqIdentityMetrics; +import org.prebid.server.hooks.modules.intentiq.identity.model.IntentiqIdentityModuleContext; +import org.prebid.server.hooks.modules.intentiq.identity.model.config.IntentiqIdentityProperties; +import org.prebid.server.hooks.modules.intentiq.identity.v1.core.ConfigResolver; +import org.prebid.server.hooks.modules.intentiq.identity.v1.core.FirstPartyKeyExtractor; +import org.prebid.server.hooks.v1.InvocationAction; +import org.prebid.server.hooks.v1.InvocationResult; +import org.prebid.server.hooks.v1.InvocationStatus; +import org.prebid.server.hooks.v1.PayloadUpdate; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.json.JsonMerger; +import org.prebid.server.json.ObjectMapperProvider; +import org.prebid.server.vertx.httpclient.HttpClient; +import org.prebid.server.vertx.httpclient.model.HttpClientResponse; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class IntentiqIdentityProcessedAuctionRequestHookTest { + + private static final JacksonMapper MAPPER = new JacksonMapper(ObjectMapperProvider.mapper()); + private static final JsonMerger MERGER = new JsonMerger(MAPPER); + private static final String API_ENDPOINT = "https://dev.example.com/resolve"; + private static final String EIDS_BODY = + "{\"data\":{\"eids\":[{\"source\":\"intentiq.com\",\"uids\":[{\"id\":\"abc\",\"atype\":1}]}]}}"; + private static final String EMPTY_BODY = "{\"data\":{\"eids\":[]}}"; + + @Mock + private HttpClient httpClient; + + @Mock + private AuctionInvocationContext invocationContext; + + @Captor + private ArgumentCaptor urlCaptor; + + @Captor + private ArgumentCaptor headersCaptor; + + private final MetricRegistry metricRegistry = new MetricRegistry(); + private final IntentiqIdentityMetrics metrics = new IntentiqIdentityMetrics(metricRegistry); + + @BeforeEach + public void setUp() { + when(invocationContext.accountConfig()).thenReturn(MAPPER.mapper().createObjectNode()); + } + + // Counter-asserting tests all run with partner id "123"; metrics are suffixed with the dpi. + private long counter(String name) { + return metricRegistry.counter("modules.module.intentiq-identity.custom." + name + "_123").getCount(); + } + + private IntentiqIdentityProcessedAuctionRequestHook target(String apiEndpoint, String partnerId) { + return target(apiEndpoint, partnerId, null); + } + + private IntentiqIdentityProcessedAuctionRequestHook target(String apiEndpoint, String partnerId, + IdentityCache cache) { + final IntentiqIdentityProperties properties = new IntentiqIdentityProperties(); + properties.setApiEndpoint(apiEndpoint); + properties.setPartnerId(partnerId); + properties.setTimeout(1000L); + if (cache != null) { + properties.getCache().setEnabled(true); + } + final ConfigResolver configResolver = new ConfigResolver(MAPPER.mapper(), MERGER, properties); + return new IntentiqIdentityProcessedAuctionRequestHook( + configResolver, httpClient, MAPPER, cache, new FirstPartyKeyExtractor(10), metrics); + } + + @Test + public void codeShouldReturnExpectedValue() { + assertThat(target(API_ENDPOINT, "123").code()) + .isEqualTo("intentiq-identity-processed-auction-request-hook"); + } + + @Test + public void callShouldEnrichUserEidsWhenApiReturnsEids() { + // given + givenResponse(EIDS_BODY); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + + // when + final InvocationResult result = target(API_ENDPOINT, "123") + .call(payload, invocationContext).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.update); + final Eid enriched = applyUpdate(result, payload).getUser().getEids().getFirst(); + assertThat(enriched.getSource()).isEqualTo("intentiq.com"); + assertThat(enriched.getUids().getFirst().getId()).isEqualTo("abc"); + } + + @Test + public void callShouldFallBackToGlobalPropertiesWhenAccountConfigIsNull() { + // given — host-level-only config: no account override, so accountConfig() is null in production + // (JsonMergePatch rejects a null patch, which previously failed the whole hook invocation). + givenResponse(EIDS_BODY); + when(invocationContext.accountConfig()).thenReturn(null); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + + // when + final InvocationResult result = target(API_ENDPOINT, "123") + .call(payload, invocationContext).result(); + + // then — resolver falls back to global properties and the hook still enriches + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.update); + assertThat(applyUpdate(result, payload).getUser().getEids().getFirst().getSource()) + .isEqualTo("intentiq.com"); + } + + @Test + public void callShouldAppendResolvedEidsAfterExistingUserEids() { + // given + givenResponse(EIDS_BODY); + final Eid existing = Eid.builder() + .source("pubcid.org") + .uids(singletonList(Uid.builder().id("existing-uid").build())) + .build(); + final BidRequest bidRequest = BidRequest.builder() + .user(User.builder().eids(singletonList(existing)).build()) + .build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + // when + final InvocationResult result = target(API_ENDPOINT, "123") + .call(payload, invocationContext).result(); + + // then + assertThat(applyUpdate(result, payload).getUser().getEids()) + .extracting(Eid::getSource) + .containsExactly("pubcid.org", "intentiq.com"); + } + + @Test + public void callShouldSendGetWithConstantsPartnerAndSrvrReq() { + // given + givenCapturedResponse(); + + // when + target(API_ENDPOINT, "partner-42").call( + AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext); + + // then + assertThat(urlCaptor.getValue()) + .isEqualTo(API_ENDPOINT + "?at=39&mi=10&dpi=partner-42&pt=17&dpn=1&srvrReq=true&source=pbjv"); + } + + @Test + public void callShouldApplyAccountLevelPartnerIdOverride() { + // given + givenCapturedResponse(); + final ObjectNode account = MAPPER.mapper().createObjectNode().put("partner-id", "acct-9"); + when(invocationContext.accountConfig()).thenReturn(account); + + // when + target(API_ENDPOINT, "global-1").call( + AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext); + + // then — account-level partner-id wins over the global value + assertThat(urlCaptor.getValue()).contains("&dpi=acct-9"); + } + + @Test + public void callShouldAppendDeviceIpIpv6AndUserAgent() { + // given + givenCapturedResponse(); + final BidRequest bidRequest = BidRequest.builder() + .device(Device.builder().ip("125.253.50.47").ipv6("2001:db8::1").ua("Mozilla/5.0 (iPhone)").build()) + .build(); + + // when + target(API_ENDPOINT, "383342646").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext); + + // then + assertThat(urlCaptor.getValue()) + .isEqualTo(API_ENDPOINT + "?at=39&mi=10&dpi=383342646&pt=17&dpn=1&srvrReq=true&source=pbjv" + + "&ip=125.253.50.47&ipv6=2001%3Adb8%3A%3A1&uas=Mozilla%2F5.0%20%28iPhone%29"); + } + + @Test + public void callShouldSendDeviceIfaAsPcidWithIdtype4ForMobile() { + // given + givenCapturedResponse(); + final BidRequest bidRequest = BidRequest.builder() + .device(Device.builder().ifa("maid-AbC").devicetype(1).build()) + .build(); + + // when + target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext); + + // then + assertThat(urlCaptor.getValue()).contains("&pcid=maid-AbC&idtype=4"); + } + + @Test + public void callShouldUppercasePcidAndSendIdtype8ForCtv() { + // given + givenCapturedResponse(); + final BidRequest bidRequest = BidRequest.builder() + .device(Device.builder().ifa("rida-abc").devicetype(3).build()) + .build(); + + // when + target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext); + + // then + assertThat(urlCaptor.getValue()).contains("&pcid=RIDA-ABC&idtype=8"); + } + + @Test + public void callShouldNotSendPcidWhenLimitAdTracking() { + // given + givenCapturedResponse(); + final BidRequest bidRequest = BidRequest.builder() + .device(Device.builder().ifa("maid-1").lmt(1).build()) + .build(); + + // when + target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext); + + // then + assertThat(urlCaptor.getValue()).doesNotContain("pcid").doesNotContain("idtype"); + } + + @Test + public void callShouldSendSiteDomainAsRef() { + // given + givenCapturedResponse(); + final BidRequest bidRequest = BidRequest.builder().site(Site.builder().domain("example.com").build()).build(); + + // when + target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext); + + // then + assertThat(urlCaptor.getValue()).contains("&ref=example.com"); + } + + @Test + public void callShouldSendExistingIiqUniversalIdAsIiquid() { + // given + givenCapturedResponse(); + final BidRequest bidRequest = BidRequest.builder() + .user(User.builder().eids(singletonList(Eid.builder() + .source("intentiq.com") + .uids(singletonList(Uid.builder().id("IIQ-UID-1").build())) + .build())).build()) + .build(); + + // when + target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext); + + // then + assertThat(urlCaptor.getValue()).contains("&iiquid=IIQ-UID-1"); + } + + @Test + public void callShouldAppendGdprUsPrivacyAndGppAsQueryParamsFromTopLevelFields() { + // given + givenCapturedResponse(); + final BidRequest bidRequest = BidRequest.builder() + .regs(Regs.builder().gdpr(1).usPrivacy("1YNN").gpp("DBABMA~CONSENT").gppSid(List.of(2, 6)).build()) + .user(User.builder().consent("CO-TCF-STRING").build()) + .build(); + + // when + target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext); + + // then — consent goes in the gdpr-consent header (GDPR guide), not the query string + assertThat(urlCaptor.getValue()) + .contains("&gdpr=1") + .contains("&us_privacy=1YNN") + .contains("&gpp=DBABMA%7ECONSENT") + .contains("&gpp_sid=2%2C6") + .doesNotContain("gdpr_consent"); + assertThat(headersCaptor.getValue().get("gdpr-consent")).isEqualTo("CO-TCF-STRING"); + } + + @Test + public void callShouldSendConsentInHeaderFromUserExtFallback() { + // given + givenCapturedResponse(); + final BidRequest bidRequest = BidRequest.builder() + .regs(Regs.builder().ext(ExtRegs.of(1, "1NYN", null, null)).build()) + .user(User.builder().ext(ExtUser.builder().consent("EXT-TCF-STRING").build()).build()) + .build(); + + // when + target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext); + + // then + assertThat(urlCaptor.getValue()) + .contains("&gdpr=1") + .contains("&us_privacy=1NYN") + .doesNotContain("gdpr_consent"); + assertThat(headersCaptor.getValue().get("gdpr-consent")).isEqualTo("EXT-TCF-STRING"); + } + + @Test + public void callShouldNotAppendConsentParamsOrHeaderWhenAbsent() { + // given + givenCapturedResponse(); + + // when + target(API_ENDPOINT, "123").call( + AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext); + + // then + assertThat(urlCaptor.getValue()) + .doesNotContain("gdpr") + .doesNotContain("us_privacy") + .doesNotContain("gpp"); + assertThat(headersCaptor.getValue().get("gdpr-consent")).isNull(); + } + + @Test + public void callShouldAppendUaHintsFromDeviceSuaInUhParam() throws Exception { + // given + givenCapturedResponse(); + final UserAgent sua = UserAgent.builder() + .source(2) + .browsers(List.of( + new BrandVersion("Chromium", List.of("108", "0", "5359", "125"), null), + new BrandVersion("Google Chrome", List.of("108", "0", "5359", "125"), null), + new BrandVersion("Not?A_Brand", List.of("8", "0", "0", "0"), null))) + .platform(new BrandVersion("Windows", List.of("15", "0", "0"), null)) + .mobile(0) + .architecture("x86") + .bitness("64") + .build(); + final BidRequest bidRequest = BidRequest.builder().device(Device.builder().sua(sua).build()).build(); + + // when + target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext); + + // then — uh is the numeric-keyed UA-CH JSON the IntentIQ backend expects (brands sorted, major vs full version) + final JsonNode uh = MAPPER.mapper().readTree(extractParam(urlCaptor.getValue(), "uh")); + assertThat(uh.get("0").asText()) + .isEqualTo("\"Chromium\";v=\"108\", \"Google Chrome\";v=\"108\", \"Not?A_Brand\";v=\"8\""); + assertThat(uh.get("8").asText()).isEqualTo("\"Chromium\";v=\"108.0.5359.125\", " + + "\"Google Chrome\";v=\"108.0.5359.125\", \"Not?A_Brand\";v=\"8.0.0.0\""); + assertThat(uh.get("1").asText()).isEqualTo("?0"); + assertThat(uh.get("2").asText()).isEqualTo("\"Windows\""); + assertThat(uh.get("3").asText()).isEqualTo("\"x86\""); + assertThat(uh.get("4").asText()).isEqualTo("\"64\""); + assertThat(uh.get("6").asText()).isEqualTo("\"15.0.0\""); + assertThat(uh.has("5")).isFalse(); + assertThat(uh.has("7")).isFalse(); + } + + @Test + public void callShouldNotAppendUhWhenSuaSourceIsNotHighEntropy() { + // given — the IntentIQ backend only consumes hints when sua.source == 2 (high-entropy) + givenCapturedResponse(); + final UserAgent sua = UserAgent.builder() + .source(1) + .browsers(List.of(new BrandVersion("Chrome", List.of("120"), null))) + .build(); + final BidRequest bidRequest = BidRequest.builder().device(Device.builder().sua(sua).build()).build(); + + // when + target(API_ENDPOINT, "123").call(AuctionRequestPayloadImpl.of(bidRequest), invocationContext); + + // then + assertThat(urlCaptor.getValue()).doesNotContain("uh="); + } + + @Test + public void callShouldCarryTerminationCauseFromResponseInModuleContext() { + // given + givenResponse("{\"data\":{\"eids\":[{\"source\":\"intentiq.com\",\"uids\":[{\"id\":\"abc\"}]}]},\"tc\":5}"); + + // when + final InvocationResult result = target(API_ENDPOINT, "123") + .call(AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext).result(); + + // then + final IntentiqIdentityModuleContext ctx = (IntentiqIdentityModuleContext) result.moduleContext(); + assertThat(ctx.abTestUuid()).isNull(); + assertThat(ctx.terminationCause()).isEqualTo(5L); + } + + @Test + public void callShouldTreatEmptyStringDataAsValidEmptyResponseNotApiError() { + // given — GDPR/invalid BE responses return data as an empty string (""), not an object + givenResponse("{\"adt\":4,\"ct\":2,\"data\":\"\",\"cttl\":600000,\"tc\":36}"); + + // when + final InvocationResult result = target(API_ENDPOINT, "123") + .call(AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext).result(); + + // then — parsed cleanly as an empty result, not counted as an API error + assertThat(result.action()).isEqualTo(InvocationAction.no_action); + assertThat(counter("api.success")).isEqualTo(1); + assertThat(counter("api.error")).isZero(); + assertThat(counter("eids.none")).isEqualTo(1); + assertThat(counter("tc.36")).isEqualTo(1); + } + + @Test + public void callShouldPassResponseCttlToNegativeCacheOnMiss() { + // given — BE supplies the suppression window via cttl on an empty response + final IdentityCache cache = mock(IdentityCache.class); + when(cache.get(any())).thenReturn(Future.succeededFuture(CacheResult.miss())); + givenResponse("{\"data\":{\"eids\":[]},\"cttl\":600000}"); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of( + BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build()); + + // when + target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result(); + + // then + verify(cache).putNegative(any(), eq(600_000L)); + } + + @Test + public void callShouldReturnNoActionWhenApiReturnsNoEids() { + // given + givenResponse(EMPTY_BODY); + + // when + final InvocationResult result = target(API_ENDPOINT, "123") + .call(AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext).result(); + + // then + assertThat(result.action()).isEqualTo(InvocationAction.no_action); + assertThat(result.payloadUpdate()).isNull(); + } + + @Test + public void callShouldReturnNoActionWhenHttpCallFails() { + // given + when(httpClient.get(any(), any(), anyLong())) + .thenReturn(Future.failedFuture(new RuntimeException("boom"))); + + // when + final Future> future = target(API_ENDPOINT, "123") + .call(AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext); + + // then + assertThat(future.succeeded()).isTrue(); + assertThat(future.result().action()).isEqualTo(InvocationAction.no_action); + } + + @Test + public void callShouldReturnNoActionAndSkipHttpWhenApiEndpointBlank() { + // when + final InvocationResult result = target(null, "123") + .call(AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext).result(); + + // then + assertThat(result.action()).isEqualTo(InvocationAction.no_action); + verifyNoInteractions(httpClient); + } + + @Test + public void callShouldUseCachedEidsAndSkipHttpOnCacheHit() { + // given + final IdentityCache cache = mock(IdentityCache.class); + final Eid cached = Eid.builder() + .source("intentiq.com") + .uids(singletonList(Uid.builder().id("cached-uid").build())) + .build(); + when(cache.get(any())) + .thenReturn(Future.succeededFuture( + CacheResult.hit(singletonList(cached), KeyType.FIRST_PARTY, CacheResult.Layer.L1))); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of( + BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build()); + + // when + final InvocationResult result = + target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result(); + + // then + assertThat(result.action()).isEqualTo(InvocationAction.update); + assertThat(applyUpdate(result, payload).getUser().getEids()).containsExactly(cached); + verifyNoInteractions(httpClient); + } + + @Test + public void callShouldFetchAndPopulateCacheOnMiss() { + // given + final IdentityCache cache = mock(IdentityCache.class); + when(cache.get(any())).thenReturn(Future.succeededFuture(CacheResult.miss())); + givenResponse(EIDS_BODY); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of( + BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build()); + + // when + final InvocationResult result = + target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result(); + + // then + assertThat(result.action()).isEqualTo(InvocationAction.update); + verify(cache).put(any(), any(), anyLong()); + } + + @Test + public void callShouldWriteNegativeCacheEntryWhenApiReturnsNoEidsOnMiss() { + // given + final IdentityCache cache = mock(IdentityCache.class); + when(cache.get(any())).thenReturn(Future.succeededFuture(CacheResult.miss())); + givenResponse(EMPTY_BODY); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of( + BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build()); + + // when + final InvocationResult result = + target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result(); + + // then — EMPTY_BODY carries no cttl, so the negative entry uses the default suppression TTL + assertThat(result.action()).isEqualTo(InvocationAction.no_action); + verify(cache).putNegative(any(), eq(0L)); + } + + @Test + public void callShouldReturnNoActionAndSkipHttpOnNegativeCacheHit() { + // given + final IdentityCache cache = mock(IdentityCache.class); + when(cache.get(any())).thenReturn(Future.succeededFuture( + CacheResult.negative(KeyType.FIRST_PARTY, CacheResult.Layer.L2))); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of( + BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build()); + + // when + final InvocationResult result = + target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result(); + + // then + assertThat(result.action()).isEqualTo(InvocationAction.no_action); + assertThat(counter("cache.l2.negative.hit.first_party")).isEqualTo(1); + assertThat(counter("cache.miss.first_party")).isEqualTo(1); + assertThat(counter("cache.l1.hit.first_party")).isZero(); + verifyNoInteractions(httpClient); + } + + @Test + public void callShouldIncrementApiSuccessAndEnrichedOnEnrichment() { + // given + givenResponse(EIDS_BODY); + + // when + target(API_ENDPOINT, "123").call( + AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext).result(); + + // then + assertThat(counter("api.success")).isEqualTo(1); + assertThat(counter("enriched")).isEqualTo(1); + } + + @Test + public void callShouldRecordRawTerminationCauseFromResponse() { + // given + givenResponse("{\"data\":{\"eids\":[]},\"tc\":20}"); + + // when + target(API_ENDPOINT, "123").call( + AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext).result(); + + // then + assertThat(counter("tc.20")).isEqualTo(1); + } + + @Test + public void callShouldDropOutOfRangeTerminationCause() { + // given — an out-of-range tc emits no counter + givenResponse("{\"data\":{\"eids\":[]},\"tc\":120088}"); + + // when + target(API_ENDPOINT, "123").call( + AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext).result(); + + // then + assertThat(counter("tc.120088")).isZero(); + } + + @Test + public void callShouldIncrementApiErrorWhenHttpFails() { + // given + when(httpClient.get(any(), any(), anyLong())) + .thenReturn(Future.failedFuture(new RuntimeException("boom"))); + + // when + target(API_ENDPOINT, "123").call( + AuctionRequestPayloadImpl.of(BidRequest.builder().build()), invocationContext).result(); + + // then + assertThat(counter("api.error")).isEqualTo(1); + } + + @Test + public void callShouldIncrementCacheHitOnCacheHit() { + // given + final IdentityCache cache = mock(IdentityCache.class); + when(cache.get(any())).thenReturn(Future.succeededFuture(CacheResult.hit(singletonList(Eid.builder() + .source("intentiq.com") + .uids(singletonList(Uid.builder().id("u").build())) + .build()), KeyType.FIRST_PARTY, CacheResult.Layer.L1))); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of( + BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build()); + + // when + target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result(); + + // then + assertThat(counter("cache.l1.hit.first_party")).isEqualTo(1); + assertThat(counter("cache.miss.first_party")).isZero(); + } + + @Test + public void callShouldIncrementCacheMissOnCacheMiss() { + // given + final IdentityCache cache = mock(IdentityCache.class); + when(cache.get(any())).thenReturn(Future.succeededFuture(CacheResult.miss())); + givenResponse(EIDS_BODY); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of( + BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build()); + + // when + target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result(); + + // then + assertThat(counter("cache.miss.first_party")).isEqualTo(1); + assertThat(counter("cache.l1.hit.first_party")).isZero(); + } + + @Test + public void callShouldMarkInProgressBeforeFetchingOnCacheMiss() { + // given + final IdentityCache cache = mock(IdentityCache.class); + when(cache.get(any())).thenReturn(Future.succeededFuture(CacheResult.miss())); + givenResponse(EIDS_BODY); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of( + BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build()); + + // when + target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result(); + + // then — marker written so concurrent requests don't fire a duplicate, then the call is made + verify(cache).putInProgress(any()); + verify(httpClient).get(any(), any(), anyLong()); + } + + @Test + public void callShouldReturnNoActionAndSkipHttpOnInProgressCacheResult() { + // given + final IdentityCache cache = mock(IdentityCache.class); + when(cache.get(any())).thenReturn(Future.succeededFuture( + CacheResult.inProgress(KeyType.FIRST_PARTY, CacheResult.Layer.L1))); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of( + BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build()); + + // when + final InvocationResult result = + target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result(); + + // then — a resolution is already in flight: do not fire a duplicate, do not enrich + assertThat(result.action()).isEqualTo(InvocationAction.no_action); + verifyNoInteractions(httpClient); + verify(cache, never()).putInProgress(any()); + } + + @Test + public void callShouldIncrementCacheInProgressOnInProgressResult() { + // given + final IdentityCache cache = mock(IdentityCache.class); + when(cache.get(any())).thenReturn(Future.succeededFuture( + CacheResult.inProgress(KeyType.FIRST_PARTY, CacheResult.Layer.L1))); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of( + BidRequest.builder().device(Device.builder().ifa("MAID-1").build()).build()); + + // when + target(API_ENDPOINT, "123", cache).call(payload, invocationContext).result(); + + // then + assertThat(counter("cache.l1.in_progress.first_party")).isEqualTo(1); + } + + private void givenResponse(String body) { + when(httpClient.get(any(), any(), anyLong())) + .thenReturn(Future.succeededFuture(HttpClientResponse.of(200, null, body))); + } + + private void givenCapturedResponse() { + when(httpClient.get(urlCaptor.capture(), headersCaptor.capture(), anyLong())) + .thenReturn(Future.succeededFuture(HttpClientResponse.of(200, null, EMPTY_BODY))); + } + + private static BidRequest applyUpdate(InvocationResult result, + AuctionRequestPayload payload) { + final PayloadUpdate update = result.payloadUpdate(); + return update.apply(payload).bidRequest(); + } + + private static String extractParam(String url, String key) { + final int start = url.indexOf("&" + key + "=") + key.length() + 2; + final int end = url.indexOf('&', start); + final String value = url.substring(start, end < 0 ? url.length() : end); + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } +} diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/DeviceUserAgentTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/DeviceUserAgentTest.java new file mode 100644 index 00000000000..d9db6af098f --- /dev/null +++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/DeviceUserAgentTest.java @@ -0,0 +1,39 @@ +package org.prebid.server.hooks.modules.intentiq.identity.v1.core; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class DeviceUserAgentTest { + + @Test + public void shouldReturnEmptyForBlankUa() { + assertThat(DeviceUserAgent.normalize(null)).isEmpty(); + assertThat(DeviceUserAgent.normalize(" ")).isEmpty(); + } + + @Test + public void shouldReturnEmptyForUnrecognizedUa() { + assertThat(DeviceUserAgent.normalize("UA")).isEmpty(); + } + + @Test + public void shouldNormalizeIphoneSafari() { + final String ua = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " + + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; + + assertThat(DeviceUserAgent.normalize(ua)) + .contains("iOS17") + .contains("iPhone") + .doesNotContain(" "); + } + + @Test + public void shouldBeDeterministic() { + final String ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + + assertThat(DeviceUserAgent.normalize(ua)).isEqualTo(DeviceUserAgent.normalize(ua)); + assertThat(DeviceUserAgent.normalize(ua)).contains("Chrome120"); + } +} diff --git a/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/FirstPartyKeyExtractorTest.java b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/FirstPartyKeyExtractorTest.java new file mode 100644 index 00000000000..de2e2e88749 --- /dev/null +++ b/extra/modules/intentiq-identity/src/test/java/org/prebid/server/hooks/modules/intentiq/identity/v1/core/FirstPartyKeyExtractorTest.java @@ -0,0 +1,146 @@ +package org.prebid.server.hooks.modules.intentiq.identity.v1.core; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.Uid; +import com.iab.openrtb.request.User; +import org.junit.jupiter.api.Test; +import org.prebid.server.hooks.modules.intentiq.identity.cache.CacheKey; +import org.prebid.server.hooks.modules.intentiq.identity.cache.KeyType; + +import java.util.List; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; + +public class FirstPartyKeyExtractorTest { + + private final FirstPartyKeyExtractor target = new FirstPartyKeyExtractor(10); + + @Test + public void shouldReturnEmptyWhenNoIdentifiersPresent() { + assertThat(target.candidateKeys(BidRequest.builder().build())).isEmpty(); + } + + @Test + public void shouldOrderKeysByPriorityAndTagTypes() { + // given + final BidRequest bidRequest = BidRequest.builder() + .user(User.builder().eids(List.of( + eid("uidapi.com", "uid2-1"), + eid("pubcid.org", "pub-1"), + eid("intentiq.com", "iiq-1"))) + .build()) + .device(Device.builder().ifa("ifa-1").ua("UA").ip("1.2.3.4").build()) + .build(); + + // when + final List keys = target.candidateKeys(bidRequest); + + // then — iiq, pubcid, maid, other eid, device composite (unrecognized UA contributes nothing) + assertThat(keys).containsExactly( + new CacheKey("iiq:iiq-1", KeyType.THIRD_PARTY), + new CacheKey("pubcid:pub-1", KeyType.FIRST_PARTY), + new CacheKey("maid:ifa-1", KeyType.FIRST_PARTY), + new CacheKey("uidapi.com:uid2-1", KeyType.FIRST_PARTY), + new CacheKey("dev:ifa-1_1.2.3.4", KeyType.DEVICE)); + } + + @Test + public void shouldNormalizeUserAgentInDeviceCompositeRatherThanUseRawString() { + // given — a real iPhone Safari UA + final String rawUa = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " + + "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; + final BidRequest bidRequest = BidRequest.builder() + .device(Device.builder().ua(rawUa).ip("1.2.3.4").build()) + .build(); + + // when + final CacheKey deviceKey = target.candidateKeys(bidRequest).stream() + .filter(key -> key.type() == KeyType.DEVICE) + .findFirst() + .orElseThrow(); + + // then — normalized OS/browser/device tokens, never the raw UA string + assertThat(deviceKey.key()) + .startsWith("dev:") + .endsWith("_1.2.3.4") + .contains("iOS") + .doesNotContain(rawUa) + .doesNotContain(" "); + } + + @Test + public void shouldTreatSharedidOrgAsPubcid() { + // given + final BidRequest bidRequest = BidRequest.builder() + .user(User.builder().eids(singletonList(eid("sharedid.org", "s-1"))).build()) + .build(); + + // when / then + assertThat(target.candidateKeys(bidRequest)) + .containsExactly(new CacheKey("pubcid:s-1", KeyType.FIRST_PARTY)); + } + + @Test + public void shouldSkipMaidKeyWhenLimitAdTracking() { + // given + final BidRequest bidRequest = BidRequest.builder() + .device(Device.builder().ifa("ifa-1").lmt(1).build()) + .build(); + + // when / then — only the device composite remains, no maid: key + assertThat(target.candidateKeys(bidRequest)) + .extracting(CacheKey::key) + .doesNotContain("maid:ifa-1"); + } + + @Test + public void shouldUppercaseMaidForCtv() { + // given + final BidRequest bidRequest = BidRequest.builder() + .device(Device.builder().ifa("rida-abc").devicetype(3).build()) + .build(); + + // when / then + assertThat(target.candidateKeys(bidRequest)) + .extracting(CacheKey::key) + .contains("maid:RIDA-ABC"); + } + + @Test + public void shouldDeduplicateRepeatedKeys() { + // given — same pubcid id twice + final BidRequest bidRequest = BidRequest.builder() + .user(User.builder().eids(List.of(eid("pubcid.org", "dup"), eid("pubcid.org", "dup"))).build()) + .build(); + + // when / then + assertThat(target.candidateKeys(bidRequest)) + .containsExactly(new CacheKey("pubcid:dup", KeyType.FIRST_PARTY)); + } + + @Test + public void shouldCapNumberOfKeys() { + // given — extractor capped at 2 + final FirstPartyKeyExtractor capped = new FirstPartyKeyExtractor(2); + final BidRequest bidRequest = BidRequest.builder() + .user(User.builder().eids(List.of( + eid("intentiq.com", "iiq-1"), + eid("pubcid.org", "pub-1"), + eid("uidapi.com", "uid2-1"))) + .build()) + .build(); + + // when / then — only the two highest-priority keys survive + assertThat(capped.candidateKeys(bidRequest)) + .containsExactly( + new CacheKey("iiq:iiq-1", KeyType.THIRD_PARTY), + new CacheKey("pubcid:pub-1", KeyType.FIRST_PARTY)); + } + + private static Eid eid(String source, String id) { + return Eid.builder().source(source).uids(singletonList(Uid.builder().id(id).build())).build(); + } +} diff --git a/extra/modules/live-intent-omni-channel-identity/pom.xml b/extra/modules/live-intent-omni-channel-identity/pom.xml index d981e52f7d0..e7ff49a16c8 100644 --- a/extra/modules/live-intent-omni-channel-identity/pom.xml +++ b/extra/modules/live-intent-omni-channel-identity/pom.xml @@ -4,7 +4,7 @@ org.prebid.server.hooks.modules all-modules - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT live-intent-omni-channel-identity diff --git a/extra/modules/optable-targeting/pom.xml b/extra/modules/optable-targeting/pom.xml index 66ed6f1d1ca..dee6e27b582 100644 --- a/extra/modules/optable-targeting/pom.xml +++ b/extra/modules/optable-targeting/pom.xml @@ -5,7 +5,7 @@ org.prebid.server.hooks.modules all-modules - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT optable-targeting diff --git a/extra/modules/pb-ortb2-blocking/pom.xml b/extra/modules/pb-ortb2-blocking/pom.xml index e011878d97a..a5d2c4164f0 100644 --- a/extra/modules/pb-ortb2-blocking/pom.xml +++ b/extra/modules/pb-ortb2-blocking/pom.xml @@ -5,7 +5,7 @@ org.prebid.server.hooks.modules all-modules - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT pb-ortb2-blocking diff --git a/extra/modules/pb-request-correction/pom.xml b/extra/modules/pb-request-correction/pom.xml index 00c00582358..240fb5040d8 100644 --- a/extra/modules/pb-request-correction/pom.xml +++ b/extra/modules/pb-request-correction/pom.xml @@ -5,7 +5,7 @@ org.prebid.server.hooks.modules all-modules - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT pb-request-correction diff --git a/extra/modules/pb-response-correction/pom.xml b/extra/modules/pb-response-correction/pom.xml index da76a6b4a5b..896d0e28eed 100644 --- a/extra/modules/pb-response-correction/pom.xml +++ b/extra/modules/pb-response-correction/pom.xml @@ -5,7 +5,7 @@ org.prebid.server.hooks.modules all-modules - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT pb-response-correction diff --git a/extra/modules/pb-richmedia-filter/pom.xml b/extra/modules/pb-richmedia-filter/pom.xml index af87c7df21a..0097c1eed94 100644 --- a/extra/modules/pb-richmedia-filter/pom.xml +++ b/extra/modules/pb-richmedia-filter/pom.xml @@ -5,7 +5,7 @@ org.prebid.server.hooks.modules all-modules - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT pb-richmedia-filter diff --git a/extra/modules/pb-rule-engine/pom.xml b/extra/modules/pb-rule-engine/pom.xml index 2ba24bb41bf..47300ceaa93 100644 --- a/extra/modules/pb-rule-engine/pom.xml +++ b/extra/modules/pb-rule-engine/pom.xml @@ -5,7 +5,7 @@ org.prebid.server.hooks.modules all-modules - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT pb-rule-engine diff --git a/extra/modules/pom.xml b/extra/modules/pom.xml index b9844fcea43..5b9b2eacc90 100644 --- a/extra/modules/pom.xml +++ b/extra/modules/pom.xml @@ -5,7 +5,7 @@ org.prebid prebid-server-aggregator - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT ../../extra/pom.xml @@ -29,6 +29,7 @@ optable-targeting wurfl-devicedetection live-intent-omni-channel-identity + intentiq-identity diff --git a/extra/modules/wurfl-devicedetection/pom.xml b/extra/modules/wurfl-devicedetection/pom.xml index 4a01dbf53fb..a5dc346385f 100644 --- a/extra/modules/wurfl-devicedetection/pom.xml +++ b/extra/modules/wurfl-devicedetection/pom.xml @@ -5,7 +5,7 @@ org.prebid.server.hooks.modules all-modules - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT wurfl-devicedetection diff --git a/extra/pom.xml b/extra/pom.xml index 4d058c0ed82..b8354272c41 100644 --- a/extra/pom.xml +++ b/extra/pom.xml @@ -4,7 +4,7 @@ org.prebid prebid-server-aggregator - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT pom diff --git a/pom.xml b/pom.xml index 8b908fe1fea..81fbc826e68 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.prebid prebid-server-aggregator - 4.1.0-SNAPSHOT + 4.2.0-SNAPSHOT extra/pom.xml diff --git a/sample/configs/prebid-config-with-intentiq.yaml b/sample/configs/prebid-config-with-intentiq.yaml new file mode 100644 index 00000000000..f35a0f39e53 --- /dev/null +++ b/sample/configs/prebid-config-with-intentiq.yaml @@ -0,0 +1,106 @@ +status-response: "ok" +adapters: + generic: + enabled: true + endpoint: http://localhost:9099/bidder + appnexus: + enabled: true + ix: + enabled: true + openx: + enabled: true + pubmatic: + enabled: true + rubicon: + enabled: true +metrics: + prefix: prebid + # JVM/system health (free memory, GC) is collected at the deployment level (JMX/process exporter), + # not by this module — keeps the shippable module free of server-level concerns. + prometheus: + enabled: true + port: 9090 + namespace: prebid + custom-labels-enabled: false +cache: + scheme: http + host: localhost + path: /cache + query: uuid= +settings: + enforce-valid-account: false + generate-storedrequest-bidrequest-id: true + filesystem: + settings-filename: sample/configs/sample-app-settings.yaml + stored-requests-dir: sample + stored-imps-dir: sample + stored-responses-dir: sample/stored + categories-dir: +gdpr: + default-value: 1 + vendorlist: + v2: + cache-dir: /var/tmp/vendor2 + v3: + cache-dir: /var/tmp/vendor3 +admin-endpoints: + logging-changelevel: + enabled: true + path: /logging/changelevel + on-application-port: true + protected: false +hooks: + intentiq-identity: + enabled: true + host-execution-plan: > + { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "processed-auction-request": { + "groups": [ + { + "timeout": 6000, + "hook-sequence": [ + { + "module-code": "intentiq-identity", + "hook-impl-code": "intentiq-identity-processed-auction-request-hook" + } + ] + } + ] + }, + "auction-response": { + "groups": [ + { + "timeout": 6000, + "hook-sequence": [ + { + "module-code": "intentiq-identity", + "hook-impl-code": "intentiq-identity-auction-response-hook" + } + ] + } + ] + } + } + } + } + } + modules: + intentiq-identity: + api-endpoint: http://127.0.0.1:9098/profiles_engine/ProfilesEngineServlet + reports-endpoint: http://127.0.0.1:9098/profiles_engine/ProfilesEngineServlet + partner-id: "383342646" + timeout: 6000 + cache-max-size: 100000 + cache: + enabled: true + ttlseconds: 43200 + # dev: keep the in-progress marker short so a timed-out VR retries soon instead of + # poisoning the device key for 30 min (default 1800s) when the VPN is slow/flapping. + in-progress-ttl-seconds: 30 + redis: + host: 127.0.0.1 + port: 6379 + password: diff --git a/src/main/java/org/prebid/server/auction/ExchangeService.java b/src/main/java/org/prebid/server/auction/ExchangeService.java index c7258a8faa7..afd05e3a1c4 100644 --- a/src/main/java/org/prebid/server/auction/ExchangeService.java +++ b/src/main/java/org/prebid/server/auction/ExchangeService.java @@ -1473,6 +1473,7 @@ private List updateResponsesMetrics(List { + + private static final TypeReference> TYPE_REFERENCE = new TypeReference<>() { + }; + + private static final String DEFAULT_CURRENCY = "EUR"; + private static final Pattern VAST_MARKUP = Pattern.compile("<\\s*VAST[\\s/>]", Pattern.CASE_INSENSITIVE); + + private final String endpointUrl; + private final JacksonMapper mapper; + + public AdswagBidder(String endpointUrl, JacksonMapper mapper) { + this.endpointUrl = HttpUtil.validateUrl(Objects.requireNonNull(endpointUrl)); + this.mapper = Objects.requireNonNull(mapper); + } + + @Override + public Result>> makeHttpRequests(BidRequest request) { + final Map> impsByPublisher = new LinkedHashMap<>(); + final List errors = new ArrayList<>(); + + for (Imp imp : request.getImp()) { + final ExtImpAdswag extImpAdswag; + try { + extImpAdswag = parseImpExt(imp); + validateImpExt(extImpAdswag, imp.getId()); + } catch (PreBidException e) { + errors.add(BidderError.badInput(e.getMessage())); + continue; + } + + impsByPublisher + .computeIfAbsent(extImpAdswag.getPublisherId(), _ -> new ArrayList<>()) + .add(modifyImp(imp, extImpAdswag.getPlacementId())); + } + + if (impsByPublisher.isEmpty()) { + return Result.withErrors(errors); + } + + if (request.getSite() == null && request.getApp() == null) { + errors.add(BidderError.badInput("request must contain either site or app")); + return Result.withErrors(errors); + } + + final List> httpRequests = impsByPublisher.entrySet().stream() + .map(group -> modifyBidRequest(request, group.getKey(), group.getValue())) + .map(modifiedRequest -> BidderUtil.defaultRequest(modifiedRequest, endpointUrl, mapper)) + .toList(); + + return Result.of(httpRequests, errors); + } + + private ExtImpAdswag parseImpExt(Imp imp) { + try { + return mapper.mapper().convertValue(imp.getExt(), TYPE_REFERENCE).getBidder(); + } catch (IllegalArgumentException e) { + throw new PreBidException("Error parsing imp.ext for impression " + imp.getId()); + } + } + + private static void validateImpExt(ExtImpAdswag extImpAdswag, String impId) { + if (StringUtils.isBlank(extImpAdswag.getPublisherId())) { + throw new PreBidException("missing publisherId for imp " + impId); + } + } + + private static Imp modifyImp(Imp imp, String placementId) { + final ObjectNode modifiedExt = imp.getExt().deepCopy(); + modifiedExt.remove("bidder"); + modifiedExt.remove("prebid"); + if (StringUtils.isNotBlank(placementId)) { + modifiedExt.putObject("adswag").put("placement_id", placementId); + } + + return imp.toBuilder().ext(modifiedExt.isEmpty() ? null : modifiedExt).build(); + } + + private static BidRequest modifyBidRequest(BidRequest request, String publisherId, List modifiedImps) { + return request.toBuilder() + .site(modifySite(request.getSite(), publisherId)) + .app(modifyApp(request.getApp(), publisherId)) + .imp(modifiedImps) + .build(); + } + + private static Site modifySite(Site site, String publisherId) { + return site != null + ? site.toBuilder().publisher(modifyPublisher(site.getPublisher(), publisherId)).build() + : null; + } + + private static App modifyApp(App app, String publisherId) { + return app != null + ? app.toBuilder().publisher(modifyPublisher(app.getPublisher(), publisherId)).build() + : null; + } + + private static Publisher modifyPublisher(Publisher publisher, String publisherId) { + return Optional.ofNullable(publisher) + .map(Publisher::toBuilder) + .orElseGet(Publisher::builder) + .id(publisherId) + .build(); + } + + @Override + public final Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + final String body = httpCall.getResponse().getBody(); + if (StringUtils.isBlank(body)) { + return Result.empty(); + } + + try { + final BidResponse bidResponse = mapper.decodeValue(body, BidResponse.class); + final List errors = new ArrayList<>(); + final List bids = extractBids(httpCall.getRequest().getPayload(), bidResponse, errors); + return Result.of(bids, errors); + } catch (DecodeException e) { + return Result.withError(BidderError.badServerResponse(e.getMessage())); + } + } + + private List extractBids(BidRequest bidRequest, BidResponse bidResponse, List errors) { + if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) { + return Collections.emptyList(); + } + + final String currency = StringUtils.defaultIfBlank(bidResponse.getCur(), DEFAULT_CURRENCY); + return bidResponse.getSeatbid().stream() + .filter(Objects::nonNull) + .map(SeatBid::getBid) + .filter(Objects::nonNull) + .flatMap(List::stream) + .filter(Objects::nonNull) + .map(bid -> makeBidderBid(bid, bidRequest.getImp(), currency, errors)) + .filter(Objects::nonNull) + .toList(); + } + + private BidderBid makeBidderBid(Bid bid, List imps, String currency, List errors) { + final Imp imp = findImp(imps, bid.getImpid()); + if (imp == null) { + errors.add(BidderError.badServerResponse( + "bid %s references unknown imp %s".formatted(bid.getId(), bid.getImpid()))); + return null; + } + + final String serveUrl = serveUrl(bid); + if (StringUtils.isBlank(bid.getAdm()) && StringUtils.isBlank(serveUrl)) { + errors.add(BidderError.badServerResponse( + "bid %s has neither adm nor ext.adswag.serve_url".formatted(bid.getId()))); + return null; + } + + final BidType bidType; + try { + bidType = resolveBidType(imp, bid); + } catch (PreBidException e) { + errors.add(BidderError.badServerResponse(e.getMessage())); + return null; + } + + final Bid modifiedBid = switch (bidType) { + case banner -> makeBannerBid(bid, serveUrl, imp.getBanner()); + case video -> makeVideoBid(bid, serveUrl, imp.getVideo()); + case audio -> makeAudioBid(bid, serveUrl); + // should never happen + default -> throw new AssertionError(); + }; + + return BidderBid.of(modifiedBid, bidType, currency); + } + + private static Imp findImp(List imps, String impId) { + return imps.stream() + .filter(imp -> Objects.equals(imp.getId(), impId)) + .findFirst() + .orElse(null); + } + + private static String serveUrl(Bid bid) { + final JsonNode serveUrlNode = bid.getExt() != null + ? bid.getExt().path("adswag").path("serve_url") + : null; + return serveUrlNode != null && serveUrlNode.isTextual() ? serveUrlNode.textValue() : null; + } + + private static BidType resolveBidType(Imp imp, Bid bid) { + final Integer mtype = bid.getMtype(); + if (mtype != null) { + return switch (mtype) { + case 1 -> BidType.banner; + case 2 -> BidType.video; + case 3 -> BidType.audio; + default -> throw new PreBidException( + "unsupported bid.mtype %d for impression %s".formatted(mtype, bid.getImpid())); + }; + } + + final boolean isVast = bid.getAdm() != null && VAST_MARKUP.matcher(bid.getAdm()).find(); + if (imp.getAudio() != null && (isVast || imp.getBanner() == null)) { + return BidType.audio; + } + if (imp.getVideo() != null && (isVast || imp.getBanner() == null)) { + return BidType.video; + } + if (imp.getBanner() != null) { + return BidType.banner; + } + throw new PreBidException("unable to resolve media type for impression " + bid.getImpid()); + } + + private static Bid makeBannerBid(Bid bid, String serveUrl, Banner banner) { + final Format size = bannerSize(banner); + final boolean updateSize = hasNoSize(bid) && size != null; + + return bid.toBuilder() + .adm(StringUtils.isBlank(bid.getAdm()) ? iframeMarkup(serveUrl, size) : bid.getAdm()) + .w(updateSize ? size.getW() : bid.getW()) + .h(updateSize ? size.getH() : bid.getH()) + .mtype(1) + .build(); + } + + private static Format bannerSize(Banner banner) { + if (banner == null) { + return null; + } + if (CollectionUtils.isNotEmpty(banner.getFormat())) { + return banner.getFormat().getFirst(); + } + if (banner.getW() != null && banner.getH() != null) { + return Format.builder().w(banner.getW()).h(banner.getH()).build(); + } + return null; + } + + private static String iframeMarkup(String serveUrl, Format size) { + final StringBuilder markup = new StringBuilder() + .append(""""); + + return markup.toString(); + } + + // Mirrors Go's html.EscapeString so both server adapters emit identical markup. + private static String escapeHtmlAttribute(String value) { + return value + .replace("&", "&") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } + + private static boolean hasNoSize(Bid bid) { + return bid.getW() == null || bid.getW() <= 0 || bid.getH() == null || bid.getH() <= 0; + } + + private static Bid makeVideoBid(Bid bid, String serveUrl, Video video) { + final boolean updateSize = hasNoSize(bid) && video != null && video.getW() != null && video.getH() != null; + return bid.toBuilder() + .nurl(StringUtils.isBlank(bid.getAdm()) ? serveUrl : bid.getNurl()) + .w(updateSize ? video.getW() : bid.getW()) + .h(updateSize ? video.getH() : bid.getH()) + .mtype(2) + .build(); + } + + private static Bid makeAudioBid(Bid bid, String serveUrl) { + return bid.toBuilder() + .nurl(StringUtils.isBlank(bid.getAdm()) ? serveUrl : bid.getNurl()) + .mtype(3) + .build(); + } +} diff --git a/src/main/java/org/prebid/server/bidder/bidwave/BidwaveBidder.java b/src/main/java/org/prebid/server/bidder/bidwave/BidwaveBidder.java new file mode 100644 index 00000000000..ab602b009d3 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/bidwave/BidwaveBidder.java @@ -0,0 +1,201 @@ +package org.prebid.server.bidder.bidwave; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import org.apache.commons.collections4.CollectionUtils; +import org.prebid.server.bidder.Bidder; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.Price; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.currency.CurrencyConversionService; +import org.prebid.server.exception.PreBidException; +import org.prebid.server.json.DecodeException; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.bidwave.ExtImpBidwave; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.util.BidderUtil; +import org.prebid.server.util.HttpUtil; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class BidwaveBidder implements Bidder { + + private static final String BIDWAVE_EXT = "bidwave"; + private static final String PUBLISHER_ID_EXT = "pid"; + private static final String BIDDER_CURRENCY = "USD"; + private static final List DEFAULT_CURRENCY = Collections.singletonList(BIDDER_CURRENCY); + private static final TypeReference> BIDWAVE_EXT_TYPE_REFERENCE = + new TypeReference<>() { + }; + + private final String endpointUrl; + private final CurrencyConversionService currencyConversionService; + private final JacksonMapper mapper; + + public BidwaveBidder(String endpointUrl, + CurrencyConversionService currencyConversionService, + JacksonMapper mapper) { + + this.endpointUrl = HttpUtil.validateUrl(Objects.requireNonNull(endpointUrl)); + this.currencyConversionService = Objects.requireNonNull(currencyConversionService); + this.mapper = Objects.requireNonNull(mapper); + } + + @Override + public Result>> makeHttpRequests(BidRequest bidRequest) { + final List errors = new ArrayList<>(); + final Map> impsByPublisherId = new LinkedHashMap<>(); + + for (Imp imp : bidRequest.getImp()) { + try { + final String publisherId = parsePublisherId(imp); + final Imp modifiedImp = modifyImpCurrency(imp, bidRequest); + impsByPublisherId.computeIfAbsent(publisherId, ignored -> new ArrayList<>()).add(modifiedImp); + } catch (PreBidException e) { + errors.add(BidderError.badInput(e.getMessage())); + } + } + + final List> requests = impsByPublisherId.entrySet().stream() + .map(entry -> BidderUtil.defaultRequest( + createRequest(bidRequest, entry.getValue(), entry.getKey()), endpointUrl, mapper)) + .toList(); + + return Result.of(requests, errors); + } + + private String parsePublisherId(Imp imp) { + final ExtImpBidwave extImpBidwave; + try { + extImpBidwave = mapper.mapper().convertValue(imp.getExt(), BIDWAVE_EXT_TYPE_REFERENCE).getBidder(); + } catch (IllegalArgumentException e) { + throw new PreBidException("Invalid imp.ext for impression %s. Error Information: %s" + .formatted(imp.getId(), e.getMessage())); + } + + return extImpBidwave.getPublisherId(); + } + + private Imp modifyImpCurrency(Imp imp, BidRequest bidRequest) { + final Price initialBidFloorPrice = Price.of(imp.getBidfloorcur(), imp.getBidfloor()); + if (!BidderUtil.shouldConvertBidFloor(initialBidFloorPrice, BIDDER_CURRENCY)) { + return imp; + } + + try { + final BigDecimal convertedBidFloor = currencyConversionService.convertCurrency( + initialBidFloorPrice.getValue(), + bidRequest, + initialBidFloorPrice.getCurrency(), + BIDDER_CURRENCY); + return imp.toBuilder() + .bidfloor(convertedBidFloor) + .bidfloorcur(BIDDER_CURRENCY) + .build(); + } catch (PreBidException e) { + throw new PreBidException( + "expected currency USD for bid floor; unable to convert from %s for imp `%s`" + .formatted(initialBidFloorPrice.getCurrency(), imp.getId())); + } + } + + private BidRequest createRequest(BidRequest bidRequest, List imps, String publisherId) { + return bidRequest.toBuilder() + .imp(imps) + .cur(DEFAULT_CURRENCY) + .ext(createRequestExt(bidRequest.getExt(), publisherId)) + .build(); + } + + private ExtRequest createRequestExt(ExtRequest requestExt, String publisherId) { + final ExtRequest updatedExt = ExtRequest.of(requestExt != null ? requestExt.getPrebid() : null); + if (requestExt != null) { + mapper.fillExtension(updatedExt, requestExt); + } + + final ObjectNode bidwaveExt = mapper.mapper().createObjectNode().put(PUBLISHER_ID_EXT, publisherId); + updatedExt.addProperty(BIDWAVE_EXT, bidwaveExt); + return updatedExt; + } + + @Override + public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + final BidResponse bidResponse; + try { + bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); + } catch (DecodeException e) { + return Result.withError(BidderError.badServerResponse(e.getMessage())); + } + + final List errors = new ArrayList<>(); + final List bids = extractBids(bidResponse, errors); + return Result.of(bids, errors); + } + + private static List extractBids(BidResponse bidResponse, + List errors) { + + if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) { + return Collections.emptyList(); + } + + final String currency = bidResponse.getCur(); + + return bidResponse.getSeatbid().stream() + .filter(Objects::nonNull) + .map(SeatBid::getBid) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .filter(Objects::nonNull) + .map(bid -> makeBidderBid(bid, currency, errors)) + .filter(Objects::nonNull) + .toList(); + } + + private static BidderBid makeBidderBid(Bid bid, + String currency, + List errors) { + + final BidType bidType; + try { + bidType = resolveBidType(bid); + } catch (PreBidException e) { + errors.add(BidderError.badServerResponse(e.getMessage())); + return null; + } + + return BidderBid.of(bid, bidType, currency); + } + + private static BidType resolveBidType(Bid bid) { + final Integer markupType = bid.getMtype(); + if (markupType == null) { + throw new PreBidException("Bid must have non-zero MType for impression with ID: \"%s\"" + .formatted(bid.getImpid())); + } + + return switch (markupType) { + case 1 -> BidType.banner; + case 2 -> BidType.video; + default -> throw new PreBidException("Unsupported MType %d for impression with ID: \"%s\"" + .formatted(markupType, bid.getImpid())); + }; + } +} diff --git a/src/main/java/org/prebid/server/bidder/connectad/ConnectAdBidder.java b/src/main/java/org/prebid/server/bidder/connectad/ConnectAdBidder.java index 4c64992184b..163963a325e 100644 --- a/src/main/java/org/prebid/server/bidder/connectad/ConnectAdBidder.java +++ b/src/main/java/org/prebid/server/bidder/connectad/ConnectAdBidder.java @@ -1,12 +1,15 @@ package org.prebid.server.bidder.connectad; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.iab.openrtb.request.Banner; import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.request.Device; import com.iab.openrtb.request.Format; import com.iab.openrtb.request.Imp; import com.iab.openrtb.request.Site; +import com.iab.openrtb.response.Bid; import com.iab.openrtb.response.BidResponse; import com.iab.openrtb.response.SeatBid; import io.vertx.core.MultiMap; @@ -31,13 +34,19 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; public class ConnectAdBidder implements Bidder { private static final TypeReference> CONNECTAD_EXT_TYPE_REFERENCE = new TypeReference<>() { }; + private static final String BIDDER_EXT_KEY = "bidder"; + private static final String NETWORK_ID_FIELD = "networkId"; + private static final String SITE_ID_FIELD = "siteId"; private static final String HTTPS_PREFIX = "https"; private final String endpointUrl; @@ -58,7 +67,7 @@ public Result>> makeHttpRequests(BidRequest request for (Imp imp : request.getImp()) { try { final ExtImpConnectAd impExt = parseImpExt(imp); - final Imp updatedImp = updateImp(imp, secure, impExt.getSiteId(), impExt.getBidFloor()); + final Imp updatedImp = updateImp(imp, secure, impExt); processedImps.add(updatedImp); } catch (PreBidException e) { errors.add(BidderError.badInput(e.getMessage())); @@ -91,27 +100,40 @@ private ExtImpConnectAd parseImpExt(Imp imp) { } catch (IllegalArgumentException e) { throw new PreBidException("Impression id=%s, has invalid Ext".formatted(imp.getId())); } - final String siteId = extImpConnectAd.getSiteId(); - if (siteId == null) { + if (extImpConnectAd.getSiteId() == null) { throw new PreBidException("Impression id=%s, has no siteId present".formatted(imp.getId())); } return extImpConnectAd; } - private Imp updateImp(Imp imp, Integer secure, String siteId, BigDecimal bidFloor) { + private static Imp updateImp(Imp imp, int secure, ExtImpConnectAd extImpConnectAd) { + final BigDecimal bidFloor = extImpConnectAd.getBidFloor(); final boolean isValidBidFloor = BidderUtil.isValidPrice(bidFloor); return imp.toBuilder() .banner(updateBanner(imp.getBanner())) - .tagid(siteId) + .tagid(String.valueOf(extImpConnectAd.getSiteId())) .secure(secure) .bidfloor(isValidBidFloor ? bidFloor : imp.getBidfloor()) .bidfloorcur(isValidBidFloor ? "USD" : imp.getBidfloorcur()) + .ext(modifyImpExt(imp.getExt())) .build(); } + private static ObjectNode modifyImpExt(ObjectNode impExt) { + final ObjectNode modifiedExt = impExt.deepCopy(); + final JsonNode bidder = modifiedExt.get(BIDDER_EXT_KEY); + if (bidder.has(NETWORK_ID_FIELD)) { + modifiedExt.set(NETWORK_ID_FIELD, bidder.get(NETWORK_ID_FIELD)); + } + if (bidder.has(SITE_ID_FIELD)) { + modifiedExt.set(SITE_ID_FIELD, bidder.get(SITE_ID_FIELD)); + } + return modifiedExt; + } + private static Banner updateBanner(Banner banner) { if (banner == null) { - throw new PreBidException("We need a Banner Object in the request"); + return null; } if (banner.getW() != null || banner.getH() != null) { @@ -153,24 +175,42 @@ private static MultiMap resolveHeaders(Device device) { public final Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { try { final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); - return Result.withValues(extractBids(bidResponse)); + return Result.withValues(extractBids(bidResponse, bidRequest)); } catch (DecodeException | PreBidException e) { return Result.withError(BidderError.badServerResponse(e.getMessage())); } } - private List extractBids(BidResponse bidResponse) { + private List extractBids(BidResponse bidResponse, BidRequest bidRequest) { if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) { return Collections.emptyList(); } + final Map impIdToImp = bidRequest.getImp().stream() + .collect(Collectors.toMap(Imp::getId, Function.identity())); + return bidResponse.getSeatbid().stream() .filter(Objects::nonNull) .map(SeatBid::getBid) .filter(Objects::nonNull) .flatMap(Collection::stream) .filter(Objects::nonNull) - .map(bid -> BidderBid.of(bid, BidType.banner, bidResponse.getCur())) + .map(bid -> BidderBid.of(bid, getBidType(bid, impIdToImp), bidResponse.getCur())) .toList(); } + + private static BidType getBidType(Bid bid, Map impIdToImp) { + final Integer mType = bid.getMtype(); + if (mType == null) { + return BidderUtil.getBidType(bid, impIdToImp); + } + + return switch (mType) { + case 1 -> BidType.banner; + case 2 -> BidType.video; + case 3 -> BidType.audio; + case 4 -> BidType.xNative; + default -> BidType.banner; + }; + } } diff --git a/src/main/java/org/prebid/server/bidder/floxis/FloxisBidder.java b/src/main/java/org/prebid/server/bidder/floxis/FloxisBidder.java new file mode 100644 index 00000000000..7e51b073b12 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/floxis/FloxisBidder.java @@ -0,0 +1,207 @@ +package org.prebid.server.bidder.floxis; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.bidder.Bidder; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.exception.PreBidException; +import org.prebid.server.json.DecodeException; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.floxis.ExtImpFloxis; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.util.BidderUtil; +import org.prebid.server.util.Uri; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +public class FloxisBidder implements Bidder { + + private static final TypeReference> FLOXIS_EXT_TYPE_REFERENCE = + new TypeReference<>() { + }; + + private static final String HOST_MACRO = "Host"; + private static final String SEAT_MACRO = "SeatId"; + + private static final String DEFAULT_REGION = "us-e"; + private static final String DEFAULT_PARTNER = "floxis"; + + private final Uri endpointUrl; + private final JacksonMapper mapper; + + public FloxisBidder(String endpointUrl, JacksonMapper mapper) { + this.endpointUrl = Uri.of(endpointUrl); + this.mapper = Objects.requireNonNull(mapper); + } + + @Override + public Result>> makeHttpRequests(BidRequest request) { + final List errors = new ArrayList<>(); + final Map> impsByHost = new LinkedHashMap<>(); + + for (Imp imp : request.getImp()) { + final ExtImpFloxis impExt; + try { + impExt = parseImpExt(imp); + } catch (PreBidException e) { + errors.add(BidderError.badInput(e.getMessage())); + continue; + } + + impsByHost.computeIfAbsent( + new HostId(impExt.getSeat(), resolveBidHost(impExt)), key -> new ArrayList<>()) + .add(imp); + } + + final List> httpRequests = impsByHost.entrySet().stream() + .map(entry -> BidderUtil.defaultRequest( + request.toBuilder().imp(entry.getValue()).build(), + resolveUrl(entry.getKey()), + mapper)) + .toList(); + + return Result.of(httpRequests, errors); + } + + private ExtImpFloxis parseImpExt(Imp imp) { + final ExtImpFloxis impExt; + try { + impExt = mapper.mapper().convertValue(imp.getExt(), FLOXIS_EXT_TYPE_REFERENCE).getBidder(); + } catch (IllegalArgumentException e) { + throw new PreBidException("invalid imp.ext.bidder for imp %s: %s".formatted(imp.getId(), e.getMessage())); + } + + final String region = StringUtils.isBlank(impExt.getRegion()) + ? DEFAULT_REGION + : impExt.getRegion().toLowerCase(Locale.ROOT); + final String partner = StringUtils.isBlank(impExt.getPartner()) + ? DEFAULT_PARTNER + : impExt.getPartner().toLowerCase(Locale.ROOT); + + return ExtImpFloxis.of(impExt.getSeat(), region, partner); + } + + private String resolveUrl(HostId hostId) { + return endpointUrl + .replaceMacro(HOST_MACRO, hostId.host()) + .replaceMacro(SEAT_MACRO, hostId.seat()) + .expand(); + } + + private static String resolveBidHost(ExtImpFloxis impExt) { + final String partner = impExt.getPartner(); + final String region = impExt.getRegion(); + return partner.equals(DEFAULT_PARTNER) ? region : partner + "-" + region; + } + + @Override + public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + try { + final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); + final List errors = new ArrayList<>(); + final List bidderBids = extractBids(bidResponse, bidRequest.getImp(), errors); + return Result.of(bidderBids, errors); + } catch (DecodeException e) { + return Result.withError(BidderError.badServerResponse(e.getMessage())); + } + } + + private static List extractBids(BidResponse bidResponse, List imps, List errors) { + if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) { + return Collections.emptyList(); + } + + return bidResponse.getSeatbid().stream() + .filter(Objects::nonNull) + .map(SeatBid::getBid) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .filter(Objects::nonNull) + .map(bid -> makeBidderBid(bid, imps, bidResponse.getCur(), errors)) + .filter(Objects::nonNull) + .toList(); + } + + private static BidderBid makeBidderBid(Bid bid, List imps, String currency, List errors) { + try { + return BidderBid.of(bid, getMediaTypeForBid(imps, bid), currency); + } catch (PreBidException e) { + errors.add(BidderError.badServerResponse(e.getMessage())); + return null; + } + } + + private static BidType getMediaTypeForBid(List imps, Bid bid) { + final Integer mtype = bid.getMtype(); + if (mtype != null && mtype != 0) { + return switch (mtype) { + case 1 -> BidType.banner; + case 2 -> BidType.video; + case 3 -> BidType.audio; + case 4 -> BidType.xNative; + default -> throw new PreBidException( + "unsupported bid.mtype %d for impression %s".formatted(mtype, bid.getImpid())); + }; + } + + final Imp imp = imps.stream() + .filter(currentImp -> Objects.equals(currentImp.getId(), bid.getImpid())) + .findFirst() + .orElseThrow(() -> new PreBidException( + "unable to find impression %s for bid".formatted(bid.getImpid()))); + + if (countFormats(imp) != 1) { + throw new PreBidException( + "unable to resolve a single media type for impression %s; set bid.mtype" + .formatted(bid.getImpid())); + } + + if (imp.getBanner() != null) { + return BidType.banner; + } else if (imp.getVideo() != null) { + return BidType.video; + } else if (imp.getAudio() != null) { + return BidType.audio; + } else { + return BidType.xNative; + } + } + + private static int countFormats(Imp imp) { + int formats = 0; + if (imp.getBanner() != null) { + formats++; + } + if (imp.getVideo() != null) { + formats++; + } + if (imp.getAudio() != null) { + formats++; + } + if (imp.getXNative() != null) { + formats++; + } + return formats; + } + + private record HostId(String seat, String host) { + } +} diff --git a/src/main/java/org/prebid/server/bidder/missena/MissenaAdRequest.java b/src/main/java/org/prebid/server/bidder/missena/MissenaAdRequest.java index 2f9722b2c82..f01c6a81e79 100644 --- a/src/main/java/org/prebid/server/bidder/missena/MissenaAdRequest.java +++ b/src/main/java/org/prebid/server/bidder/missena/MissenaAdRequest.java @@ -2,10 +2,12 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Eid; import lombok.Builder; import lombok.Value; import java.math.BigDecimal; +import java.util.List; @Value @Builder(toBuilder = true) @@ -16,6 +18,11 @@ public class MissenaAdRequest { String currency; + Boolean debug; + + @JsonProperty("userEids") + List userEids; + BigDecimal floor; String floorCurrency; diff --git a/src/main/java/org/prebid/server/bidder/missena/MissenaAdResponse.java b/src/main/java/org/prebid/server/bidder/missena/MissenaAdResponse.java index 6a34c31efbf..acec2fa0199 100644 --- a/src/main/java/org/prebid/server/bidder/missena/MissenaAdResponse.java +++ b/src/main/java/org/prebid/server/bidder/missena/MissenaAdResponse.java @@ -16,6 +16,10 @@ public class MissenaAdResponse { String currency; + Integer width; + + Integer height; + @JsonProperty("requestId") String requestId; } diff --git a/src/main/java/org/prebid/server/bidder/missena/MissenaBidder.java b/src/main/java/org/prebid/server/bidder/missena/MissenaBidder.java index 4cfaa156e9b..1194d57444e 100644 --- a/src/main/java/org/prebid/server/bidder/missena/MissenaBidder.java +++ b/src/main/java/org/prebid/server/bidder/missena/MissenaBidder.java @@ -3,11 +3,14 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Eid; import com.iab.openrtb.request.Imp; import com.iab.openrtb.request.Site; +import com.iab.openrtb.request.User; import com.iab.openrtb.response.Bid; import io.vertx.core.MultiMap; import io.vertx.core.http.HttpMethod; +import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.prebid.server.bidder.Bidder; import org.prebid.server.bidder.model.BidderBid; @@ -21,6 +24,7 @@ import org.prebid.server.json.DecodeException; import org.prebid.server.json.JacksonMapper; import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtUser; import org.prebid.server.proto.openrtb.ext.request.missena.ExtImpMissena; import org.prebid.server.proto.openrtb.ext.response.BidType; import org.prebid.server.util.BidderUtil; @@ -42,6 +46,9 @@ public class MissenaBidder implements Bidder { private static final String USD_CURRENCY = "USD"; private static final String EUR_CURRENCY = "EUR"; private static final String PUBLISHER_ID_MACRO = "PublisherID"; + private static final String VERSION_NAME = "prebid-server-java"; + private static final String VERSION_UNKNOWN = "unknown"; + private static final String VERSION_SEPARATOR = "@"; private final Uri endpointUrl; private final JacksonMapper mapper; @@ -91,22 +98,25 @@ private HttpRequest makeHttpRequest(BidRequest request, Imp im final Price floorInfo = resolveBidFloor(imp, request, requestCurrency); final MissenaUserParams userParams = MissenaUserParams.builder() + .apiKey(extImp.getApiKey()) .formats(extImp.getFormats()) .placement(extImp.getPlacement()) - .testMode(extImp.getTestMode()) + .sample(extImp.getSample()) .settings(extImp.getSettings()) .build(); final MissenaAdRequest missenaAdRequest = MissenaAdRequest.builder() .adUnit(imp.getId()) .currency(requestCurrency) + .debug(Objects.equals(request.getTest(), 1) ? Boolean.TRUE : null) + .userEids(resolveUserEids(request.getUser())) .floor(floorInfo.getValue()) .floorCurrency(floorInfo.getCurrency()) .idempotencyKey(request.getId()) .requestId(request.getId()) .timeout(request.getTmax()) .params(userParams) - .version(prebidVersionProvider.getNameVersionRecord()) + .version(resolveVersion()) .bidRequest(request) .build(); @@ -120,6 +130,17 @@ private HttpRequest makeHttpRequest(BidRequest request, Imp im .build(); } + private String resolveVersion() { + final String version = StringUtils.substringAfter(prebidVersionProvider.getNameVersionRecord(), "/"); + return VERSION_NAME + VERSION_SEPARATOR + (StringUtils.isNotEmpty(version) ? version : VERSION_UNKNOWN); + } + + private static List resolveUserEids(User user) { + final ExtUser extUser = user != null ? user.getExt() : null; + final List eids = extUser != null ? extUser.getEids() : null; + return CollectionUtils.isNotEmpty(eids) ? eids : null; + } + private Price resolveBidFloor(Imp imp, BidRequest bidRequest, String targetCurrency) { final Price initialBidFloorPrice = Price.of(imp.getBidfloorcur(), imp.getBidfloor()); return BidderUtil.isValidPrice(initialBidFloorPrice) @@ -202,6 +223,8 @@ private BidderBid extractBid(BidRequest request, MissenaAdResponse response) { .impid(request.getImp().getFirst().getId()) .adm(response.getAd()) .crid(response.getRequestId()) + .w(response.getWidth()) + .h(response.getHeight()) .build(); return BidderBid.of(bid, BidType.banner, response.getCurrency()); diff --git a/src/main/java/org/prebid/server/bidder/missena/MissenaUserParams.java b/src/main/java/org/prebid/server/bidder/missena/MissenaUserParams.java index e63a704a60c..bc97b9242bc 100644 --- a/src/main/java/org/prebid/server/bidder/missena/MissenaUserParams.java +++ b/src/main/java/org/prebid/server/bidder/missena/MissenaUserParams.java @@ -1,7 +1,7 @@ package org.prebid.server.bidder.missena; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.node.ObjectNode; // Changed import +import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.Builder; import lombok.Value; @@ -11,13 +11,14 @@ @Value public class MissenaUserParams { + @JsonProperty("apiKey") + String apiKey; + List formats; String placement; - @JsonProperty("test") - String testMode; + String sample; ObjectNode settings; } - diff --git a/src/main/java/org/prebid/server/bidder/rtbhouse/RtbhouseBidder.java b/src/main/java/org/prebid/server/bidder/rtbhouse/RtbhouseBidder.java index 8d45320cb9c..19717bbc951 100644 --- a/src/main/java/org/prebid/server/bidder/rtbhouse/RtbhouseBidder.java +++ b/src/main/java/org/prebid/server/bidder/rtbhouse/RtbhouseBidder.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.App; import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.request.Imp; import com.iab.openrtb.request.Publisher; @@ -88,6 +89,7 @@ public Result>> makeHttpRequests(BidRequest bidRequ final BidRequest outgoingRequest = bidRequest.toBuilder() .cur(Collections.singletonList(BIDDER_CURRENCY)) .site(modifySite(bidRequest.getSite(), publisherId)) + .app(modifyApp(bidRequest.getApp(), publisherId)) .imp(modifiedImps) .build(); @@ -178,24 +180,33 @@ private static Optional extractPbadslot(Imp imp) { } private Site modifySite(Site site, String publisherId) { + return Optional.ofNullable(site) + .map(Site::toBuilder) + .map(builder -> builder.publisher(modifyPublisher(site.getPublisher(), publisherId))) + .map(Site.SiteBuilder::build) + .orElse(site); + } + + private App modifyApp(App app, String publisherId) { + return Optional.ofNullable(app) + .map(App::toBuilder) + .map(builder -> builder.publisher(modifyPublisher(app.getPublisher(), publisherId))) + .map(App.AppBuilder::build) + .orElse(app); + } + + private Publisher modifyPublisher(Publisher publisher, String publisherId) { final ObjectNode prebidNode = mapper.mapper().createObjectNode(); prebidNode.put("publisherId", publisherId); final ExtPublisher extPublisher = ExtPublisher.empty(); extPublisher.addProperty("prebid", prebidNode); - final Publisher publisher = Optional.ofNullable(site) - .map(Site::getPublisher) + return Optional.ofNullable(publisher) .map(Publisher::toBuilder) .orElseGet(Publisher::builder) .ext(extPublisher) .build(); - - return Optional.ofNullable(site) - .map(Site::toBuilder) - .orElseGet(Site::builder) - .publisher(publisher) - .build(); } @Override diff --git a/src/main/java/org/prebid/server/bidder/scalibur/ScaliburBidder.java b/src/main/java/org/prebid/server/bidder/scalibur/ScaliburBidder.java new file mode 100644 index 00000000000..f6ae3d0bee7 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/scalibur/ScaliburBidder.java @@ -0,0 +1,319 @@ +package org.prebid.server.bidder.scalibur; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.IntNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Video; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.bidder.Bidder; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.Price; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.currency.CurrencyConversionService; +import org.prebid.server.exception.PreBidException; +import org.prebid.server.json.DecodeException; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.scalibur.ExtImpScalibur; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.util.BidderUtil; +import org.prebid.server.util.Uri; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class ScaliburBidder implements Bidder { + + private static final TypeReference> TYPE_REFERENCE = new TypeReference<>() { + }; + private static final String DEFAULT_HOST_LABEL = "srv"; + private static final String HOST_MACRO = "Host"; + private static final String DEFAULT_BID_CURRENCY = "USD"; + private static final String VAST_XML = """ + + """; + + private final Uri endpointUrl; + private final CurrencyConversionService currencyConversionService; + private final JacksonMapper mapper; + + public ScaliburBidder(String endpointUrl, + CurrencyConversionService currencyConversionService, + JacksonMapper mapper) { + + this.endpointUrl = Uri.of(endpointUrl); + this.currencyConversionService = Objects.requireNonNull(currencyConversionService); + this.mapper = Objects.requireNonNull(mapper); + } + + @Override + public Result>> makeHttpRequests(BidRequest bidRequest) { + final List errors = new ArrayList<>(); + final Map> hostLabelToImps = new HashMap<>(); + final List> httpRequests = new ArrayList<>(); + + for (Imp imp : bidRequest.getImp()) { + try { + final ExtImpScalibur scaliburExt = parseImpExt(imp); + final String hostLabel = StringUtils.defaultIfEmpty(scaliburExt.getHost(), DEFAULT_HOST_LABEL); + final Imp modifiedImp = modifyImp(imp, scaliburExt, bidRequest); + + hostLabelToImps.computeIfAbsent(hostLabel, ignored -> new ArrayList<>()).add(modifiedImp); + } catch (PreBidException e) { + errors.add(BidderError.badInput(e.getMessage())); + } + } + + if (hostLabelToImps.isEmpty()) { + return Result.withErrors(errors); + } + + for (Map.Entry> entry : hostLabelToImps.entrySet()) { + final BidRequest modifiedBidRequest = bidRequest.toBuilder() + .imp(entry.getValue()) + .cur(null) + .ext(isDebugEnabled(bidRequest) ? createDebugExt() : null) + .build(); + httpRequests.add(BidderUtil.defaultRequest( + modifiedBidRequest, + resolveUrl(endpointUrl, entry.getKey()), + mapper)); + } + + return Result.of(httpRequests, errors); + } + + private ExtImpScalibur parseImpExt(Imp imp) { + try { + return mapper.mapper().convertValue(imp.getExt(), TYPE_REFERENCE).getBidder(); + } catch (IllegalArgumentException e) { + throw new PreBidException(e.getMessage()); + } + } + + private Imp modifyImp(Imp imp, + ExtImpScalibur extImpScalibur, + BidRequest bidRequest) { + + final String tagId = StringUtils.defaultIfEmpty(imp.getTagid(), extImpScalibur.getPlacementId()); + + if (StringUtils.isBlank(tagId)) { + throw new PreBidException("imp %s: missing placement; set imp.tagid or the placementId param" + .formatted(imp.getId())); + } + + final Price resolvedBidFloor = resolveBidFloor(imp, extImpScalibur, bidRequest); + + return imp.toBuilder() + .tagid(tagId) + .bidfloor(resolvedBidFloor.getValue()) + .bidfloorcur(resolvedBidFloor.getCurrency()) + .video(resolveVideo(imp.getVideo())) + .ext(resolveImpExt(resolvedBidFloor, imp.getExt())) + .build(); + } + + private Price resolveBidFloor(Imp imp, + ExtImpScalibur extImpScalibur, + BidRequest bidRequest) { + + final BigDecimal extPrice = extImpScalibur.getBidFloor(); + final Price price = BidderUtil.isValidPrice(extPrice) + ? Price.of(StringUtils.defaultIfBlank(extImpScalibur.getBidFloorCur(), imp.getBidfloorcur()), extPrice) + : Price.of(imp.getBidfloorcur(), imp.getBidfloor()); + if (BidderUtil.shouldConvertBidFloor(price, DEFAULT_BID_CURRENCY)) { + return convertBidFloor(price, bidRequest); + } + return Price.of( + StringUtils.defaultIfBlank(price.getCurrency(), DEFAULT_BID_CURRENCY), + price.getValue()); + } + + private Price convertBidFloor(Price bidFloorPrice, BidRequest bidRequest) { + final BigDecimal convertedPrice = currencyConversionService.convertCurrency(bidFloorPrice.getValue(), + bidRequest, + bidFloorPrice.getCurrency(), + DEFAULT_BID_CURRENCY); + + return Price.of(DEFAULT_BID_CURRENCY, convertedPrice); + } + + private ObjectNode resolveImpExt(Price bidFloor, ObjectNode impExt) { + final ObjectNode ext = impExt.get("bidder").deepCopy(); + ext.remove("placementId"); + ext.remove("host"); + if (BidderUtil.isValidPrice(bidFloor)) { + ext.put("bidfloor", bidFloor.getValue()); + ext.put("bidfloorcur", bidFloor.getCurrency()); + } else { + ext.remove("bidfloor"); + ext.remove("bidfloorcur"); + } + final JsonNode gpidNode = impExt.get("gpid"); + if (gpidNode != null && !gpidNode.isNull()) { + ext.set("gpid", gpidNode); + } + return ext; + } + + private Video resolveVideo(Video video) { + if (video == null) { + return null; + } + + final Video.VideoBuilder builder = video.toBuilder(); + + if (CollectionUtils.isEmpty(video.getMimes())) { + builder.mimes(Collections.singletonList("video/mp4")); + } + if (BidderUtil.isNullOrZero(video.getMinduration())) { + builder.minduration(1); + } + if (BidderUtil.isNullOrZero(video.getMaxduration())) { + builder.maxduration(180); + } + if (BidderUtil.isNullOrZero(video.getMaxbitrate())) { + builder.maxbitrate(30_000); + } + if (CollectionUtils.isEmpty(video.getProtocols())) { + builder.protocols(List.of(2, 3, 5, 6)); + } + if (BidderUtil.isNullOrZero(video.getW())) { + builder.w(640); + } + if (BidderUtil.isNullOrZero(video.getH())) { + builder.h(480); + } + if (BidderUtil.isNullOrZero(video.getPlacement())) { + builder.placement(1); + } + if (BidderUtil.isNullOrZero(video.getLinearity())) { + builder.linearity(1); + } + + return builder.build(); + } + + private boolean isDebugEnabled(BidRequest bidRequest) { + if (Objects.equals(bidRequest.getTest(), 1)) { + return true; + } + final ExtRequest ext = bidRequest.getExt(); + if (ext == null || ext.getPrebid() == null) { + return false; + } + return Objects.equals(ext.getPrebid().getDebug(), 1); + } + + private ExtRequest createDebugExt() { + final ExtRequest extRequest = ExtRequest.empty(); + extRequest.addProperty("isDebug", IntNode.valueOf(1)); + return extRequest; + } + + private String resolveUrl(Uri configEndpoint, String externalUrl) { + return configEndpoint.replaceMacro(HOST_MACRO, externalUrl).expand(); + } + + @Override + public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + try { + final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); + return extractBids(httpCall.getRequest().getPayload(), bidResponse); + } catch (DecodeException | PreBidException e) { + return Result.withError(BidderError.badServerResponse(e.getMessage())); + } + } + + private Result> extractBids(BidRequest bidRequest, BidResponse bidResponse) { + if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) { + return Result.empty(); + } + final List errors = new ArrayList<>(); + final List bidderBids = new ArrayList<>(); + for (SeatBid seatBid : bidResponse.getSeatbid()) { + if (CollectionUtils.isEmpty(seatBid.getBid())) { + continue; + } + + for (Bid bid : seatBid.getBid()) { + try { + bidderBids.add(createBidderBid(bid, bidRequest, bidResponse)); + } catch (PreBidException e) { + errors.add(BidderError.badServerResponse(e.getMessage())); + } + } + } + + return Result.of(bidderBids, errors); + } + + private BidderBid createBidderBid(Bid bid, BidRequest bidRequest, BidResponse bidResponse) { + final Imp imp = bidRequest.getImp().stream() + .filter(candidate -> Objects.equals(candidate.getId(), bid.getImpid())) + .findFirst() + .orElseThrow(() -> new PreBidException("Invalid bid imp ID %s".formatted(bid.getImpid()))); + + final BidType bidType = resolveBidType(bid, imp); + final Bid resolvedBid = bidType == BidType.video ? resolveVideoBid(bid) : bid; + final String resolvedCurrency = StringUtils.defaultIfEmpty(bidResponse.getCur(), DEFAULT_BID_CURRENCY); + + return BidderBid.of(resolvedBid, bidType, resolvedCurrency); + } + + private Bid resolveVideoBid(Bid bid) { + if (bid.getExt() == null) { + return bid; + } + + final JsonNode vastXml = bid.getExt().get("vastXml"); + if (isNonEmptyText(vastXml)) { + return bid.toBuilder().adm(vastXml.asText()).build(); + } + + final JsonNode vastUrl = bid.getExt().get("vastUrl"); + if (isNonEmptyText(vastUrl) && StringUtils.isEmpty(bid.getAdm())) { + return bid.toBuilder() + .adm(VAST_XML.formatted(vastUrl.asText()).trim()) + .build(); + } + return bid; + } + + private boolean isNonEmptyText(JsonNode node) { + return node != null && node.isTextual() && StringUtils.isNotEmpty(node.asText()); + } + + private BidType resolveBidType(Bid bid, Imp imp) { + if (Objects.equals(bid.getMtype(), 1)) { + return BidType.banner; + } + if (Objects.equals(bid.getMtype(), 2)) { + return BidType.video; + } + if (imp.getBanner() != null && imp.getVideo() == null) { + return BidType.banner; + } + if (imp.getVideo() != null && imp.getBanner() == null) { + return BidType.video; + } + throw new PreBidException( + "Unsupported or ambiguous media type for bid id=%s".formatted(bid.getId())); + } +} diff --git a/src/main/java/org/prebid/server/bidder/sevio/SevioBidder.java b/src/main/java/org/prebid/server/bidder/sevio/SevioBidder.java new file mode 100644 index 00000000000..434ba516aeb --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/sevio/SevioBidder.java @@ -0,0 +1,96 @@ +package org.prebid.server.bidder.sevio; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import io.vertx.core.MultiMap; +import org.apache.commons.collections4.CollectionUtils; +import org.prebid.server.bidder.Bidder; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.exception.PreBidException; +import org.prebid.server.json.DecodeException; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.util.BidderUtil; +import org.prebid.server.util.HttpUtil; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public class SevioBidder implements Bidder { + + private final String endpointUrl; + private final JacksonMapper mapper; + + public SevioBidder(String endpointUrl, JacksonMapper mapper) { + this.endpointUrl = HttpUtil.validateUrl(Objects.requireNonNull(endpointUrl)); + this.mapper = Objects.requireNonNull(mapper); + } + + @Override + public Result>> makeHttpRequests(BidRequest bidRequest) { + final MultiMap headers = HttpUtil.headers().add(HttpUtil.X_OPENRTB_VERSION_HEADER, "2.6"); + return Result.withValue(BidderUtil.defaultRequest(bidRequest, headers, endpointUrl, mapper)); + } + + @Override + public final Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + try { + final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); + final List errors = new ArrayList<>(); + return Result.of(extractBids(bidResponse, errors), errors); + } catch (DecodeException e) { + return Result.withError(BidderError.badServerResponse(e.getMessage())); + } + } + + private static List extractBids(BidResponse bidResponse, List errors) { + if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) { + return Collections.emptyList(); + } + return bidsFromResponse(bidResponse, errors); + } + + private static List bidsFromResponse(BidResponse bidResponse, List errors) { + return bidResponse.getSeatbid().stream() + .filter(Objects::nonNull) + .map(SeatBid::getBid) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .filter(Objects::nonNull) + .map(bid -> makeBid(bid, bidResponse.getCur(), errors)) + .filter(Objects::nonNull) + .toList(); + } + + private static BidderBid makeBid(Bid bid, String currency, List errors) { + try { + return BidderBid.of(bid, getBidType(bid), currency); + } catch (PreBidException e) { + errors.add(BidderError.badServerResponse(e.getMessage())); + return null; + } + } + + private static BidType getBidType(Bid bid) { + final Integer mtype = bid.getMtype(); + if (mtype == null) { + throw new PreBidException("Missing MType for bid: " + bid.getId()); + } + + return switch (mtype) { + case 1 -> BidType.banner; + case 4 -> BidType.xNative; + default -> throw new PreBidException( + "failed to parse bid mtype (%d) for impression id \"%s\"".formatted(mtype, bid.getImpid())); + }; + } +} diff --git a/src/main/java/org/prebid/server/bidder/synapsehx/SynapseHXBidder.java b/src/main/java/org/prebid/server/bidder/synapsehx/SynapseHXBidder.java new file mode 100644 index 00000000000..3f232210cf7 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/synapsehx/SynapseHXBidder.java @@ -0,0 +1,160 @@ +package org.prebid.server.bidder.synapsehx; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import io.vertx.core.MultiMap; +import org.apache.commons.collections4.CollectionUtils; +import org.prebid.server.bidder.Bidder; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.exception.PreBidException; +import org.prebid.server.json.DecodeException; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.synapsehx.ExtImpSynapseHX; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebid; +import org.prebid.server.util.BidderUtil; +import org.prebid.server.util.HttpUtil; +import org.prebid.server.util.Uri; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public class SynapseHXBidder implements Bidder { + + private static final TypeReference> SYNAPSE_HX_EXT_TYPE_REFERENCE = + new TypeReference<>() { + }; + + private static final TypeReference> EXT_PREBID_TYPE_REFERENCE = + new TypeReference<>() { + }; + + private static final String OPENRTB_VERSION = "2.6"; + + private final Uri endpoint; + private final JacksonMapper mapper; + + public SynapseHXBidder(String endpoint, JacksonMapper mapper) { + this.endpoint = Uri.of(endpoint); + this.mapper = Objects.requireNonNull(mapper); + } + + @Override + public final Result>> makeHttpRequests(BidRequest bidRequest) { + final Imp firstImp = bidRequest.getImp().getFirst(); + final String uri; + + try { + uri = makeUri(getTenantId(firstImp)); + } catch (PreBidException e) { + return Result.withError(BidderError.badInput(e.getMessage())); + } + + final MultiMap headers = HttpUtil.headers(); + headers.add(HttpUtil.X_OPENRTB_VERSION_HEADER, OPENRTB_VERSION); + + return Result.withValue(BidderUtil.defaultRequest(bidRequest, headers, uri, mapper)); + } + + private String getTenantId(Imp firstImp) { + try { + return mapper.mapper() + .convertValue(firstImp.getExt(), SYNAPSE_HX_EXT_TYPE_REFERENCE) + .getBidder() + .getTenantId(); + } catch (IllegalArgumentException e) { + throw new PreBidException("Failed to parse bidder parameters: %s".formatted(e.getMessage())); + } + } + + private String makeUri(String tenantId) { + return endpoint.addQueryParam("pid", tenantId).expand(); + } + + @Override + public final Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + try { + final List bidderErrors = new ArrayList<>(); + final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); + return Result.of(extractBids(bidResponse, bidderErrors), bidderErrors); + } catch (DecodeException e) { + return Result.withError(BidderError.badServerResponse(e.getMessage())); + } + } + + private List extractBids(BidResponse bidResponse, List bidderErrors) { + if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) { + return Collections.emptyList(); + } + return bidsFromResponse(bidResponse, bidderErrors); + } + + private List bidsFromResponse(BidResponse bidResponse, List bidderErrors) { + return bidResponse.getSeatbid().stream() + .filter(Objects::nonNull) + .map(SeatBid::getBid) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .filter(Objects::nonNull) + .map(bid -> makeBidderBid(bid, bidResponse, bidderErrors)) + .filter(Objects::nonNull) + .toList(); + } + + private BidderBid makeBidderBid(Bid bid, BidResponse bidResponse, List bidderErrors) { + final BidType bidType = getBidType(bid, bidderErrors); + return bidType == null + ? null + : BidderBid.of(bid, bidType, bidResponse.getCur()); + } + + private BidType getBidType(Bid bid, List errors) { + final Integer mType = bid.getMtype(); + return mType != null + ? getBidTypeFromMType(mType, errors) + : getBidTypeFromExt(bid.getExt(), errors); + } + + private static BidType getBidTypeFromMType(Integer mType, List errors) { + return switch (mType) { + case 1 -> BidType.banner; + case 2 -> BidType.video; + default -> { + errors.add(BidderError.badServerResponse("Unsupported media type: %d".formatted(mType))); + yield null; + } + }; + } + + private BidType getBidTypeFromExt(ObjectNode bidExt, List errors) { + final BidType bidType = Optional.ofNullable(bidExt) + .map(ext -> mapper.mapper().convertValue(ext, EXT_PREBID_TYPE_REFERENCE)) + .map(ExtPrebid::getPrebid) + .map(ExtBidPrebid::getType) + .orElse(null); + + return switch (bidType) { + case banner -> BidType.banner; + case video -> BidType.video; + case null, default -> { + errors.add(BidderError.badServerResponse("Unsupported media type: %s".formatted(bidType))); + yield null; + } + }; + } + +} diff --git a/src/main/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidder.java b/src/main/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidder.java index ce7e28fa8ec..b40a877242a 100644 --- a/src/main/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidder.java +++ b/src/main/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidder.java @@ -215,6 +215,7 @@ private static BidType getBidType(Bid bid, List errors) { return switch (bid.getMtype()) { case 1 -> BidType.banner; case 2 -> BidType.video; + case 3 -> BidType.audio; case 4 -> BidType.xNative; case null, default -> { errors.add(BidderError.badServerResponse( diff --git a/src/main/java/org/prebid/server/metric/Metrics.java b/src/main/java/org/prebid/server/metric/Metrics.java index 06c31bb1895..693da456828 100644 --- a/src/main/java/org/prebid/server/metric/Metrics.java +++ b/src/main/java/org/prebid/server/metric/Metrics.java @@ -277,6 +277,10 @@ public void updateAccountRequestRejectedByFailedFetch(String accountId) { updateAccountRequestsMetrics(accountId, MetricName.rejected_by_account_fetch_failed); } + public void updateAccountRequestGotbidsMetric(String accountId) { + updateAccountRequestsMetrics(accountId, MetricName.gotbids); + } + private void updateAccountRequestsMetrics(String accountId, MetricName metricName) { forAccount(accountId).requests().incCounter(metricName); } diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/adswag/ExtImpAdswag.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/adswag/ExtImpAdswag.java new file mode 100644 index 00000000000..2de23e9340f --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/adswag/ExtImpAdswag.java @@ -0,0 +1,14 @@ +package org.prebid.server.proto.openrtb.ext.request.adswag; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Value; + +@Value(staticConstructor = "of") +public class ExtImpAdswag { + + @JsonProperty("publisherId") + String publisherId; + + @JsonProperty("placementId") + String placementId; +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/bidwave/ExtImpBidwave.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/bidwave/ExtImpBidwave.java new file mode 100644 index 00000000000..83d38e74702 --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/bidwave/ExtImpBidwave.java @@ -0,0 +1,11 @@ +package org.prebid.server.proto.openrtb.ext.request.bidwave; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Value; + +@Value(staticConstructor = "of") +public class ExtImpBidwave { + + @JsonProperty("publisherId") + String publisherId; +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/connectad/ExtImpConnectAd.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/connectad/ExtImpConnectAd.java index a75c4846f64..cb4e0badd21 100644 --- a/src/main/java/org/prebid/server/proto/openrtb/ext/request/connectad/ExtImpConnectAd.java +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/connectad/ExtImpConnectAd.java @@ -9,10 +9,10 @@ public class ExtImpConnectAd { @JsonProperty("networkId") - String networkId; + Integer networkId; @JsonProperty("siteId") - String siteId; + Integer siteId; @JsonProperty("bidfloor") BigDecimal bidFloor; diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/floxis/ExtImpFloxis.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/floxis/ExtImpFloxis.java new file mode 100644 index 00000000000..e1302852cde --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/floxis/ExtImpFloxis.java @@ -0,0 +1,13 @@ +package org.prebid.server.proto.openrtb.ext.request.floxis; + +import lombok.Value; + +@Value(staticConstructor = "of") +public class ExtImpFloxis { + + String seat; + + String region; + + String partner; +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/missena/ExtImpMissena.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/missena/ExtImpMissena.java index 0c3a8fd08ab..bf11d296186 100644 --- a/src/main/java/org/prebid/server/proto/openrtb/ext/request/missena/ExtImpMissena.java +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/missena/ExtImpMissena.java @@ -18,8 +18,7 @@ public class ExtImpMissena { String placement; - @JsonProperty("test") - String testMode; + String sample; ObjectNode settings; } diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/scalibur/ExtImpScalibur.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/scalibur/ExtImpScalibur.java new file mode 100644 index 00000000000..38f7964df30 --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/scalibur/ExtImpScalibur.java @@ -0,0 +1,21 @@ +package org.prebid.server.proto.openrtb.ext.request.scalibur; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Value; + +import java.math.BigDecimal; + +@Value(staticConstructor = "of") +public class ExtImpScalibur { + + @JsonProperty("placementId") + String placementId; + + @JsonProperty("bidfloor") + BigDecimal bidFloor; + + @JsonProperty("bidfloorcur") + String bidFloorCur; + + String host; +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/synapsehx/ExtImpSynapseHX.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/synapsehx/ExtImpSynapseHX.java new file mode 100644 index 00000000000..83a9d08d9e5 --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/synapsehx/ExtImpSynapseHX.java @@ -0,0 +1,11 @@ +package org.prebid.server.proto.openrtb.ext.request.synapsehx; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Value; + +@Value(staticConstructor = "of") +public class ExtImpSynapseHX { + + @JsonProperty("tenantId") + String tenantId; +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/AdswagConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/AdswagConfiguration.java new file mode 100644 index 00000000000..4a6e4d5bc53 --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/AdswagConfiguration.java @@ -0,0 +1,35 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.adswag.AdswagBidder; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.spring.config.bidder.model.BidderConfigurationProperties; +import org.prebid.server.spring.config.bidder.util.BidderDepsAssembler; +import org.prebid.server.spring.env.YamlPropertySourceFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +@Configuration +@PropertySource(value = "classpath:/bidder-config/adswag.yaml", factory = YamlPropertySourceFactory.class) +public class AdswagConfiguration { + + private static final String BIDDER_NAME = "adswag"; + + @Bean("adswagConfigurationProperties") + @ConfigurationProperties("adapters.adswag") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps adswagBidderDeps(BidderConfigurationProperties adswagConfigurationProperties, + JacksonMapper mapper) { + + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(adswagConfigurationProperties) + .bidderCreator(config -> new AdswagBidder(config.getEndpoint(), mapper)) + .assemble(); + } +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/BidwaveConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/BidwaveConfiguration.java new file mode 100644 index 00000000000..5a49fc29ea6 --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/BidwaveConfiguration.java @@ -0,0 +1,37 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.bidwave.BidwaveBidder; +import org.prebid.server.currency.CurrencyConversionService; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.spring.config.bidder.model.BidderConfigurationProperties; +import org.prebid.server.spring.config.bidder.util.BidderDepsAssembler; +import org.prebid.server.spring.env.YamlPropertySourceFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +@Configuration +@PropertySource(value = "classpath:/bidder-config/bidwave.yaml", factory = YamlPropertySourceFactory.class) +public class BidwaveConfiguration { + + private static final String BIDDER_NAME = "bidwave"; + + @Bean("bidwaveConfigurationProperties") + @ConfigurationProperties("adapters.bidwave") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps bidwaveBidderDeps(BidderConfigurationProperties bidwaveConfigurationProperties, + CurrencyConversionService currencyConversionService, + JacksonMapper mapper) { + + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(bidwaveConfigurationProperties) + .bidderCreator(config -> new BidwaveBidder(config.getEndpoint(), currencyConversionService, mapper)) + .assemble(); + } +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/FloxisConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/FloxisConfiguration.java new file mode 100644 index 00000000000..896ac465fbe --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/FloxisConfiguration.java @@ -0,0 +1,35 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.floxis.FloxisBidder; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.spring.config.bidder.model.BidderConfigurationProperties; +import org.prebid.server.spring.config.bidder.util.BidderDepsAssembler; +import org.prebid.server.spring.env.YamlPropertySourceFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +@Configuration +@PropertySource(value = "classpath:/bidder-config/floxis.yaml", factory = YamlPropertySourceFactory.class) +public class FloxisConfiguration { + + private static final String BIDDER_NAME = "floxis"; + + @Bean("floxisConfigurationProperties") + @ConfigurationProperties("adapters.floxis") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps floxisBidderDeps(BidderConfigurationProperties floxisConfigurationProperties, + JacksonMapper mapper) { + + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(floxisConfigurationProperties) + .bidderCreator(config -> new FloxisBidder(config.getEndpoint(), mapper)) + .assemble(); + } +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/ScaliburConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/ScaliburConfiguration.java new file mode 100644 index 00000000000..4e69427e3ef --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/ScaliburConfiguration.java @@ -0,0 +1,37 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.scalibur.ScaliburBidder; +import org.prebid.server.currency.CurrencyConversionService; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.spring.config.bidder.model.BidderConfigurationProperties; +import org.prebid.server.spring.config.bidder.util.BidderDepsAssembler; +import org.prebid.server.spring.env.YamlPropertySourceFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +@Configuration +@PropertySource(value = "classpath:/bidder-config/scalibur.yaml", factory = YamlPropertySourceFactory.class) +public class ScaliburConfiguration { + + private static final String BIDDER_NAME = "scalibur"; + + @Bean("scaliburConfigurationProperties") + @ConfigurationProperties("adapters.scalibur") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps scaliburBidderDeps(BidderConfigurationProperties scaliburConfigurationProperties, + CurrencyConversionService currencyConversionService, + JacksonMapper mapper) { + + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(scaliburConfigurationProperties) + .bidderCreator(config -> new ScaliburBidder(config.getEndpoint(), currencyConversionService, mapper)) + .assemble(); + } +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/SevioConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/SevioConfiguration.java new file mode 100644 index 00000000000..190886a4d3e --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/SevioConfiguration.java @@ -0,0 +1,34 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.sevio.SevioBidder; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.spring.config.bidder.model.BidderConfigurationProperties; +import org.prebid.server.spring.config.bidder.util.BidderDepsAssembler; +import org.prebid.server.spring.env.YamlPropertySourceFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +@Configuration +@PropertySource(value = "classpath:/bidder-config/sevio.yaml", factory = YamlPropertySourceFactory.class) +public class SevioConfiguration { + + private static final String BIDDER_NAME = "sevio"; + + @Bean("sevioConfigurationProperties") + @ConfigurationProperties("adapters.sevio") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps sevioBidderDeps(BidderConfigurationProperties sevioConfigurationProperties, JacksonMapper mapper) { + + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(sevioConfigurationProperties) + .bidderCreator(config -> new SevioBidder(config.getEndpoint(), mapper)) + .assemble(); + } +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/SynapseHXBidderConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/SynapseHXBidderConfiguration.java new file mode 100644 index 00000000000..d5d87bce0b0 --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/SynapseHXBidderConfiguration.java @@ -0,0 +1,35 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.synapsehx.SynapseHXBidder; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.spring.config.bidder.model.BidderConfigurationProperties; +import org.prebid.server.spring.config.bidder.util.BidderDepsAssembler; +import org.prebid.server.spring.env.YamlPropertySourceFactory; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +@Configuration +@PropertySource(value = "classpath:/bidder-config/synapsehx.yaml", factory = YamlPropertySourceFactory.class) +public class SynapseHXBidderConfiguration { + + private static final String BIDDER_NAME = "synapsehx"; + + @Bean("synapsehxConfigurationProperties") + @ConfigurationProperties("adapters.synapsehx") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps synapsehxBidderDeps(BidderConfigurationProperties synapsehxConfigurationProperties, + JacksonMapper mapper) { + + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(synapsehxConfigurationProperties) + .bidderCreator(config -> new SynapseHXBidder(config.getEndpoint(), mapper)) + .assemble(); + } +} diff --git a/src/main/java/org/prebid/server/validation/ImpValidator.java b/src/main/java/org/prebid/server/validation/ImpValidator.java index c254ac00bfc..7462b1ab3d3 100644 --- a/src/main/java/org/prebid/server/validation/ImpValidator.java +++ b/src/main/java/org/prebid/server/validation/ImpValidator.java @@ -647,8 +647,8 @@ private void validateMetrics(List metrics, String msgPrefix) throws Vali } final Float value = metric.getValue(); - if (value == null || value < 0.0 || value > 1.0) { - throw new ValidationException("%s.metric[%d].value must be in the range [0.0, 1.0]", msgPrefix, i); + if (value == null) { + throw new ValidationException("Missing %s.metric[%d].value", msgPrefix, i); } } } diff --git a/src/main/resources/bidder-config/adagio.yaml b/src/main/resources/bidder-config/adagio.yaml index 9848b3135f2..62915cf545b 100644 --- a/src/main/resources/bidder-config/adagio.yaml +++ b/src/main/resources/bidder-config/adagio.yaml @@ -23,5 +23,5 @@ adapters: usersync: cookie-family-name: adagio iframe: - url: https://u-REGION.4dex.io/pbserver/usync.html?gdpr={gdpr}&gdpr_consent={gdpr_consent}&us_privacy={us_privacy}&gpp={gpp}&&gpp_sid={gpp_sid}&r={redirect_url} + url: https://u-REGION.4dex.io/pbserver/usync.html?gdpr={gdpr}&gdpr_consent={gdpr_consent}&us_privacy={us_privacy}&gpp={gpp}&gpp_sid={gpp_sid}&r={redirect_url} uid-macro: '{UID}' diff --git a/src/main/resources/bidder-config/adswag.yaml b/src/main/resources/bidder-config/adswag.yaml new file mode 100644 index 00000000000..0f40b19e4d2 --- /dev/null +++ b/src/main/resources/bidder-config/adswag.yaml @@ -0,0 +1,26 @@ +adapters: + adswag: + ortb-version: "2.6" + endpoint: https://bid.adswag.ai/prebid/bid + modifying-vast-xml-allowed: false + geoscope: + - EEA + ortb: + multiformat-supported: true + usersync: + cookie-family-name: adswag + redirect: + url: https://ev.adswag.ai/sync/pixel?gdpr={gdpr}&gdpr_consent={gdpr_consent}&us_privacy={us_privacy}&gpp={gpp}&gpp_sid={gpp_sid}&redirect={redirect_url} + uid-macro: '$UID' + meta-info: + maintainer-email: prebid@adswag.ai + app-media-types: + - banner + - video + - audio + site-media-types: + - banner + - video + - audio + supported-vendors: + vendor-id: 1417 diff --git a/src/main/resources/bidder-config/bidwave.yaml b/src/main/resources/bidder-config/bidwave.yaml new file mode 100644 index 00000000000..65bd02fb891 --- /dev/null +++ b/src/main/resources/bidder-config/bidwave.yaml @@ -0,0 +1,14 @@ +adapters: + bidwave: + endpoint: https://rtb.bidwave.net/rtb/v1/bid + ortb-version: "2.6" + meta-info: + maintainer-email: prebid@bidwave.net + app-media-types: + - banner + - video + site-media-types: + - banner + - video + supported-vendors: + vendor-id: 0 diff --git a/src/main/resources/bidder-config/connectad.yaml b/src/main/resources/bidder-config/connectad.yaml index d6f9db7301a..26d10dc57ed 100644 --- a/src/main/resources/bidder-config/connectad.yaml +++ b/src/main/resources/bidder-config/connectad.yaml @@ -2,18 +2,25 @@ adapters: connectad: # Please uncomment the appropriate endpoint URL for your datacenter # Europe - endpoint: "http://bidder.connectad.io/API?src=pbs" + endpoint: "https://bidder.connectad.io/API?src=pbs" + ortb-version: "2.6" # North/South America - # endpoint: "http://bidder-us.connectad.io/API?src=pbs" + # endpoint: "https://bidder-us.connectad.io/API?src=pbs" # APAC - # endpoint: "http://bidder-apac.connectad.io/API?src=pbs" + # endpoint: "https://bidder-apac.connectad.io/API?src=pbs" endpoint-compression: gzip meta-info: maintainer-email: support@connectad.io app-media-types: - banner + - video + - native + - audio site-media-types: - banner + - video + - native + - audio supported-vendors: vendor-id: 138 usersync: diff --git a/src/main/resources/bidder-config/floxis.yaml b/src/main/resources/bidder-config/floxis.yaml new file mode 100644 index 00000000000..a2f058c5e14 --- /dev/null +++ b/src/main/resources/bidder-config/floxis.yaml @@ -0,0 +1,25 @@ +adapters: + floxis: + endpoint: https://{Host}.floxis.tech/pbs?seat={SeatId} + endpoint-compression: gzip + ortb-version: "2.6" + modifying-vast-xml-allowed: false + meta-info: + maintainer-email: prebid@floxis.tech + app-media-types: + - banner + - video + - native + - audio + site-media-types: + - banner + - video + - native + - audio + supported-vendors: + vendor-id: 1609 + usersync: + cookie-family-name: floxis + redirect: + url: "https://px-us-e.floxis.tech/sync?gdpr={gdpr}&gdpr_consent={gdpr_consent}&gpp={gpp}&gpp_sid={gpp_sid}&us_privacy={us_privacy}&dest={redirect_url}" + uid-macro: "${USER_ID}" diff --git a/src/main/resources/bidder-config/scalibur.yaml b/src/main/resources/bidder-config/scalibur.yaml new file mode 100644 index 00000000000..fadbea062f7 --- /dev/null +++ b/src/main/resources/bidder-config/scalibur.yaml @@ -0,0 +1,22 @@ +adapters: + scalibur: + endpoint: http://{Host}.scalibur.io/adserver/ortb?type=prebid-server + geoscope: + - global + ortb-version: "2.6" + endpoint-compression: gzip + meta-info: + maintainer-email: support@scalibur.io + app-media-types: + - banner + - video + site-media-types: + - banner + - video + supported-vendors: + vendor-id: 1471 + usersync: + cookie-family-name: scalibur + iframe: + url: https://srv.scalibur.io/adserver/sync?type=iframe&gdpr={gdpr}&gdpr_consent={gdpr_consent}&us_privacy={us_privacy}&gpp={gpp}&&gpp_sid={gpp_sid}&redirect={redirect_url} + uid-macro: '[PBS_UID]' diff --git a/src/main/resources/bidder-config/sevio.yaml b/src/main/resources/bidder-config/sevio.yaml new file mode 100644 index 00000000000..6bb520c4648 --- /dev/null +++ b/src/main/resources/bidder-config/sevio.yaml @@ -0,0 +1,13 @@ +adapters: + sevio: + endpoint: https://req.adx.ws/rtb + meta-info: + maintainer-email: technical@sevio.com + app-media-types: + - banner + - native + site-media-types: + - banner + - native + supported-vendors: + vendor-id: 1393 diff --git a/src/main/resources/bidder-config/smarthub.yaml b/src/main/resources/bidder-config/smarthub.yaml index 8ed2e05b854..9edde91f97f 100644 --- a/src/main/resources/bidder-config/smarthub.yaml +++ b/src/main/resources/bidder-config/smarthub.yaml @@ -1,45 +1,45 @@ adapters: smarthub: - endpoint: https://prebid.attekmi.com/pbserver?partnerName={Host}&seat={AccountID}&token={SourceId} + endpoint: https://prebid.attekmi.co/pbserver?partnerName={Host}&seat={AccountID}&token={SourceId} aliases: markapp: enabled: false - endpoint: https://markapp-prebid.attekmi.com/pbserver/?seat={AccountID}&token={SourceId} + endpoint: https://markapp-prebid.attekmi.co/pbserver/?seat={AccountID}&token={SourceId} meta-info: vendor-id: 0 jdpmedia: enabled: false - endpoint: https://jdpmedia-prebid.attekmi.com/pbserver/?seat={AccountID}&token={SourceId} + endpoint: https://jdpmedia-prebid.attekmi.co/pbserver/?seat={AccountID}&token={SourceId} meta-info: vendor-id: 0 tredio: enabled: false - endpoint: https://tredio-prebid.attekmi.com/pbserver/?seat={AccountID}&token={SourceId} + endpoint: https://tredio-prebid.attekmi.co/pbserver/?seat={AccountID}&token={SourceId} meta-info: vendor-id: 0 felixads: enabled: false - endpoint: https://felixads-prebid.attekmi.com/pbserver/?seat={AccountID}&token={SourceId} + endpoint: https://felixads-prebid.attekmi.co/pbserver/?seat={AccountID}&token={SourceId} meta-info: vendor-id: 0 jambojar: enabled: false - endpoint: https://jambojar-prebid.attekmi.com/pbserver/?seat={AccountID}&token={SourceId} + endpoint: https://jambojar-prebid.attekmi.co/pbserver/?seat={AccountID}&token={SourceId} meta-info: vendor-id: 0 adinify: enabled: false - endpoint: https://adinify-prebid.attekmi.com/pbserver/?seat={AccountID}&token={SourceId} + endpoint: https://adinify-prebid.attekmi.co/pbserver/?seat={AccountID}&token={SourceId} meta-info: vendor-id: 0 artechnology: enabled: false - endpoint: https://artechnology-prebid.attekmi.com/pbserver/?seat={AccountID}&token={SourceId} + endpoint: https://artechnology-prebid.attekmi.co/pbserver/?seat={AccountID}&token={SourceId} meta-info: vendor-id: 0 addigi: enabled: false - endpoint: https://addigi-prebid.attekmi.com/pbserver/?seat={AccountID}&token={SourceId} + endpoint: https://addigi-prebid.attekmi.co/pbserver/?seat={AccountID}&token={SourceId} meta-info: vendor-id: 0 radiantfusion: diff --git a/src/main/resources/bidder-config/synapsehx.yaml b/src/main/resources/bidder-config/synapsehx.yaml new file mode 100644 index 00000000000..ede5ccf6469 --- /dev/null +++ b/src/main/resources/bidder-config/synapsehx.yaml @@ -0,0 +1,21 @@ +adapters: + synapsehx: + endpoint: https://rtb.hx.compasonline.com/pbs + openrtb-version: 2.6 + meta-info: + maintainer-email: prebid@compas-inc.com + app-media-types: + - banner + - video + site-media-types: + - banner + - video + vendor-id: 0 + usersync: + cookie-family-name: synapsehx + redirect: + url: https://sync.hx.compasonline.com/pbserver/image?gdpr={gdpr}&gdpr_consent={gdpr_consent}&ccpa={us_privacy}&gpp={gpp}&gpp_sid={gpp_sid}&redirect={redirect_url} + uid-macro: "${VISITOR_ID}" + iframe: + url: https://sync.hx.compasonline.com/pbserver/iframe?gdpr={gdpr}&gdpr_consent={gdpr_consent}&ccpa={us_privacy}&gpp={gpp}&gpp_sid={gpp_sid}&redirect={redirect_url} + uid-macro: "${VISITOR_ID}" diff --git a/src/main/resources/bidder-config/teqblaze.yaml b/src/main/resources/bidder-config/teqblaze.yaml index d6c34012379..4c2c4af5a3c 100644 --- a/src/main/resources/bidder-config/teqblaze.yaml +++ b/src/main/resources/bidder-config/teqblaze.yaml @@ -115,6 +115,12 @@ adapters: meta-info: maintainer-email: prebid@adsmovil.com vendor-id: 0 + appMonstaMedia: + enabled: false + endpoint: https://ssp-us.appmonsta.ai/pserver + meta-info: + maintainer-email: media.support@appmonsta.ai + vendor-id: 1283 meta-info: maintainer-email: github@teqblaze.com app-media-types: diff --git a/src/main/resources/bidder-config/thetradedesk.yaml b/src/main/resources/bidder-config/thetradedesk.yaml index 628e3b149fa..421cf00a91f 100644 --- a/src/main/resources/bidder-config/thetradedesk.yaml +++ b/src/main/resources/bidder-config/thetradedesk.yaml @@ -1,6 +1,7 @@ adapters: thetradedesk: endpoint: https://direct.adsrvr.org/bid/bidder/{SupplyId} + ortb-version: "2.6" aliases: ttd: meta-info: @@ -10,10 +11,12 @@ adapters: app-media-types: - banner - video + - audio - native site-media-types: - banner - video + - audio - native supported-vendors: vendor-id: 21 diff --git a/src/main/resources/static/bidder-params/adswag.json b/src/main/resources/static/bidder-params/adswag.json new file mode 100644 index 00000000000..4a3b70230d1 --- /dev/null +++ b/src/main/resources/static/bidder-params/adswag.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Adswag Adapter Params", + "description": "A schema which validates params accepted by the Adswag adapter", + "type": "object", + "properties": { + "publisherId": { + "type": "string", + "minLength": 1, + "description": "Adswag publisher id (issued at onboarding)" + }, + "placementId": { + "type": "string", + "minLength": 1, + "description": "Explicit placement override; omit to let Adswag discover the placement from GPID or the ad unit code" + } + }, + "required": ["publisherId"] +} diff --git a/src/main/resources/static/bidder-params/bidwave.json b/src/main/resources/static/bidder-params/bidwave.json new file mode 100644 index 00000000000..d7edfaed1ad --- /dev/null +++ b/src/main/resources/static/bidder-params/bidwave.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "BidWave Adapter Params", + "description": "A schema which validates params accepted by the BidWave adapter", + "type": "object", + "properties": { + "publisherId": { + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "description": "Publisher ID" + } + }, + "required": [ + "publisherId" + ] +} diff --git a/src/main/resources/static/bidder-params/connectad.json b/src/main/resources/static/bidder-params/connectad.json index e36410928da..5a6f6ec9294 100644 --- a/src/main/resources/static/bidder-params/connectad.json +++ b/src/main/resources/static/bidder-params/connectad.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-04/schema#", - "title": "ConnectAd S2S dapter Params", + "title": "ConnectAd S2S Adapter Params", "description": "A schema which validates params accepted by the ConnectAd Adapter", "type": "object", "properties": { @@ -20,7 +20,11 @@ }, "bidfloor": { "type": "number", - "description": "Requests Floorprice" + "description": "Requested Floorprice" + }, + "endpointUrl": { + "type": "string", + "description": "Client-side only (Prebid.js): override the bid endpoint URL for testing or a custom datacenter. Ignored by Prebid Server, which always uses its configured endpoint." } }, "required": [ diff --git a/src/main/resources/static/bidder-params/floxis.json b/src/main/resources/static/bidder-params/floxis.json new file mode 100644 index 00000000000..e0a64d19e87 --- /dev/null +++ b/src/main/resources/static/bidder-params/floxis.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Floxis Adapter Params", + "description": "A schema which validates params accepted by the Floxis adapter", + "type": "object", + "properties": { + "seat": { + "type": "string", + "minLength": 1, + "description": "The Floxis seat ID this publisher buys through" + }, + "region": { + "type": "string", + "pattern": "^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$", + "description": "The Floxis region (DNS label, interpolated into the bidding host); defaults to us-e when omitted" + }, + "partner": { + "type": "string", + "pattern": "^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$", + "description": "The white-label partner (DNS label, interpolated into the bidding host); defaults to floxis when omitted" + } + }, + "required": ["seat"] +} diff --git a/src/main/resources/static/bidder-params/missena.json b/src/main/resources/static/bidder-params/missena.json index be5217efdb1..69d0291cc36 100644 --- a/src/main/resources/static/bidder-params/missena.json +++ b/src/main/resources/static/bidder-params/missena.json @@ -13,9 +13,9 @@ "type": "string", "description": "Placement Type (Sticky, Header, ...)" }, - "test": { - "type": "string", - "description": "Test Mode" + "sample": { + "type": ["string", "null"], + "description": "Sample" }, "formats": { "type": "array", diff --git a/src/main/resources/static/bidder-params/scalibur.json b/src/main/resources/static/bidder-params/scalibur.json new file mode 100644 index 00000000000..56037ad7e0f --- /dev/null +++ b/src/main/resources/static/bidder-params/scalibur.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Scalibur Adapter Params", + "description": "A valid BidderParams object for the Scalibur adapter. Every impression must resolve a placement: either set placementId here, or set imp.tagid on the ad unit. An impression with neither is not served.", + "type": "object", + "properties": { + "placementId": { + "type": "string", + "description": "Placement id, applied to imp.tagid. Optional: may instead be supplied per ad unit as imp.tagid. At least one source must resolve or the imp is not served.", + "minLength": 1 + }, + "bidfloor": { + "type": "number", + "description": "The minimum price acceptable for a bid", + "minimum": 0 + }, + "bidfloorcur": { + "type": "string", + "description": "currency floor price" + }, + "host": { + "type": "string", + "description": "Subdomain label of the scalibur.io endpoint, for account or environment specific servers. A single RFC 1123 DNS label only, no dots or port; the domain itself is fixed. Defaults to srv. Impressions naming different subdomains are sent as separate requests, one per subdomain.", + "pattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$" + } + } +} diff --git a/src/main/resources/static/bidder-params/sevio.json b/src/main/resources/static/bidder-params/sevio.json new file mode 100644 index 00000000000..da0992cc359 --- /dev/null +++ b/src/main/resources/static/bidder-params/sevio.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Sevio Adapter Params", + "description": "A schema which validates params accepted by the Sevio adapter", + "type": "object", + "properties": { + "placementId": { + "type": "string", + "minLength": 1, + "description": "Placement Id or Zone Id" + } + }, + "required": [ + "placementId" + ] +} diff --git a/src/main/resources/static/bidder-params/synapsehx.json b/src/main/resources/static/bidder-params/synapsehx.json new file mode 100644 index 00000000000..167a0b8f070 --- /dev/null +++ b/src/main/resources/static/bidder-params/synapsehx.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Synapse HX Adapter Params", + "description": "A schema which validates params accepted by the Synapse HX adapter", + "type": "object", + "properties": { + "tenantId": { + "type": "string", + "description": "Synapse HX tenant identifier", + "minLength": 1 + } + }, + "required": ["tenantId"] +} diff --git a/src/test/groovy/org/prebid/server/functional/tests/MetricsSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/MetricsSpec.groovy index ee3e808a56e..8a38ab04e60 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/MetricsSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/MetricsSpec.groovy @@ -9,6 +9,7 @@ import org.prebid.server.functional.model.request.auction.Site import org.prebid.server.functional.model.response.auction.BidResponse import org.prebid.server.functional.service.PrebidServerService +import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR import static org.prebid.server.functional.model.config.AccountMetricsVerbosityLevel.BASIC import static org.prebid.server.functional.model.config.AccountMetricsVerbosityLevel.DETAILED import static org.prebid.server.functional.model.config.AccountMetricsVerbosityLevel.NONE @@ -24,7 +25,7 @@ class MetricsSpec extends BaseSpec { flushMetrics(softPrebidService) } - def "PBS should not populate account metric when verbosity level is none"() { + def "PBS should only populate requests.gotbids account metric when verbosity level is none"() { given: "Default basic BidRequest with generic bidder" def bidRequest = BidRequest.defaultBidRequest @@ -39,7 +40,8 @@ class MetricsSpec extends BaseSpec { then: "account..* metric shouldn't be populated" def metrics = defaultPbsService.sendCollectedMetricsRequest() - assert !metrics.find { it.key.startsWith("account.${accountId}") } + def filteredMetrics = metrics.findAll { it.key != "account.${accountId}.requests.gotbids" } + assert !filteredMetrics.find { it.key.startsWith("account.${accountId}") } } def "PBS should update account..requests metric when verbosity level is basic"() { @@ -94,9 +96,44 @@ class MetricsSpec extends BaseSpec { assert metrics["account.${accountId}.adapter.generic.request_time" as String] == 1 assert metrics["account.${accountId}.adapter.generic.requests.gotbids" as String] == 1 assert metrics["account.${accountId}.requests" as String] == 1 + assert metrics["account.${accountId}.requests.gotbids" as String] == 1 assert metrics["account.${accountId}.requests.type.openrtb2-web" as String] == 1 } + def "PBS should not populate bid-related account metrics for failed bidder response when verbosity level is detailed"() { + given: "Default basic BidRequest with generic bidder" + def bidRequest = BidRequest.defaultBidRequest + + and: "Default basic BidResponse with bid price" + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) + + and: "Set bidder response to internal server error" + bidder.setResponse(bidRequest.id, bidResponse, SC_INTERNAL_SERVER_ERROR) + + and: "Account in the DB" + def accountId = bidRequest.site.publisher.id + def accountMetricsConfig = new AccountConfig(metrics: new AccountMetricsConfig(verbosityLevel: DETAILED)) + def account = new Account(uuid: accountId, config: accountMetricsConfig) + accountDao.save(account) + + when: "PBS processes auction request" + defaultPbsService.sendAuctionRequest(bidRequest) + + then: "General request metrics should be populated" + def metrics = defaultPbsService.sendCollectedMetricsRequest() + assert metrics["account.${accountId}.requests" as String] == 1 + assert metrics["account.${accountId}.requests.type.openrtb2-web" as String] == 1 + + and: "Adapter request time should be populated" + assert metrics["account.${accountId}.adapter.generic.request_time" as String] == 1 + + and: "Bid-related metrics should not be populated due to bidder error" + assert !metrics["account.${accountId}.requests.gotbids" as String] + assert !metrics["account.${accountId}.adapter.generic.requests.gotbids" as String] + assert !metrics["account.${accountId}.adapter.generic.bids_received" as String] + assert !metrics["account.${accountId}.adapter.generic.prices" as String] + } + def "PBS should update hood metrics when bid request contains hood channel type and verbosity level is detailed"() { given: "Default basic BidRequest with generic bidder" def bidRequest = BidRequest.getDefaultBidRequest(DOOH) @@ -183,8 +220,8 @@ class MetricsSpec extends BaseSpec { where: bidRequest << [BidRequest.getDefaultBidRequest(APP).tap { - it.dooh = Dooh.defaultDooh - }, + it.dooh = Dooh.defaultDooh + }, BidRequest.getDefaultBidRequest(APP).tap { it.site = Site.defaultSite }, diff --git a/src/test/java/org/prebid/server/bidder/adswag/AdswagBidderTest.java b/src/test/java/org/prebid/server/bidder/adswag/AdswagBidderTest.java new file mode 100644 index 00000000000..9451efc6247 --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/adswag/AdswagBidderTest.java @@ -0,0 +1,555 @@ +package org.prebid.server.bidder.adswag; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.App; +import com.iab.openrtb.request.Audio; +import com.iab.openrtb.request.Banner; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Format; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Publisher; +import com.iab.openrtb.request.Site; +import com.iab.openrtb.request.Video; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import org.junit.jupiter.api.Test; +import org.prebid.server.VertxTest; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.HttpResponse; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.adswag.ExtImpAdswag; +import org.prebid.server.proto.openrtb.ext.response.BidType; + +import java.math.BigDecimal; +import java.util.List; +import java.util.function.UnaryOperator; + +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static java.util.function.UnaryOperator.identity; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.tuple; + +public class AdswagBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "https://test.endpoint.com/prebid/bid"; + private static final String SERVE_URL = "https://ads.adswag.ai/v1/ad?sc=c2ln&sig=YWJj"; + private static final String VAST_ADM = """ + \ + Adswag\ + \ + """; + private static final String IFRAME_ADM = """ + """; + + private final AdswagBidder target = new AdswagBidder(ENDPOINT_URL, jacksonMapper); + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + assertThatIllegalArgumentException().isThrownBy(() -> new AdswagBidder("invalid_url", jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldReturnErrorIfImpExtCouldNotBeParsed() { + // given + final BidRequest bidRequest = givenBidRequest(givenImp( + imp -> imp.id("imp1").ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode()))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()).startsWith("Error parsing imp.ext for impression imp1"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldReturnErrorIfPublisherIdIsMissing() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("imp1").ext(givenImpExt(" ", "placement")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()).isEqualTo("missing publisherId for imp imp1"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldReturnErrorIfNeitherSiteNorAppIsPresent() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("imp1").ext(givenImpExt("pub-1", null)))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()).isEqualTo("request must contain either site or app"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldPromotePublisherIdAndRewriteImpExt() { + // given + final ObjectNode impExt = givenImpExt("pub-1", "plc-1"); + impExt.put("gpid", "/1111/homepage#div-1"); + final BidRequest bidRequest = givenBidRequest( + request -> request.site(Site.builder() + .publisher(Publisher.builder().name("Example").build()) + .build()), + givenImp(imp -> imp.id("imp1").ext(impExt))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getSite) + .extracting(Site::getPublisher) + .extracting(Publisher::getId, Publisher::getName) + .containsExactly(tuple("pub-1", "Example")); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getExt) + .allSatisfy(ext -> { + assertThat(ext.has("bidder")).isFalse(); + assertThat(ext.get("gpid").textValue()).isEqualTo("/1111/homepage#div-1"); + assertThat(ext.get("adswag").get("placement_id").textValue()).isEqualTo("plc-1"); + }); + } + + @Test + public void makeHttpRequestsShouldRemoveImpExtWhenOnlyBidderParamsPresent() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.site(Site.builder().domain("example.nl").build()), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("pub-1", null)))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getExt) + .containsOnlyNulls(); + } + + @Test + public void makeHttpRequestsShouldPromotePublisherIdToApp() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.app(App.builder().bundle("com.example.app").build()), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("pub-1", null)))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getApp) + .extracting(App::getPublisher) + .extracting(Publisher::getId) + .containsExactly("pub-1"); + } + + @Test + public void makeHttpRequestsShouldDropImpsWithInvalidExtAndKeepValidOnes() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.site(Site.builder().domain("example.nl").build()), + givenImp(imp -> imp.id("imp1").ext(givenImpExt("pub-1", null))), + givenImp(imp -> imp.id("imp2").ext(givenImpExt(null, "plc-2")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> assertThat(error.getMessage()).isEqualTo("missing publisherId for imp imp2")); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getId) + .containsExactly("imp1"); + } + + @Test + public void makeHttpRequestsShouldSplitImpsByPublisherId() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.site(Site.builder().domain("example.nl").build()), + givenImp(imp -> imp.id("imp-a").ext(givenImpExt("pub-a", null))), + givenImp(imp -> imp.id("imp-b").ext(givenImpExt("pub-b", null))), + givenImp(imp -> imp.id("imp-a-2").ext(givenImpExt("pub-a", null)))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(2); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(payload -> payload.getSite().getPublisher().getId()) + .containsExactly("pub-a", "pub-b"); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(payload -> payload.getImp().stream().map(Imp::getId).toList()) + .containsExactly(List.of("imp-a", "imp-a-2"), List.of("imp-b")); + } + + @Test + public void makeBidsShouldReturnEmptyResultForEmptyBody() { + // given + final BidderCall httpCall = BidderCall.succeededHttp( + HttpRequest.builder().build(), + HttpResponse.of(200, null, ""), + null); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnErrorWhenResponseBodyCouldNotBeParsed() { + // given + final BidderCall httpCall = BidderCall.succeededHttp( + HttpRequest.builder().build(), + HttpResponse.of(200, null, "invalid_json"), + null); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response); + assertThat(error.getMessage()).startsWith("Failed to decode: Unrecognized token 'invalid_json'"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldSynthesizeIframeMarkupForBannerServeUrlBid() throws JsonProcessingException { + // given + final Bid responseBid = givenServeUrlBid("imp1"); + final BidRequest bidRequest = givenBidRequest(givenImp(imp -> imp.id("imp1").banner(givenBanner()))); + final BidderCall httpCall = givenHttpCall(bidRequest, singletonList(responseBid)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType, BidderBid::getBidCurrency) + .containsExactly(tuple(BidType.banner, "EUR")); + assertThat(result.getValue()) + .extracting(BidderBid::getBid) + .extracting(Bid::getAdm, Bid::getW, Bid::getH, Bid::getMtype) + .containsExactly(tuple(IFRAME_ADM, 300, 250, 1)); + } + + @Test + public void makeBidsShouldPassThroughAdmWhenMarkupIsPresent() throws JsonProcessingException { + // given + final String loaderAdm = ""; + final Bid responseBid = givenServeUrlBid("imp1").toBuilder().adm(loaderAdm).build(); + final BidRequest bidRequest = givenBidRequest(givenImp(imp -> imp.id("imp1").banner(givenBanner()))); + final BidderCall httpCall = givenHttpCall(bidRequest, singletonList(responseBid)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getBid) + .extracting(Bid::getAdm) + .containsExactly(loaderAdm); + } + + @Test + public void makeBidsShouldReturnVideoBidForVastMarkup() throws JsonProcessingException { + // given + final Bid responseBid = givenVastBid("imp1"); + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("imp1").video(Video.builder().w(640).h(360).build()))); + final BidderCall httpCall = givenHttpCall(bidRequest, singletonList(responseBid)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType) + .containsExactly(BidType.video); + assertThat(result.getValue()) + .extracting(BidderBid::getBid) + .extracting(Bid::getAdm, Bid::getW, Bid::getH, Bid::getMtype) + .containsExactly(tuple(VAST_ADM, 640, 360, 2)); + } + + @Test + public void makeBidsShouldReturnAudioBidForVastMarkupOnAudioImp() throws JsonProcessingException { + // given + final Bid responseBid = givenVastBid("imp1"); + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("imp1") + .banner(givenBanner()) + .audio(Audio.builder().mimes(singletonList("audio/mpeg")).build()))); + final BidderCall httpCall = givenHttpCall(bidRequest, singletonList(responseBid)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType) + .containsExactly(BidType.audio); + assertThat(result.getValue()) + .extracting(BidderBid::getBid) + .extracting(Bid::getMtype) + .containsExactly(3); + } + + @Test + public void makeBidsShouldResolveServeUrlBidOnMultiFormatImpAsBanner() throws JsonProcessingException { + // given + final Bid responseBid = givenServeUrlBid("imp1"); + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("imp1").banner(givenBanner()).video(Video.builder().w(640).h(360).build()))); + final BidderCall httpCall = givenHttpCall(bidRequest, singletonList(responseBid)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType) + .containsExactly(BidType.banner); + } + + @Test + public void makeBidsShouldSetNurlForVideoServeUrlBidWithoutAdm() throws JsonProcessingException { + // given + final Bid responseBid = givenServeUrlBid("imp1"); + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("imp1").video(Video.builder().w(640).h(360).build()))); + final BidderCall httpCall = givenHttpCall(bidRequest, singletonList(responseBid)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getMtype) + .containsExactly(tuple(SERVE_URL, 2)); + } + + @Test + public void makeBidsShouldHonorMtypeWhenPresent() throws JsonProcessingException { + // given + final Bid responseBid = givenVastBid("imp1").toBuilder().mtype(2).build(); + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("imp1") + .audio(Audio.builder().mimes(singletonList("audio/mpeg")).build()) + .video(Video.builder().w(640).h(360).build()))); + final BidderCall httpCall = givenHttpCall(bidRequest, singletonList(responseBid)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType) + .containsExactly(BidType.video); + } + + @Test + public void makeBidsShouldReturnErrorForUnsupportedMtype() throws JsonProcessingException { + // given + final Bid responseBid = givenVastBid("imp1").toBuilder().mtype(4).build(); + final BidRequest bidRequest = givenBidRequest(givenImp(imp -> imp.id("imp1").banner(givenBanner()))); + final BidderCall httpCall = givenHttpCall(bidRequest, singletonList(responseBid)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response); + assertThat(error.getMessage()).isEqualTo("unsupported bid.mtype 4 for impression imp1"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnErrorForUnknownImp() throws JsonProcessingException { + // given + final Bid responseBid = givenServeUrlBid("unknown-imp"); + final BidRequest bidRequest = givenBidRequest(givenImp(imp -> imp.id("imp1").banner(givenBanner()))); + final BidderCall httpCall = givenHttpCall(bidRequest, singletonList(responseBid)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response); + assertThat(error.getMessage()) + .isEqualTo("bid adswag-unknown-imp references unknown imp unknown-imp"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnErrorForBidWithoutAdmOrServeUrl() throws JsonProcessingException { + // given + final Bid responseBid = Bid.builder() + .id("adswag-imp1") + .impid("imp1") + .price(BigDecimal.valueOf(2.5)) + .build(); + final BidRequest bidRequest = givenBidRequest(givenImp(imp -> imp.id("imp1").banner(givenBanner()))); + final BidderCall httpCall = givenHttpCall(bidRequest, singletonList(responseBid)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response); + assertThat(error.getMessage()) + .isEqualTo("bid adswag-imp1 has neither adm nor ext.adswag.serve_url"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldDefaultCurrencyToEur() throws JsonProcessingException { + // given + final BidResponse bidResponse = BidResponse.builder() + .seatbid(singletonList(SeatBid.builder().bid(singletonList(givenServeUrlBid("imp1"))).build())) + .build(); + final BidRequest bidRequest = givenBidRequest(givenImp(imp -> imp.id("imp1").banner(givenBanner()))); + final BidderCall httpCall = BidderCall.succeededHttp( + HttpRequest.builder().payload(bidRequest).build(), + HttpResponse.of(200, null, mapper.writeValueAsString(bidResponse)), + null); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getBidCurrency) + .containsExactly("EUR"); + } + + private static BidRequest givenBidRequest(UnaryOperator bidRequestCustomizer, + Imp... imps) { + + return bidRequestCustomizer.apply(BidRequest.builder().imp(asList(imps))).build(); + } + + private static BidRequest givenBidRequest(Imp... imps) { + return givenBidRequest(identity(), imps); + } + + private static Imp givenImp(UnaryOperator impCustomizer) { + return impCustomizer.apply(Imp.builder()).build(); + } + + private static ObjectNode givenImpExt(String publisherId, String placementId) { + return mapper.valueToTree(ExtPrebid.of(null, ExtImpAdswag.of(publisherId, placementId))); + } + + private static Banner givenBanner() { + return Banner.builder().format(singletonList(Format.builder().w(300).h(250).build())).build(); + } + + private static Bid givenServeUrlBid(String impId) { + final ObjectNode bidExt = mapper.createObjectNode(); + bidExt.putObject("adswag").put("serve_url", SERVE_URL); + return Bid.builder() + .id("adswag-" + impId) + .impid(impId) + .price(BigDecimal.valueOf(2.5)) + .crid("cr-1") + .ext(bidExt) + .build(); + } + + private static Bid givenVastBid(String impId) { + return Bid.builder() + .id("adswag-" + impId) + .impid(impId) + .price(BigDecimal.valueOf(2.5)) + .crid("cr-1") + .adm(VAST_ADM) + .build(); + } + + private static BidResponse givenBidResponse(List bids) { + return BidResponse.builder() + .cur("EUR") + .seatbid(singletonList(SeatBid.builder().bid(bids).build())) + .build(); + } + + private static BidderCall givenHttpCall(BidRequest bidRequest, List bids) + throws JsonProcessingException { + return BidderCall.succeededHttp( + HttpRequest.builder().payload(bidRequest).build(), + HttpResponse.of(200, null, mapper.writeValueAsString(givenBidResponse(bids))), + null); + } +} diff --git a/src/test/java/org/prebid/server/bidder/bidwave/BidwaveBidderTest.java b/src/test/java/org/prebid/server/bidder/bidwave/BidwaveBidderTest.java new file mode 100644 index 00000000000..43b814120c0 --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/bidwave/BidwaveBidderTest.java @@ -0,0 +1,318 @@ +package org.prebid.server.bidder.bidwave; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.iab.openrtb.request.Banner; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Format; +import com.iab.openrtb.request.Geo; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Site; +import com.iab.openrtb.request.Video; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.prebid.server.VertxTest; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.HttpResponse; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.currency.CurrencyConversionService; +import org.prebid.server.exception.PreBidException; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.bidwave.ExtImpBidwave; + +import java.math.BigDecimal; +import java.util.List; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.BDDAssertions.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.prebid.server.proto.openrtb.ext.response.BidType.banner; +import static org.prebid.server.proto.openrtb.ext.response.BidType.video; + +@ExtendWith(MockitoExtension.class) +public class BidwaveBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "https://rtb.bidwave.net/rtb/v1/bid"; + private static final String PUBLISHER_ID = "11111111-1111-4111-8111-111111111111"; + private static final String OTHER_PUBLISHER_ID = "22222222-2222-4222-8222-222222222222"; + + @Mock + private CurrencyConversionService currencyConversionService; + + private BidwaveBidder target; + + @BeforeEach + public void setUp() { + target = new BidwaveBidder(ENDPOINT_URL, currencyConversionService, jacksonMapper); + } + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new BidwaveBidder("invalid_url", currencyConversionService, jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldSetBidwaveExt() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("imp-1").ext(givenImpExt(PUBLISHER_ID)))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1); + final BidRequest payload = result.getValue().getFirst().getPayload(); + + assertThat(payload.getExt().getProperty("bidwave").get("pid").asText()).isEqualTo(PUBLISHER_ID); + } + + @Test + public void makeHttpRequestsShouldSetDefaultCurrency() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("imp-1").ext(givenImpExt(PUBLISHER_ID)))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1); + final BidRequest payload = result.getValue().getFirst().getPayload(); + + assertThat(payload.getCur()).containsExactly("USD"); + } + + @Test + public void makeHttpRequestsShouldGroupImpsByPublisherId() { + // given + final Imp firstImp = givenImp(imp -> imp.id("imp-1").ext(givenImpExt(PUBLISHER_ID))); + final Imp secondImp = givenImp(imp -> imp.id("imp-2").ext(givenImpExt(OTHER_PUBLISHER_ID))); + final Imp thirdImp = givenImp(imp -> imp.id("imp-3").ext(givenImpExt(PUBLISHER_ID))); + final BidRequest bidRequest = givenBidRequest(firstImp, secondImp, thirdImp); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(2); + assertThat(result.getValue().get(0).getPayload()) + .satisfies(payload -> { + assertThat(payload.getImp()).containsExactly(firstImp, thirdImp); + assertThat(payload.getExt().getProperty("bidwave").get("pid").asText()) + .isEqualTo(PUBLISHER_ID); + }); + assertThat(result.getValue().get(1).getPayload()) + .satisfies(payload -> { + assertThat(payload.getImp()).containsExactly(secondImp); + assertThat(payload.getExt().getProperty("bidwave").get("pid").asText()) + .isEqualTo(OTHER_PUBLISHER_ID); + }); + } + + @Test + public void makeHttpRequestsShouldNormalizeCurrency() { + // given + final Imp imp = givenImp(impBuilder -> impBuilder.id("imp-1").ext(givenImpExt(PUBLISHER_ID))); + final BidRequest bidRequest = givenBidRequest(imp) + .toBuilder() + .cur(List.of("EUR")) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1); + assertThat(result.getValue().getFirst().getPayload().getCur()).containsExactly("USD"); + } + + @Test + public void makeHttpRequestsShouldConvertBidFloorCurrency() { + // given + given(currencyConversionService.convertCurrency(any(), any(), anyString(), anyString())) + .willReturn(BigDecimal.TEN); + + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .id("imp-1") + .bidfloor(BigDecimal.ONE) + .bidfloorcur("EUR") + .ext(givenImpExt(PUBLISHER_ID)))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBidfloor, Imp::getBidfloorcur) + .containsExactly(tuple(BigDecimal.TEN, "USD")); + } + + @Test + public void makeHttpRequestsShouldReturnErrorWhenBidFloorCurrencyCannotBeConverted() { + // given + given(currencyConversionService.convertCurrency(any(), any(), anyString(), anyString())) + .willThrow(new PreBidException("missing conversion rate")); + + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .id("imp-1") + .bidfloor(BigDecimal.ONE) + .bidfloorcur("EUR") + .ext(givenImpExt(PUBLISHER_ID)))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).containsExactly(BidderError.badInput( + "expected currency USD for bid floor; unable to convert from EUR for imp `imp-1`")); + } + + @Test + public void makeBidsShouldReturnBannerAndVideoBids() throws JsonProcessingException { + // given + final Bid bannerBid = Bid.builder().impid("imp-1").mtype(1).build(); + final Bid videoBid = Bid.builder().impid("imp-2").mtype(2).build(); + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("imp-1").banner(givenBanner()).ext(givenImpExt(PUBLISHER_ID))), + givenImp(imp -> imp.id("imp-2").video(givenVideo()).ext(givenImpExt(PUBLISHER_ID)))); + final BidderCall httpCall = sampleHttpCall( + bidRequest, + mapper.writeValueAsString(givenBidResponse(bannerBid, videoBid))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).containsExactly( + BidderBid.of(bannerBid, banner, "USD"), + BidderBid.of(videoBid, video, "USD")); + } + + @Test + public void makeBidsShouldReturnErrorWhenMtypeIsAbsent() throws JsonProcessingException { + // given + final Bid bid = Bid.builder().impid("imp-1").build(); + final BidderCall httpCall = sampleHttpCall( + givenBidRequest(), + mapper.writeValueAsString(givenBidResponse(bid))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).containsExactly( + BidderError.badServerResponse( + "Bid must have non-zero MType for impression with ID: \"imp-1\"")); + } + + @Test + public void makeBidsShouldReturnErrorIfMtypeIsUnsupported() throws JsonProcessingException { + // given + final Bid bid = Bid.builder().impid("imp-1").mtype(3).build(); + final BidderCall httpCall = sampleHttpCall( + givenBidRequest(givenImp(imp -> imp.id("imp-1").banner(givenBanner()).ext(givenImpExt(PUBLISHER_ID)))), + mapper.writeValueAsString(givenBidResponse(bid))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).containsExactly( + BidderError.badServerResponse("Unsupported MType 3 for impression with ID: \"imp-1\"")); + } + + @Test + public void makeBidsShouldReturnErrorIfResponseBodyCouldNotBeParsed() { + // given + final BidderCall httpCall = sampleHttpCall(givenBidRequest(), "invalid response"); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).hasSize(1) + .allMatch(error -> error.getType() == BidderError.Type.bad_server_response + && error.getMessage().startsWith("Failed to decode: Unrecognized token")); + } + + private static BidRequest givenBidRequest(Imp... imps) { + return BidRequest.builder() + .id("request-id") + .site(Site.builder().domain("publisher.example.com").build()) + .device(Device.builder() + .ip("203.0.113.42") + .geo(Geo.builder().country("USA").region("CA").city("San Francisco").build()) + .build()) + .imp(List.of(imps)) + .build(); + } + + private static BidRequest givenBidRequest() { + return givenBidRequest(givenImp(imp -> imp.id("imp-1").banner(givenBanner()).ext(givenImpExt(PUBLISHER_ID)))); + } + + private static Imp givenImp(java.util.function.Function impCustomizer) { + return impCustomizer.apply(Imp.builder()).build(); + } + + private static Banner givenBanner() { + return Banner.builder() + .format(singletonList(Format.builder().w(300).h(250).build())) + .build(); + } + + private static Video givenVideo() { + return Video.builder() + .mimes(singletonList("video/mp4")) + .protocols(List.of(2, 3)) + .w(640) + .h(360) + .build(); + } + + private static com.fasterxml.jackson.databind.node.ObjectNode givenImpExt(String publisherId) { + return mapper.valueToTree(ExtPrebid.of(null, ExtImpBidwave.of(publisherId))); + } + + private static BidResponse givenBidResponse(Bid... bids) { + return BidResponse.builder() + .cur("USD") + .seatbid(singletonList(SeatBid.builder().bid(List.of(bids)).build())) + .build(); + } + + private static BidderCall sampleHttpCall(BidRequest bidRequest, String body) { + return BidderCall.succeededHttp( + HttpRequest.builder().payload(bidRequest).build(), + HttpResponse.of(200, null, body), + null); + } +} diff --git a/src/test/java/org/prebid/server/bidder/connectad/ConnectAdBidderTest.java b/src/test/java/org/prebid/server/bidder/connectad/ConnectAdBidderTest.java index 3ac89edc67a..122f4ffad8e 100644 --- a/src/test/java/org/prebid/server/bidder/connectad/ConnectAdBidderTest.java +++ b/src/test/java/org/prebid/server/bidder/connectad/ConnectAdBidderTest.java @@ -49,6 +49,7 @@ public void makeHttpRequestsShouldReturnErrorWhenImpExtCouldNotBeParsed() { final BidRequest bidRequest = BidRequest.builder() .imp(singletonList(Imp.builder() .id("123") + .banner(Banner.builder().w(300).h(250).build()) .ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode()))) .build())) .build(); @@ -109,12 +110,15 @@ public void makeBidsShouldReturnEmptyListWhenBidResponseSeatBidIsNull() throws J @Test public void makeBidsShouldReturnBannerBid() throws JsonProcessingException { // given + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(Imp.builder().id("123").banner(Banner.builder().build()).build())) + .build(); final BidderCall httpCall = givenHttpCall( - BidRequest.builder().imp(singletonList(Imp.builder().id("123").build())).build(), + bidRequest, mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.impid("123")))); // when - final Result> result = target.makeBids(httpCall, null); + final Result> result = target.makeBids(httpCall, bidRequest); // then assertThat(result.getErrors()).isEmpty(); @@ -146,7 +150,7 @@ public void makeHttpRequestsShouldReturnErrorIfImpExtHasNoSiteId() { impBuilder -> impBuilder .id("123") .ext(mapper.valueToTree(ExtPrebid.of(null, - ExtImpConnectAd.of("12", null, BigDecimal.ONE))))); + ExtImpConnectAd.of(12, null, BigDecimal.ONE))))); // when final Result>> result = target.makeHttpRequests(bidRequest); @@ -164,7 +168,7 @@ public void impSecureShouldBeOneIfSitePageStartsFromHttps() { impBuilder -> impBuilder .id("123") .ext(mapper.valueToTree(ExtPrebid.of(null, - ExtImpConnectAd.of("12", "1", BigDecimal.ONE))))); + ExtImpConnectAd.of(12, 1, BigDecimal.ONE))))); // when final Result>> result = target.makeHttpRequests(bidRequest); @@ -180,6 +184,32 @@ public void impSecureShouldBeOneIfSitePageStartsFromHttps() { .containsOnly(1); } + @Test + public void makeHttpRequestsShouldPropagateSiteIdAndNetworkId() { + // given + final BidRequest bidRequest = givenBidRequest( + impBuilder -> impBuilder + .id("123") + .ext(mapper.valueToTree(ExtPrebid.of(null, + ExtImpConnectAd.of(12345, 67890, BigDecimal.ONE))))); + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .hasSize(1) + .first() + .satisfies(imp -> { + assertThat(imp.getTagid()).isEqualTo("67890"); + assertThat(imp.getExt().get("networkId").asInt()).isEqualTo(12345); + assertThat(imp.getExt().get("siteId").asInt()).isEqualTo(67890); + }); + } + private static BidRequest givenBidRequest( Function bidRequestCustomizer, Function impCustomizer) { @@ -202,7 +232,7 @@ private static Imp givenImp(Function impCustomiz .w(14) .h(15).build()) .ext(mapper.valueToTree(ExtPrebid.of(null, - ExtImpConnectAd.of("12", "12", BigDecimal.ONE))))) + ExtImpConnectAd.of(12, 12, BigDecimal.ONE))))) .build(); } diff --git a/src/test/java/org/prebid/server/bidder/floxis/FloxisBidderTest.java b/src/test/java/org/prebid/server/bidder/floxis/FloxisBidderTest.java new file mode 100644 index 00000000000..f94d035289f --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/floxis/FloxisBidderTest.java @@ -0,0 +1,595 @@ +package org.prebid.server.bidder.floxis; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.Audio; +import com.iab.openrtb.request.Banner; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Format; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Native; +import com.iab.openrtb.request.Site; +import com.iab.openrtb.request.Video; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import org.junit.jupiter.api.Test; +import org.prebid.server.VertxTest; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.HttpResponse; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.floxis.ExtImpFloxis; +import org.prebid.server.proto.openrtb.ext.response.BidType; + +import java.util.List; +import java.util.function.UnaryOperator; + +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static java.util.function.UnaryOperator.identity; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.tuple; + +public class FloxisBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "https://{Host}.floxis.tech/pbs?seat={SeatId}"; + + private final FloxisBidder target = new FloxisBidder(ENDPOINT_URL, jacksonMapper); + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new FloxisBidder("invalid_url", jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldReturnErrorWhenImpExtCouldNotBeParsed() { + // given + final BidRequest bidRequest = givenBidRequest(imp -> imp + .id("imp-1") + .ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode())))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()).startsWith("invalid imp.ext.bidder for imp imp-1:"); + }); + } + + @Test + public void makeHttpRequestsShouldUrlEscapeSeatAndUseEuHost() { + // given + final BidRequest bidRequest = givenBidRequest(imp -> imp.ext(givenImpExt("a b&c", "eu"))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getUri) + .containsExactly("https://eu.floxis.tech/pbs?seat=a%20b%26c"); + } + + @Test + public void makeHttpRequestsShouldUseApacHost() { + // given + final BidRequest bidRequest = givenBidRequest(imp -> imp.ext(givenImpExt("seat-apac", "apac"))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getUri) + .containsExactly("https://apac.floxis.tech/pbs?seat=seat-apac"); + } + + @Test + public void makeHttpRequestsShouldUseUseHostForExplicitUsE() { + // given + final BidRequest bidRequest = givenBidRequest(imp -> imp.ext(givenImpExt("abc", "us-e"))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getUri) + .containsExactly("https://us-e.floxis.tech/pbs?seat=abc"); + } + + @Test + public void makeHttpRequestsShouldDefaultToUseHostWhenRegionMissing() { + // given + final BidRequest bidRequest = givenBidRequest(imp -> imp.ext(givenImpExt("abc", null))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getUri) + .containsExactly("https://us-e.floxis.tech/pbs?seat=abc"); + } + + @Test + public void makeHttpRequestsShouldUseArbitraryValidRegionLabelAsSubdomain() { + // given + final BidRequest bidRequest = givenBidRequest(imp -> imp.ext(givenImpExt("abc", "mars"))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getUri) + .containsExactly("https://mars.floxis.tech/pbs?seat=abc"); + } + + @Test + public void makeHttpRequestsShouldUsePartnerPrefixedHostWhenPartnerProvided() { + // given + final BidRequest bidRequest = givenBidRequest(imp -> imp.ext(givenImpExt("abc", "us-e", "acme"))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getUri) + .containsExactly("https://acme-us-e.floxis.tech/pbs?seat=abc"); + } + + @Test + public void makeHttpRequestsShouldLeaveEndpointUnchangedWhenItDeclaresNoMacros() { + // given + final FloxisBidder bidder = new FloxisBidder("http://localhost:8090/floxis-exchange", jacksonMapper); + final BidRequest bidRequest = givenBidRequest(imp -> imp.ext(givenImpExt("abc", "eu"))); + + // when + final Result>> result = bidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getUri) + .containsExactly("http://localhost:8090/floxis-exchange"); + } + + @Test + public void makeHttpRequestsShouldRouteOnceAndForwardAllImpsWhenSeatAndRegionMatch() { + // given + final BidRequest bidRequest = BidRequest.builder() + .id("req-1") + .imp(asList( + givenImp(imp -> imp.id("imp-1").ext(givenImpExt("seat-eu", "eu"))), + givenImp(imp -> imp.id("imp-2").ext(givenImpExt("seat-eu", "eu"))))) + .site(Site.builder().id("271").build()) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getUri) + .containsExactly("https://eu.floxis.tech/pbs?seat=seat-eu"); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getId) + .containsExactly("imp-1", "imp-2"); + } + + @Test + public void makeHttpRequestsShouldRouteOnceWhenImpsResolveToSameHostThroughDefaults() { + // given + final BidRequest bidRequest = BidRequest.builder() + .id("req-1") + .imp(asList( + givenImp(imp -> imp.id("imp-1").ext(givenImpExt("seat-us", null))), + givenImp(imp -> imp.id("imp-2").ext(givenImpExt("seat-us", "us-e", "floxis"))))) + .site(Site.builder().id("271").build()) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getUri) + .containsExactly("https://us-e.floxis.tech/pbs?seat=seat-us"); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getId) + .containsExactly("imp-1", "imp-2"); + } + + @Test + public void makeHttpRequestsShouldRouteOnceWhenRegionCaseDiffers() { + // given + final BidRequest bidRequest = BidRequest.builder() + .id("req-1") + .imp(asList( + givenImp(imp -> imp.id("imp-1").ext(givenImpExt("seat-eu", "eu"))), + givenImp(imp -> imp.id("imp-2").ext(givenImpExt("seat-eu", "EU"))))) + .site(Site.builder().id("271").build()) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getUri) + .containsExactly("https://eu.floxis.tech/pbs?seat=seat-eu"); + } + + @Test + public void makeHttpRequestsShouldSplitPerSeatWhenImpsShareAHost() { + // given + final BidRequest bidRequest = BidRequest.builder() + .id("req-1") + .imp(asList( + givenImp(imp -> imp.id("imp-1").ext(givenImpExt("seat-eu", "eu"))), + givenImp(imp -> imp.id("imp-2").ext(givenImpExt("seat-other", "eu"))))) + .site(Site.builder().id("271").build()) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(2) + .extracting(HttpRequest::getUri) + .containsExactly( + "https://eu.floxis.tech/pbs?seat=seat-eu", + "https://eu.floxis.tech/pbs?seat=seat-other"); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getId) + .containsExactly("imp-1", "imp-2"); + } + + @Test + public void makeHttpRequestsShouldSplitPerRegionWhenImpsTargetDifferentRegions() { + // given + final BidRequest bidRequest = BidRequest.builder() + .id("req-1") + .imp(asList( + givenImp(imp -> imp.id("imp-1").ext(givenImpExt("seat-eu", "eu"))), + givenImp(imp -> imp.id("imp-2").ext(givenImpExt("seat-eu", "apac"))))) + .site(Site.builder().id("271").build()) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(2) + .extracting(HttpRequest::getUri) + .containsExactly( + "https://eu.floxis.tech/pbs?seat=seat-eu", + "https://apac.floxis.tech/pbs?seat=seat-eu"); + } + + @Test + public void makeHttpRequestsShouldSplitPerPartnerWhenImpsTargetDifferentPartners() { + // given + final BidRequest bidRequest = BidRequest.builder() + .id("req-1") + .imp(asList( + givenImp(imp -> imp.id("imp-1").ext(givenImpExt("seat-eu", "eu", "acme"))), + givenImp(imp -> imp.id("imp-2").ext(givenImpExt("seat-eu", "eu", "other"))))) + .site(Site.builder().id("271").build()) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(2) + .extracting(HttpRequest::getUri) + .containsExactly( + "https://acme-eu.floxis.tech/pbs?seat=seat-eu", + "https://other-eu.floxis.tech/pbs?seat=seat-eu"); + } + + @Test + public void makeHttpRequestsShouldDropOnlyTheImpWhoseExtCouldNotBeParsed() { + // given + final BidRequest bidRequest = BidRequest.builder() + .id("req-1") + .imp(asList( + givenImp(imp -> imp.id("imp-bad") + .ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode())))), + givenImp(imp -> imp.id("imp-good").ext(givenImpExt("seat-eu", "eu"))))) + .site(Site.builder().id("271").build()) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> assertThat(error.getMessage()) + .startsWith("invalid imp.ext.bidder for imp imp-bad:")); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getUri) + .containsExactly("https://eu.floxis.tech/pbs?seat=seat-eu"); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getId) + .containsExactly("imp-good"); + } + + @Test + public void makeHttpRequestsShouldForwardRequestBodyUnchanged() { + // given + final BidRequest bidRequest = givenBidRequest(imp -> imp.ext(givenImpExt("abc", "eu"))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .containsExactly(bidRequest); + } + + @Test + public void makeBidsShouldReturnErrorWhenResponseBodyCouldNotBeParsed() { + // given + final BidderCall httpCall = givenHttpCall(givenBidRequest(identity()), "invalid"); + + // when + final Result> result = target.makeBids(httpCall, BidRequest.builder().build()); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response)); + } + + @Test + public void makeBidsShouldReturnEmptyWhenResponseHasNoSeatBid() throws Exception { + // given + final BidderCall httpCall = givenHttpCall(givenBidRequest(identity()), + mapper.writeValueAsString(BidResponse.builder().build())); + + // when + final Result> result = target.makeBids(httpCall, BidRequest.builder().build()); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).isEmpty(); + } + + @Test + public void makeBidsShouldSkipNullSeatBidAndEmptyBids() throws Exception { + // given + final BidResponse bidResponse = BidResponse.builder() + .cur("USD") + .seatbid(asList( + null, + SeatBid.builder().bid(null).build(), + SeatBid.builder() + .bid(singletonList(Bid.builder().impid("imp-1").mtype(1).build())) + .build())) + .build(); + final BidderCall httpCall = givenHttpCall(givenBidRequest(identity()), + mapper.writeValueAsString(bidResponse)); + + // when + final Result> result = target.makeBids(httpCall, givenBidRequest(identity())); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getType) + .containsExactly(BidType.banner); + } + + @Test + public void makeBidsShouldResolveBidTypeFromMTypeAndPassCurrency() throws Exception { + // given + final BidResponse bidResponse = BidResponse.builder() + .cur("EUR") + .seatbid(singletonList(SeatBid.builder() + .bid(asList( + Bid.builder().impid("imp-1").mtype(1).build(), + Bid.builder().impid("imp-1").mtype(2).build(), + Bid.builder().impid("imp-1").mtype(3).build(), + Bid.builder().impid("imp-1").mtype(4).build())) + .build())) + .build(); + final BidderCall httpCall = givenHttpCall(givenBidRequest(identity()), + mapper.writeValueAsString(bidResponse)); + + // when + final Result> result = target.makeBids(httpCall, givenBidRequest(identity())); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType, BidderBid::getBidCurrency) + .containsExactly( + tuple(BidType.banner, "EUR"), + tuple(BidType.video, "EUR"), + tuple(BidType.audio, "EUR"), + tuple(BidType.xNative, "EUR")); + } + + @Test + public void makeBidsShouldReturnErrorWhenMTypeUnsupported() throws Exception { + // given + final BidResponse bidResponse = givenBidResponse(Bid.builder().impid("imp-1").mtype(7).build()); + final BidderCall httpCall = givenHttpCall(givenBidRequest(identity()), + mapper.writeValueAsString(bidResponse)); + + // when + final Result> result = target.makeBids(httpCall, givenBidRequest(identity())); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1) + .containsOnly(BidderError.badServerResponse( + "unsupported bid.mtype 7 for impression imp-1")); + } + + @Test + public void makeBidsShouldResolveBidTypeFromSingleFormatImpWhenMTypeAbsent() throws Exception { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(asList( + Imp.builder().id("b").banner(Banner.builder().build()).build(), + Imp.builder().id("v").video(Video.builder().build()).build(), + Imp.builder().id("a").audio(Audio.builder().build()).build(), + Imp.builder().id("n").xNative(Native.builder().build()).build())) + .build(); + final BidResponse bidResponse = BidResponse.builder() + .cur("USD") + .seatbid(singletonList(SeatBid.builder() + .bid(asList( + Bid.builder().impid("b").build(), + Bid.builder().impid("v").build(), + Bid.builder().impid("a").build(), + Bid.builder().impid("n").build())) + .build())) + .build(); + final BidderCall httpCall = givenHttpCall(bidRequest, mapper.writeValueAsString(bidResponse)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType) + .containsExactly(BidType.banner, BidType.video, BidType.audio, BidType.xNative); + } + + @Test + public void makeBidsShouldReturnErrorForMultiFormatImpWithoutMType() throws Exception { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(Imp.builder() + .id("imp-1") + .banner(Banner.builder().build()) + .video(Video.builder().build()) + .build())) + .build(); + final BidResponse bidResponse = givenBidResponse(Bid.builder().impid("imp-1").build()); + final BidderCall httpCall = givenHttpCall(bidRequest, mapper.writeValueAsString(bidResponse)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1) + .containsOnly(BidderError.badServerResponse( + "unable to resolve a single media type for impression imp-1; set bid.mtype")); + } + + @Test + public void makeBidsShouldReturnErrorForImpWithoutFormatAndWithoutMType() throws Exception { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(Imp.builder().id("imp-1").build())) + .build(); + final BidResponse bidResponse = givenBidResponse(Bid.builder().impid("imp-1").build()); + final BidderCall httpCall = givenHttpCall(bidRequest, mapper.writeValueAsString(bidResponse)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1) + .containsOnly(BidderError.badServerResponse( + "unable to resolve a single media type for impression imp-1; set bid.mtype")); + } + + @Test + public void makeBidsShouldReturnErrorWhenBidImpIdNotFound() throws Exception { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(Imp.builder().id("imp-1").banner(Banner.builder().build()).build())) + .build(); + final BidResponse bidResponse = givenBidResponse(Bid.builder().impid("no-such-imp").build()); + final BidderCall httpCall = givenHttpCall(bidRequest, mapper.writeValueAsString(bidResponse)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1) + .containsOnly(BidderError.badServerResponse( + "unable to find impression no-such-imp for bid")); + } + + private static BidRequest givenBidRequest(UnaryOperator impCustomizer) { + return BidRequest.builder() + .id("req-1") + .imp(singletonList(givenImp(impCustomizer))) + .site(Site.builder().id("271").build()) + .build(); + } + + private static Imp givenImp(UnaryOperator impCustomizer) { + return impCustomizer.apply(Imp.builder() + .id("imp-1") + .banner(Banner.builder().format(singletonList(Format.builder().w(300).h(250).build())).build()) + .ext(givenImpExt("pub-seat-1", null))) + .build(); + } + + private static ObjectNode givenImpExt(String seat, String region) { + return givenImpExt(seat, region, null); + } + + private static ObjectNode givenImpExt(String seat, String region, String partner) { + return mapper.valueToTree(ExtPrebid.of(null, ExtImpFloxis.of(seat, region, partner))); + } + + private static BidResponse givenBidResponse(Bid bid) { + return BidResponse.builder() + .cur("USD") + .seatbid(singletonList(SeatBid.builder().bid(singletonList(bid)).build())) + .build(); + } + + private static BidderCall givenHttpCall(BidRequest bidRequest, String body) { + return BidderCall.succeededHttp( + HttpRequest.builder().payload(bidRequest).build(), + HttpResponse.of(200, null, body), + null); + } +} diff --git a/src/test/java/org/prebid/server/bidder/missena/MissenaBidderTest.java b/src/test/java/org/prebid/server/bidder/missena/MissenaBidderTest.java index b4025692372..441e1700023 100644 --- a/src/test/java/org/prebid/server/bidder/missena/MissenaBidderTest.java +++ b/src/test/java/org/prebid/server/bidder/missena/MissenaBidderTest.java @@ -4,11 +4,13 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Eid; import com.iab.openrtb.request.Imp; import com.iab.openrtb.request.Regs; import com.iab.openrtb.request.Site; import com.iab.openrtb.request.Source; import com.iab.openrtb.request.SupplyChain; +import com.iab.openrtb.request.Uid; import com.iab.openrtb.request.User; import com.iab.openrtb.response.Bid; import org.junit.jupiter.api.BeforeEach; @@ -33,7 +35,6 @@ import org.prebid.server.version.PrebidVersionProvider; import java.math.BigDecimal; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.function.UnaryOperator; @@ -41,6 +42,7 @@ import static java.util.Collections.singletonList; import static java.util.function.UnaryOperator.identity; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; @@ -57,7 +59,8 @@ class MissenaBidderTest extends VertxTest { private static final String ENDPOINT_URL = "https://test-url.com/?t={PublisherID}"; - private static final String TEST_PBS_VERSION = "pbs-java/1.0"; + private static final String TEST_PBS_VERSION_RECORD = "pbs-java/1.0"; + private static final String TEST_EXPECTED_VERSION = "prebid-server-java@1.0"; @Mock(strictness = LENIENT) private CurrencyConversionService currencyConversionService; @@ -75,7 +78,7 @@ public void setUp() { currencyConversionService, prebidVersionProvider); - given(prebidVersionProvider.getNameVersionRecord()).willReturn(TEST_PBS_VERSION); + given(prebidVersionProvider.getNameVersionRecord()).willReturn(TEST_PBS_VERSION_RECORD); given(currencyConversionService.convertCurrency(any(), any(), anyString(), anyString())) .willAnswer(invocation -> invocation.getArgument(0)); } @@ -83,7 +86,7 @@ public void setUp() { @Test public void makeHttpRequestsShouldReturnErrorIfImpExtCouldNotBeParsed() { // given - final BidRequest bidRequest = givenBidRequest(imp -> imp.ext(givenInvalidImpExt())); + final BidRequest bidRequest = givenBidRequest(givenImp(imp -> imp.ext(givenInvalidImpExt()))); // when final Result>> result = target.makeHttpRequests(bidRequest); @@ -98,20 +101,26 @@ public void makeHttpRequestsShouldReturnErrorIfImpExtCouldNotBeParsed() { public void makeHttpRequestsShouldMakeRequestForFirstValidImp() { // given final ObjectNode settingsNode = mapper.createObjectNode().put("settingKey", "settingValue"); + final List userEids = List.of(Eid.builder() + .source("id-source") + .uids(List.of(Uid.builder().id("uid").atype(1).build())) + .build()); final BidRequest bidRequest = BidRequest.builder() .id("requestId") + .test(1) .tmax(500L) .cur(singletonList("USD")) .imp(List.of( givenImp(imp -> imp.id("impId1") - .ext(givenImpExt("apiKey1", "placementId1", "1", List.of("banner"), settingsNode))), + .ext(givenImpExt("apiKey1", "placementId1", "sample1", + List.of("banner"), settingsNode))), givenImp(imp -> imp.id("impId2") - .ext(givenImpExt("apiKey2", "placementId2", "0", null, null))))) + .ext(givenImpExt("apiKey2", "placementId2", "sample2", null, null))))) .site(Site.builder().page("http://test.com/page").domain("test.com").build()) .regs(Regs.builder().ext(ExtRegs.of(1, null, null, null)).build()) .user(User.builder().buyeruid("buyer1") - .ext(ExtUser.builder().consent("consentStr").build()).build()) + .ext(ExtUser.builder().consent("consentStr").eids(userEids).build()).build()) .source(Source.builder().schain(SupplyChain.of(1, null, null, null)).build()) .device(Device.builder().ua("test-ua").ip("123.123.123.123").build()) .build(); @@ -121,22 +130,25 @@ public void makeHttpRequestsShouldMakeRequestForFirstValidImp() { // then final MissenaUserParams expectedUserParams = MissenaUserParams.builder() + .apiKey("apiKey1") .formats(List.of("banner")) .placement("placementId1") - .testMode("1") + .sample("sample1") .settings(settingsNode) .build(); final MissenaAdRequest expectedPayload = MissenaAdRequest.builder() .adUnit("impId1") .currency("USD") + .debug(true) + .userEids(userEids) .floor(BigDecimal.valueOf(0.1)) .floorCurrency("USD") .idempotencyKey("requestId") .requestId("requestId") .timeout(500L) .params(expectedUserParams) - .version(TEST_PBS_VERSION) + .version(TEST_EXPECTED_VERSION) .bidRequest(bidRequest) .build(); @@ -149,12 +161,140 @@ public void makeHttpRequestsShouldMakeRequestForFirstValidImp() { .containsExactly(Collections.singleton("impId1")); } + @Test + public void makeHttpRequestsShouldPassOriginalBidRequestAsOrtb2() { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(imp -> imp.ext(givenImpExt("apiKey")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1).first() + .extracting(HttpRequest::getPayload) + .extracting(MissenaAdRequest::getBidRequest) + .isEqualTo(bidRequest); + } + + @Test + public void makeHttpRequestsShouldPassUserEidsWhenPresent() { + // given + final List userEids = List.of(Eid.builder() + .source("id-source") + .uids(List.of(Uid.builder().id("uid").atype(1).build())) + .build()); + final BidRequest bidRequest = givenBidRequest( + request -> request.user(User.builder().ext(ExtUser.builder().eids(userEids).build()).build()), + givenImp(imp -> imp.ext(givenImpExt("apiKey")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1).first() + .extracting(HttpRequest::getPayload) + .extracting(MissenaAdRequest::getUserEids) + .isEqualTo(userEids); + } + + @Test + public void makeHttpRequestsShouldNotPassUserEidsWhenUserIsNull() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.user(null), + givenImp(imp -> imp.ext(givenImpExt("apiKey")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1).first() + .extracting(HttpRequest::getPayload) + .extracting(MissenaAdRequest::getUserEids) + .isNull(); + } + + @Test + public void makeHttpRequestsShouldNotPassUserEidsWhenEidsAreEmpty() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.user( + User.builder().ext(ExtUser.builder().eids(Collections.emptyList()).build()).build()), + givenImp(imp -> imp.ext(givenImpExt("apiKey")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1).first() + .extracting(HttpRequest::getPayload) + .extracting(MissenaAdRequest::getUserEids) + .isNull(); + } + + @Test + public void makeHttpRequestsShouldSetDebugWhenTestIsOne() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.test(1), + givenImp(imp -> imp.ext(givenImpExt("apiKey")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1).first() + .extracting(HttpRequest::getPayload) + .extracting(MissenaAdRequest::getDebug) + .isEqualTo(true); + } + + @Test + public void makeHttpRequestsShouldNotSetDebugWhenTestIsNotOne() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.test(0), + givenImp(imp -> imp.ext(givenImpExt("apiKey")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1).first() + .extracting(HttpRequest::getPayload) + .extracting(MissenaAdRequest::getDebug) + .isNull(); + } + + @Test + public void makeHttpRequestsShouldSendUnknownVersionWhenVersionRecordIsNull() { + // given + given(prebidVersionProvider.getNameVersionRecord()).willReturn(null); + final BidRequest bidRequest = givenBidRequest(givenImp(imp -> imp.ext(givenImpExt("apiKey")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1).first() + .extracting(HttpRequest::getPayload) + .extracting(MissenaAdRequest::getVersion) + .isEqualTo("prebid-server-java@unknown"); + } + @Test public void makeHttpRequestsShouldReturnErrorIfAllImpsAreInvalid() { // given final BidRequest bidRequest = givenBidRequest( - imp -> imp.ext(givenInvalidImpExt()), - imp -> imp.ext(givenInvalidImpExt())); + givenImp(imp -> imp.ext(givenInvalidImpExt())), + givenImp(imp -> imp.ext(givenInvalidImpExt()))); // when final Result>> result = target.makeHttpRequests(bidRequest); @@ -169,11 +309,11 @@ public void makeHttpRequestsShouldReturnErrorIfAllImpsAreInvalid() { @Test public void makeHttpRequestsShouldReturnExpectedHeadersWhenDeviceHasIpAndIpv6() { // given - final BidRequest bidRequest = givenBidRequest(identity()) - .toBuilder() - .site(Site.builder().page("http://page.com").build()) - .device(Device.builder().ua("ua").ip("ip").ipv6("ipv6").build()) - .build(); + final BidRequest bidRequest = givenBidRequest( + request -> request + .site(Site.builder().page("http://page.com").build()) + .device(Device.builder().ua("ua").ip("ip").ipv6("ipv6").build()), + givenImp(identity())); // when final Result>> result = target.makeHttpRequests(bidRequest); @@ -195,10 +335,9 @@ public void makeHttpRequestsShouldReturnExpectedHeadersWhenDeviceHasIpAndIpv6() @Test public void makeHttpRequestsShouldReturnExpectedHeadersWhenDeviceHasIpv6Only() { // given - final BidRequest bidRequest = givenBidRequest(identity()) - .toBuilder() - .device(Device.builder().ip(null).ipv6("ipv6").build()) - .build(); + final BidRequest bidRequest = givenBidRequest( + request -> request.device(Device.builder().ip(null).ipv6("ipv6").build()), + givenImp(identity())); // when final Result>> result = target.makeHttpRequests(bidRequest); @@ -244,7 +383,7 @@ public void makeHttpRequestsShouldConvertBidFloorCurrency() { @Test public void makeHttpRequestsShouldUseCorrectUri() { // given - final BidRequest bidRequest = givenBidRequest(imp -> imp.ext(givenImpExt("testApiKey"))); + final BidRequest bidRequest = givenBidRequest(givenImp(imp -> imp.ext(givenImpExt("testApiKey")))); // when final Result>> result = target.makeHttpRequests(bidRequest); @@ -260,7 +399,7 @@ public void makeHttpRequestsShouldUseCorrectUri() { public void makeBidsShouldReturnErrorIfResponseBodyCouldNotBeParsed() { // given final BidderCall httpCall = givenHttpCall("invalid"); - final BidRequest bidRequest = givenBidRequest(imp -> imp.id("impId1")); + final BidRequest bidRequest = givenBidRequest(givenImp(imp -> imp.id("impId1"))); // when final Result> result = target.makeBids(httpCall, bidRequest); @@ -281,10 +420,14 @@ public void makeBidsShouldReturnSingleBid() throws JsonProcessingException { .cpm(BigDecimal.TEN) .currency("USD") .ad("adm") + .width(320) + .height(100) .build(); final BidderCall httpCall = givenHttpCall(mapper.writeValueAsString(bidResponse)); - final BidRequest bidRequest = givenBidRequest(imp -> imp.id("impId")).toBuilder().id("requestId").build(); + final BidRequest bidRequest = givenBidRequest( + request -> request.id("requestId"), + givenImp(imp -> imp.id("impId"))); // when final Result> result = target.makeBids(httpCall, bidRequest); @@ -298,17 +441,48 @@ public void makeBidsShouldReturnSingleBid() throws JsonProcessingException { .price(BigDecimal.TEN) .adm("adm") .crid("id") + .w(320) + .h(100) .build(); assertThat(result.getValue()).hasSize(1) .containsOnly(BidderBid.of(expectedBid, BidType.banner, "USD")); } - private static BidRequest givenBidRequest(UnaryOperator... impCustomizers) { - final List imps = Arrays.stream(impCustomizers) - .map(MissenaBidderTest::givenImp) - .toList(); - return BidRequest.builder().imp(imps).cur(singletonList("USD")).build(); + @Test + public void makeBidsShouldReturnBidWithoutSizeWhenResponseHasNoSize() throws JsonProcessingException { + // given + final MissenaAdResponse bidResponse = MissenaAdResponse.builder() + .requestId("id") + .cpm(BigDecimal.TEN) + .currency("USD") + .ad("adm") + .build(); + + final BidderCall httpCall = givenHttpCall(mapper.writeValueAsString(bidResponse)); + final BidRequest bidRequest = givenBidRequest( + request -> request.id("requestId"), + givenImp(imp -> imp.id("impId"))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getBid) + .extracting(Bid::getW, Bid::getH) + .containsExactly(tuple(null, null)); + } + + private static BidRequest givenBidRequest(Imp... imps) { + return givenBidRequest(identity(), imps); + } + + private static BidRequest givenBidRequest(UnaryOperator bidRequestCustomizer, + Imp... imps) { + + return bidRequestCustomizer.apply(BidRequest.builder().imp(List.of(imps)).cur(singletonList("USD"))).build(); } private static Imp givenImp(UnaryOperator impCustomizer) { @@ -326,14 +500,14 @@ private static ObjectNode givenImpExt(String apiKey) { private static ObjectNode givenImpExt(String apiKey, String placement, - String testMode, + String sample, List formats, ObjectNode settings) { final ExtImpMissena extImpMissena = ExtImpMissena.builder() .apiKey(apiKey) .placement(placement) - .testMode(testMode) + .sample(sample) .formats(formats) .settings(settings) .build(); diff --git a/src/test/java/org/prebid/server/bidder/rtbhouse/RtbhouseBidderTest.java b/src/test/java/org/prebid/server/bidder/rtbhouse/RtbhouseBidderTest.java index 7a4e483303c..9a354cfbae7 100644 --- a/src/test/java/org/prebid/server/bidder/rtbhouse/RtbhouseBidderTest.java +++ b/src/test/java/org/prebid/server/bidder/rtbhouse/RtbhouseBidderTest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.App; import com.iab.openrtb.request.Banner; import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.request.Deal; @@ -189,10 +190,10 @@ public void makeHttpRequestsShouldNotReturnErrorIfNativePresent() { } @Test - public void makeHttpRequestsShouldCreateSiteAndPublisherWhenBidRequestHasNoSite() { + public void makeHttpRequestsShouldNotCreateSiteWhenBidRequestHasAppOnly() { // given final BidRequest bidRequest = givenBidRequest( - bidReq -> bidReq.site(null), + bidReq -> bidReq.site(null).app(App.builder().id("app_id").build()), identity(), identity()); @@ -204,12 +205,31 @@ public void makeHttpRequestsShouldCreateSiteAndPublisherWhenBidRequestHasNoSite( assertThat(result.getValue()).hasSize(1) .extracting(HttpRequest::getPayload) .extracting(BidRequest::getSite) - .allSatisfy(site -> { - assertThat(site).isNotNull(); - assertThat(site.getPublisher()).isNotNull(); - assertThat(site.getPublisher().getExt()).isNotNull(); + .containsOnlyNulls(); + } - final JsonNode prebidNode = site.getPublisher().getExt().getProperty("prebid"); + @Test + public void makeHttpRequestsShouldAddPublisherToAppWhenBidRequestHasAppOnly() { + // given + final BidRequest bidRequest = givenBidRequest( + bidReq -> bidReq.site(null).app(App.builder().id("app_id").build()), + identity(), + identity()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getApp) + .allSatisfy(app -> { + assertThat(app.getId()).isEqualTo("app_id"); + assertThat(app.getPublisher()).isNotNull(); + assertThat(app.getPublisher().getExt()).isNotNull(); + + final JsonNode prebidNode = app.getPublisher().getExt().getProperty("prebid"); assertThat(prebidNode).isNotNull(); assertThat(prebidNode.get("publisherId").asText()).isEqualTo("publisherId"); }); @@ -236,8 +256,10 @@ public void makeHttpRequestsShouldAddPublisherToExistingSiteWhenNoPublisher() { assertThat(result.getErrors()).isEmpty(); assertThat(result.getValue()).hasSize(1) .extracting(HttpRequest::getPayload) - .extracting(BidRequest::getSite) - .allSatisfy(site -> { + .allSatisfy(payload -> { + assertThat(payload.getApp()).isNull(); + + final Site site = payload.getSite(); assertThat(site.getId()).isEqualTo("site_id"); assertThat(site.getName()).isEqualTo("site_name"); assertThat(site.getDomain()).isEqualTo("example.com"); diff --git a/src/test/java/org/prebid/server/bidder/scalibur/ScaliburBidderTest.java b/src/test/java/org/prebid/server/bidder/scalibur/ScaliburBidderTest.java new file mode 100644 index 00000000000..68bff4aaaa6 --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/scalibur/ScaliburBidderTest.java @@ -0,0 +1,971 @@ +package org.prebid.server.bidder.scalibur; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.iab.openrtb.request.Audio; +import com.iab.openrtb.request.Banner; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Video; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import lombok.SneakyThrows; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.prebid.server.VertxTest; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.HttpResponse; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.proto.openrtb.ext.request.scalibur.ExtImpScalibur; +import org.prebid.server.currency.CurrencyConversionService; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; +import org.prebid.server.proto.openrtb.ext.response.BidType; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.UnaryOperator; + +import static java.util.Collections.singletonList; +import static java.util.function.UnaryOperator.identity; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class ScaliburBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "https://{Host}.test.endpoint.com"; + private static final String DEFAULT_BID_CURRENCY = "USD"; + private static final String VAST_XML = """ + """; + + @Mock + private CurrencyConversionService currencyConversionService; + + private ScaliburBidder target; + + @BeforeEach + public void setUp() { + target = new ScaliburBidder(ENDPOINT_URL, currencyConversionService, jacksonMapper); + } + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + assertThatIllegalArgumentException().isThrownBy(() -> + new ScaliburBidder("invalid_url", currencyConversionService, jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldReturnErrorsWithoutRequestWhenAllImpsAreInvalid() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode()))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1); + } + + @Test + public void makeHttpRequestsShouldAddErrorsOnInvalidImps() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode())))), + givenImp(imp -> imp.ext( + givenImpExt(ExtImpScalibur.of(null, null, DEFAULT_BID_CURRENCY, null)))), + givenImp(imp -> imp.ext( + givenImpExt(ExtImpScalibur.of("", null, DEFAULT_BID_CURRENCY, null)))), + // valid imps + givenImp(identity()), + givenImp(imp -> imp.tagid("test-tag").ext( + givenImpExt(ExtImpScalibur.of(null, null, DEFAULT_BID_CURRENCY, null)))), + givenImp(imp -> imp.tagid("test-tag").ext( + givenImpExt(ExtImpScalibur.of("", null, DEFAULT_BID_CURRENCY, null)))), + givenImp(imp -> imp.ext( + givenImpExt(ExtImpScalibur.of("placementId", null, null, null))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .hasSize(4); + assertThat(result.getErrors()) + .extracting(BidderError::getMessage) + .hasSize(3) + .satisfies(errors -> { + assertThat(errors.get(0)).startsWith("Cannot deserialize value of type"); + assertThat(errors.get(1)).isEqualTo( + "imp 123: missing placement; set imp.tagid or the placementId param"); + assertThat(errors.get(2)).isEqualTo( + "imp 123: missing placement; set imp.tagid or the placementId param"); + }); + } + + @Test + public void makeHttpRequestsShouldPreferImpTagIdOverPlacementId() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .tagid("tagId") + .ext(givenImpExt(ExtImpScalibur.of("placementId", null, null, null))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .singleElement() + .extracting(Imp::getTagid) + .isEqualTo("tagId"); + } + + @Test + public void makeHttpRequestsShouldUsePlacementIdWhenImpTagIdIsMissing() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .tagid(null) + .ext(givenImpExt(ExtImpScalibur.of("placementId", null, null, null))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .singleElement() + .extracting(Imp::getTagid) + .isEqualTo("placementId"); + } + + @Test + public void makeHttpRequestsShouldConvertBidFloorIfCurrencyIsDifferent() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.bidfloor(BigDecimal.TEN).bidfloorcur("EUR"))); + + given(currencyConversionService.convertCurrency(BigDecimal.TEN, bidRequest, "EUR", DEFAULT_BID_CURRENCY)) + .willReturn(BigDecimal.ONE); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBidfloor, Imp::getBidfloorcur) + .containsExactly(tuple(BigDecimal.ONE, DEFAULT_BID_CURRENCY)); + } + + @Test + public void makeHttpRequestsShouldNotConvertBidfloorAndAssignUSDCurrencyWhenBidfloorHasEmptyCurrency() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.bidfloor(BigDecimal.TEN).bidfloorcur(null))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBidfloor, Imp::getBidfloorcur) + .containsExactly(tuple(BigDecimal.TEN, DEFAULT_BID_CURRENCY)); + } + + @Test + public void makeHttpRequestsShouldOverrideBidfloorAndCurrencyWhenScaliburBidFloorIsValid() { + // given + + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .bidfloor(BigDecimal.TEN) + .bidfloorcur("EUR") + .ext(givenImpExt( + ExtImpScalibur.of("placementId", BigDecimal.ONE, DEFAULT_BID_CURRENCY, null))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBidfloor, Imp::getBidfloorcur) + .containsExactly(tuple(BigDecimal.ONE, DEFAULT_BID_CURRENCY)); + } + + @Test + public void makeHttpRequestsShouldNotOverrideBidfloorAndCurrencyWhenScaliburBidFloorIsInvalid() { + // given + + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.bidfloor(BigDecimal.TEN).bidfloorcur(DEFAULT_BID_CURRENCY) + .ext(givenImpExt(ExtImpScalibur.of("placementId", null, "EUR", null))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBidfloor, Imp::getBidfloorcur) + .containsExactly(tuple(BigDecimal.TEN, DEFAULT_BID_CURRENCY)); + } + + @Test + public void makeHttpRequestsShouldFallbackToImpCurrencyWhenScaliburCurrencyIsMissing() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.bidfloor(BigDecimal.TEN).bidfloorcur("EUR") + .ext(givenImpExt(ExtImpScalibur.of("placementId", BigDecimal.ONE, null, null))))); + + given(currencyConversionService.convertCurrency(BigDecimal.ONE, bidRequest, "EUR", DEFAULT_BID_CURRENCY)) + .willReturn(BigDecimal.valueOf(2)); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBidfloor, Imp::getBidfloorcur) + .containsExactly(tuple(BigDecimal.valueOf(2), DEFAULT_BID_CURRENCY)); + } + + @Test + public void makeHttpRequestsShouldNotSetPlacementIdInImpExt() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .ext(givenImpExt(ExtImpScalibur.of("placementId", null, null, null))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getExt) + .containsExactly(mapper.valueToTree(ExtImpScalibur.of(null, null, null, null))); + } + + @Test + public void makeHttpRequestsShouldExtractBidderParamsAndKeepFloorAndGpid() { + // given + final ObjectNode impExt = givenImpExt( + ExtImpScalibur.of("placementId", BigDecimal.ONE, DEFAULT_BID_CURRENCY, "host")); + ((ObjectNode) impExt.get("bidder")).put("extraField", "test-custom"); + impExt.put("gpid", "test-gpid"); + impExt.put("random", "test-random"); + + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.ext(impExt))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getExt) + .containsExactly( + mapper.createObjectNode() + .put("bidfloor", BigDecimal.ONE) + .put("bidfloorcur", DEFAULT_BID_CURRENCY) + .put("extraField", "test-custom") + .put("gpid", "test-gpid")); + } + + @Test + public void makeHttpRequestsShouldFillVideoDefaultValues() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .banner(null) + .video(Video.builder().build()))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(HttpRequest::getPayload) + .satisfies(request -> assertThat(request.getImp()) + .singleElement() + .extracting(Imp::getVideo) + .satisfies(video -> { + assertThat(video.getMimes()).containsExactly("video/mp4"); + assertThat(video.getMinduration()).isEqualTo(1); + assertThat(video.getMaxduration()).isEqualTo(180); + assertThat(video.getMaxbitrate()).isEqualTo(30_000); + assertThat(video.getProtocols()).containsExactly(2, 3, 5, 6); + assertThat(video.getW()).isEqualTo(640); + assertThat(video.getH()).isEqualTo(480); + assertThat(video.getPlacement()).isEqualTo(1); + assertThat(video.getLinearity()).isEqualTo(1); + })); + } + + @Test + public void makeHttpRequestsShouldPreserveProvidedVideoValues() { + // given + final Video video = Video.builder() + .mimes(List.of("video/webm")) + .minduration(10) + .maxduration(30) + .maxbitrate(5000) + .protocols(List.of(1, 2)) + .w(1280) + .h(720) + .placement(2) + .linearity(2) + .build(); + + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .banner(null) + .video(video))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(HttpRequest::getPayload) + .satisfies() + .satisfies(request -> assertThat(request.getImp()) + .singleElement() + .extracting(Imp::getVideo) + .isEqualTo(video)); + } + + @Test + public void makeHttpRequestsShouldSetIsDebugWhenTestIsEnabled() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(identity())) + .toBuilder() + .test(1) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getExt) + .satisfies(ext -> assertThat(ext.getProperty("isDebug").asInt()).isEqualTo(1)); + } + + @Test + public void makeHttpRequestsShouldSetIsDebugWhenPrebidDebugIsEnabled() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(identity())) + .toBuilder() + .ext(ExtRequest.of(ExtRequestPrebid.builder().debug(1).build())) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getExt) + .satisfies(ext -> assertThat(ext.getProperty("isDebug").asInt()).isEqualTo(1)); + } + + @Test + public void makeHttpRequestsShouldRemoveRequestExtWhenDebugIsDisabled() { + // given + final ExtRequest extRequest = ExtRequest.empty(); + extRequest.addProperty("random", TextNode.valueOf("test-random")); + + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp)) + .toBuilder() + .ext(extRequest) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getExt) + .isNull(); + } + + @Test + public void makeHttpRequestsShouldUseDefaultHostWhenHostIsMissing() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(identity()), + givenImp(imp -> imp.ext(givenImpExtWithHost(""))) + ); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(HttpRequest::getUri) + .isEqualTo("https://srv.test.endpoint.com"); + } + + @Test + public void makeHttpRequestsShouldGroupMultipleImpsUnderSameHost() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("123").ext(givenImpExtWithHost("host1"))), + givenImp(imp -> imp.id("456").ext(givenImpExtWithHost("host2"))), + givenImp(imp -> imp.id("789").ext(givenImpExtWithHost("host1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(2); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getImp) + .extracting(imps -> imps.stream().map(Imp::getId).toList()) + .containsExactlyInAnyOrder(List.of("123", "789"), List.of("456")); + } + + @Test + public void makeBidsShouldReturnBidsFromAllSeatBids() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(identity()), + givenImp(imp -> imp.id("456"))); + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder.id("bid-1").impid("123"), + bidBuilder -> bidBuilder.id("bid-2").impid("456")))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getBid) + .extracting(Bid::getId) + .containsExactly("bid-1", "bid-2"); + } + + @Test + public void makeBidsShouldUseUsdCurrencyWhenResponseCurrencyIsMissing() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(identity())); + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse(bidBuilder -> bidBuilder.id("bid-1").impid("123")))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getBidCurrency) + .isEqualTo(DEFAULT_BID_CURRENCY); + } + + @Test + public void makeBidsShouldUseResponseCurrency() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(identity())); + final BidResponse bidResponse = givenBidResponse(bidBuilder -> bidBuilder.id("bid-1").impid("123")).toBuilder() + .cur("EUR") + .build(); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString(bidResponse)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getBidCurrency) + .isEqualTo("EUR"); + } + + @Test + public void makeBidsShouldRejectInvalidBidImpId() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(identity())); + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("invalid-imp")))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()) + .singleElement() + .extracting(BidderError::getMessage) + .isEqualTo("Invalid bid imp ID invalid-imp"); + } + + @Test + public void makeBidsShouldResolveBannerBidTypeFromMtype() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(identity())); + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .mtype(1)))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getType) + .isEqualTo(BidType.banner); + } + + @Test + public void makeBidsShouldResolveVideoBidTypeFromMtype() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(imp -> imp.banner(null).video(Video.builder().build()))); + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .mtype(2)))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getType) + .isEqualTo(BidType.video); + } + + @Test + public void makeBidsShouldResolveBidTypeFromImpWhenMtypeIsMissing() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.banner(Banner.builder().w(1).h(1).build()).video(null))); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .mtype(null)))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getType) + .isEqualTo(BidType.banner); + } + + @Test + public void makeBidsShouldResolveVideoBidTypeFromVideoImpWhenMtypeIsMissing() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.banner(null).video(Video.builder().build()))); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .mtype(null)))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getType) + .isEqualTo(BidType.video); + } + + @Test + public void makeBidsShouldRecoverMediaTypeFromImpWhenMtypeIsInvalid() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(identity())); + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .mtype(3)))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getType) + .isEqualTo(BidType.banner); + } + + @Test + public void makeBidsShouldRejectAmbiguousImpMediaType() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .banner(Banner.builder().w(1).h(1).build()) + .video(Video.builder().build()))); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .mtype(null)))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()) + .singleElement() + .extracting(BidderError::getMessage) + .isEqualTo("Unsupported or ambiguous media type for bid id=bid-1"); + } + + @Test + public void makeBidsShouldRejectInvalidMediaType() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .banner(null) + .audio(Audio.builder().build()))); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .mtype(3)))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()) + .singleElement() + .extracting(BidderError::getMessage) + .isEqualTo("Unsupported or ambiguous media type for bid id=bid-1"); + } + + @Test + public void makeBidsShouldReplaceAdmWithVastXml() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.banner(null).video(Video.builder().build()))); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .ext(mapper.createObjectNode().put("vastXml", "test"))))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getBid) + .extracting(Bid::getAdm) + .isEqualTo("test"); + } + + @Test + public void makeBidsShouldReplaceNotPresentAdmWithVastUrlWrapper() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.banner(null).video(Video.builder().build()))); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .ext(mapper.createObjectNode().put("vastUrl", "https://test.com"))))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getBid) + .extracting(Bid::getAdm) + .isEqualTo(VAST_XML.formatted("https://test.com")); + } + + @Test + public void makeBidsShouldPrioritizeVastXmlOverVastUrlWhenBothArePresent() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.banner(null).video(Video.builder().build()))); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .ext(mapper.createObjectNode() + .put("vastXml", "test") + .put("vastUrl", "https://test.com"))))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getBid) + .extracting(Bid::getAdm) + .isEqualTo("test"); + } + + @Test + public void makeBidsShouldPreserveAdmWhenVastUrlIsPresent() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.banner(null).video(Video.builder().build()))); + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .adm("test-adm") + .ext(mapper.createObjectNode().put("vastUrl", "https://test.com"))))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getBid) + .extracting(Bid::getAdm) + .isEqualTo("test-adm"); + } + + @Test + public void makeBidsShouldReturnEmptyResultWhenResponseHasNoSeatBids() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(identity())); + final BidResponse bidResponse = BidResponse.builder() + .seatbid(Collections.emptyList()) + .build(); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString(bidResponse)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnErrorIfResponseBodyCouldNotBeParsed() { + // given + final BidderCall httpCall = givenHttpCall(givenBidRequest(givenImp(identity())), "invalid"); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).allMatch(error -> error.getType() == BidderError.Type.bad_server_response + && error.getMessage().startsWith("Failed to decode: Unrecognized token")); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnValidBidsAndErrorsForInvalidBids() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(identity())); + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder.id("valid-bid").impid("123"), + bidBuilder -> bidBuilder.id("invalid-bid").impid("invalid-imp")))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getValue()) + .extracting(BidderBid::getBid) + .extracting(Bid::getId) + .containsExactly("valid-bid"); + + assertThat(result.getErrors()) + .singleElement() + .extracting(BidderError::getMessage) + .isEqualTo("Invalid bid imp ID invalid-imp"); + } + + private static BidRequest givenBidRequest(Imp... imps) { + return BidRequest.builder() + .cur(List.of("USD")) + .imp(Arrays.stream(imps).toList()) + .build(); + } + + private static Imp givenImp(UnaryOperator impCustomizer) { + return impCustomizer.apply(Imp.builder() + .id("123") + .banner(Banner.builder().w(1).h(1).build()) + .ext(givenImpExt(ExtImpScalibur.of("placementId", null, null, null)))) + .build(); + } + + private static ObjectNode givenImpExtWithHost(String host) { + return givenImpExt(ExtImpScalibur.of("placementId", null, null, host)); + } + + private static ObjectNode givenImpExt(ExtImpScalibur extImpScalibur) { + return mapper.valueToTree(ExtPrebid.of(null, extImpScalibur)); + } + + @SafeVarargs + @SneakyThrows + private BidResponse givenBidResponse(UnaryOperator... bidCustomizers) { + return BidResponse.builder() + .seatbid(singletonList(SeatBid.builder() + .bid(Arrays.stream(bidCustomizers) + .map(bidCustomizer -> bidCustomizer.apply(Bid.builder()).build()) + .toList()) + .build())) + .build(); + } + + private static BidderCall givenHttpCall(BidRequest bidRequest, String body) { + return BidderCall.succeededHttp( + HttpRequest.builder().payload(bidRequest).build(), + HttpResponse.of(200, null, body), + null); + } +} diff --git a/src/test/java/org/prebid/server/bidder/sevio/SevioBidderTest.java b/src/test/java/org/prebid/server/bidder/sevio/SevioBidderTest.java new file mode 100644 index 00000000000..8a026f91ae4 --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/sevio/SevioBidderTest.java @@ -0,0 +1,219 @@ +package org.prebid.server.bidder.sevio; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import org.junit.jupiter.api.Test; +import org.prebid.server.VertxTest; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.HttpResponse; +import org.prebid.server.bidder.model.Result; + +import java.util.Arrays; +import java.util.List; +import java.util.function.UnaryOperator; + +import static java.util.Collections.singletonList; +import static java.util.function.UnaryOperator.identity; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.prebid.server.proto.openrtb.ext.response.BidType.banner; +import static org.prebid.server.proto.openrtb.ext.response.BidType.xNative; +import static org.prebid.server.util.HttpUtil.ACCEPT_HEADER; +import static org.prebid.server.util.HttpUtil.APPLICATION_JSON_CONTENT_TYPE; +import static org.prebid.server.util.HttpUtil.CONTENT_TYPE_HEADER; +import static org.prebid.server.util.HttpUtil.X_OPENRTB_VERSION_HEADER; +import static org.springframework.util.MimeTypeUtils.APPLICATION_JSON_VALUE; + +public class SevioBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "https://req.adx.ws/rtb"; + + private final SevioBidder target = new SevioBidder(ENDPOINT_URL, jacksonMapper); + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + assertThatIllegalArgumentException().isThrownBy(() -> new SevioBidder("invalid_url", jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldReturnExpectedRequestWithAllImps() { + // given + final BidRequest bidRequest = givenBidRequest( + imp -> imp.id("impId1"), + imp -> imp.id("impId2")); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1).first() + .satisfies(request -> assertThat(request.getBody()) + .isEqualTo(jacksonMapper.encodeToBytes(bidRequest))) + .satisfies(request -> assertThat(request.getPayload()) + .isEqualTo(bidRequest)) + .satisfies(request -> assertThat(request.getImpIds()) + .containsExactlyInAnyOrder("impId1", "impId2")); + } + + @Test + public void makeHttpRequestsShouldReturnExpectedHeaders() { + // given + final BidRequest bidRequest = givenBidRequest( + imp -> imp.id("impId1"), + imp -> imp.id("impId2")); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1).first() + .extracting(HttpRequest::getHeaders) + .satisfies(headers -> assertThat(headers.get(CONTENT_TYPE_HEADER)) + .isEqualTo(APPLICATION_JSON_CONTENT_TYPE)) + .satisfies(headers -> assertThat(headers.get(ACCEPT_HEADER)) + .isEqualTo(APPLICATION_JSON_VALUE)) + .satisfies(headers -> assertThat(headers.get(X_OPENRTB_VERSION_HEADER)) + .isEqualTo("2.6")); + } + + @Test + public void makeHttpRequestsShouldUseCorrectUri() { + // given + final BidRequest bidRequest = givenBidRequest(identity()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1).first() + .extracting(HttpRequest::getUri) + .isEqualTo(ENDPOINT_URL); + } + + @Test + public void makeBidsShouldReturnErrorIfResponseBodyCouldNotBeParsed() { + // given + final BidderCall httpCall = givenHttpCall("invalid"); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response); + assertThat(error.getMessage()).startsWith("Failed to decode: Unrecognized token"); + }); + } + + @Test + public void makeBidsShouldReturnEmptyListIfBidResponseSeatBidIsNull() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(mapper.writeValueAsString(BidResponse.builder().build())); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnBannerBidWhenMtypeIsBanner() throws JsonProcessingException { + // given + final Bid bannerBid = Bid.builder().impid("1").mtype(1).build(); + + final BidderCall httpCall = givenHttpCall(givenBidResponse(bannerBid)); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).containsOnly(BidderBid.of(bannerBid, banner, "USD")); + } + + @Test + public void makeBidsShouldReturnNativeBidWhenMtypeIsNative() throws JsonProcessingException { + // given + final Bid nativeBid = Bid.builder().impid("4").mtype(4).build(); + + final BidderCall httpCall = givenHttpCall(givenBidResponse(nativeBid)); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).containsOnly(BidderBid.of(nativeBid, xNative, "USD")); + } + + @Test + public void makeBidsShouldReturnErrorWhenMtypeIsNotSupported() throws JsonProcessingException { + // given + final Bid bannerBid = Bid.builder().impid("id").mtype(1).build(); + final Bid audioBid = Bid.builder().impid("id").mtype(3).build(); + + final BidderCall httpCall = givenHttpCall(givenBidResponse(bannerBid, audioBid)); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).containsExactly( + BidderError.badServerResponse("failed to parse bid mtype (3) for impression id \"id\"")); + assertThat(result.getValue()).containsOnly(BidderBid.of(bannerBid, banner, "USD")); + } + + @Test + public void makeBidsShouldReturnErrorWhenMtypeIsMissing() throws JsonProcessingException { + // given + final Bid bid = Bid.builder().id("bidId").impid("1").build(); + + final BidderCall httpCall = givenHttpCall(givenBidResponse(bid)); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).containsExactly( + BidderError.badServerResponse("Missing MType for bid: bidId")); + } + + private static BidRequest givenBidRequest(UnaryOperator... impCustomizers) { + return BidRequest.builder() + .imp(Arrays.stream(impCustomizers).map(SevioBidderTest::givenImp).toList()) + .build(); + } + + private static Imp givenImp(UnaryOperator impCustomizer) { + return impCustomizer.apply(Imp.builder().id("impId")).build(); + } + + private static String givenBidResponse(Bid... bids) throws JsonProcessingException { + return mapper.writeValueAsString(BidResponse.builder() + .cur("USD") + .seatbid(singletonList(SeatBid.builder().bid(List.of(bids)).build())) + .build()); + } + + private static BidderCall givenHttpCall(String body) { + return BidderCall.succeededHttp( + HttpRequest.builder().build(), + HttpResponse.of(200, null, body), + null); + } +} diff --git a/src/test/java/org/prebid/server/bidder/synapsehx/SynapseHXBidderTest.java b/src/test/java/org/prebid/server/bidder/synapsehx/SynapseHXBidderTest.java new file mode 100644 index 00000000000..f1b81ec0f06 --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/synapsehx/SynapseHXBidderTest.java @@ -0,0 +1,261 @@ +package org.prebid.server.bidder.synapsehx; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.Banner; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import io.netty.handler.codec.http.HttpHeaderValues; +import io.vertx.core.MultiMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.prebid.server.VertxTest; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.HttpResponse; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.synapsehx.ExtImpSynapseHX; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.util.HttpUtil; + +import java.util.List; +import java.util.Map; +import java.util.function.UnaryOperator; + +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static java.util.function.UnaryOperator.identity; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.tuple; + +public class SynapseHXBidderTest extends VertxTest { + + private SynapseHXBidder target; + + @BeforeEach + public void setUp() { + target = new SynapseHXBidder("http://test.endpoint.com", jacksonMapper); + } + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + assertThatIllegalArgumentException().isThrownBy( + () -> new SynapseHXBidder("invalid_url", jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldCorrectlyAddHeaders() { + // given + final BidRequest bidRequest = givenBidRequest(identity()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()) + .extracting(HttpRequest::getHeaders) + .flatExtracting(MultiMap::entries) + .extracting(Map.Entry::getKey, Map.Entry::getValue) + .containsExactlyInAnyOrder( + tuple(HttpUtil.CONTENT_TYPE_HEADER.toString(), HttpUtil.APPLICATION_JSON_CONTENT_TYPE), + tuple(HttpUtil.ACCEPT_HEADER.toString(), HttpHeaderValues.APPLICATION_JSON.toString()), + tuple(HttpUtil.X_OPENRTB_VERSION_HEADER.toString(), "2.6")); + } + + @Test + public void makeHttpRequestsShouldReturnErrorWhenImpressionContainsInvalidBidderParameters() { + // given + final ObjectNode invalidExt = mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode())); + + final BidRequest bidRequest = givenBidRequest(identity(), + impBuilder -> impBuilder.id("Imp01").ext(invalidExt)); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).satisfiesExactly( + error -> assertThat(error.getMessage()).startsWith("Failed to parse bidder parameters")); + } + + @Test + public void makeBidsShouldReturnErrorIfResponseBodyCouldNotBeParsed() { + // given + final BidderCall httpCall = givenHttpCall(null, "invalid"); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response); + assertThat(error.getMessage()).startsWith("Failed to decode: Unrecognized token"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnEmptyListIfBidResponseIsNull() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(null, mapper.writeValueAsString(null)); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnEmptyListIfBidResponseSeatBidIsNull() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(null, + mapper.writeValueAsString(BidResponse.builder().build())); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldSkipBidWithUnsupportedBidTypeAndKeepValidBid() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(null, + givenBidResponse( + givenBidWithMType("Imp1", BidType.banner), + givenBidWithMType("Imp2", BidType.audio))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).satisfiesExactly( + error -> assertThat(error.getMessage()).startsWith("Unsupported media type")); + assertThat(result.getValue()).satisfiesExactly( + bid -> assertThat(bid.getBid().getImpid()).isEqualTo("Imp1")); + } + + @Test + public void makeBidsShouldReportErrorsSkipForBidsWithUnsupportedOrMissingBidType() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(null, + givenBidResponse( + givenBidWithMType("Imp1", BidType.xNative), + givenBidWithMType("Imp2", BidType.audio), + givenBid("Imp3", BidType.xNative), + givenBid("Imp4", BidType.audio), + givenBid("Imp9", BidType.banner).toBuilder().ext(null).build())); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).hasSize(5).allSatisfy( + error -> assertThat(error.getMessage()).startsWith("Unsupported media type")); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsCorrectlyRecogniseBidTypeWithFallback() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(null, + givenBidResponse( + givenBidWithMType("Imp1", BidType.banner), + givenBidWithMType("Imp2", BidType.video), + givenBid("Imp3", BidType.banner), + givenBid("Imp4", BidType.video))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).satisfiesExactlyInAnyOrder( + bid -> { + assertThat(bid.getBid().getImpid()).isEqualTo("Imp1"); + assertThat(bid.getType()).isEqualTo(BidType.banner); + }, + bid -> { + assertThat(bid.getBid().getImpid()).isEqualTo("Imp2"); + assertThat(bid.getType()).isEqualTo(BidType.video); + }, + bid -> { + assertThat(bid.getBid().getImpid()).isEqualTo("Imp3"); + assertThat(bid.getType()).isEqualTo(BidType.banner); + }, + bid -> { + assertThat(bid.getBid().getImpid()).isEqualTo("Imp4"); + assertThat(bid.getType()).isEqualTo(BidType.video); + } + ); + } + + private static BidRequest givenBidRequest(UnaryOperator impCustomizer) { + return givenBidRequest(identity(), impCustomizer); + } + + private static BidRequest givenBidRequest( + UnaryOperator bidRequestCustomizer, + UnaryOperator impCustomizer) { + + return bidRequestCustomizer.apply(BidRequest.builder() + .imp(singletonList(givenImp(impCustomizer)))) + .build(); + } + + private static Imp givenImp(UnaryOperator impCustomizer) { + return givenImp(impCustomizer, ExtImpSynapseHX.of("tenant-id")); + } + + private static Imp givenImp(UnaryOperator impCustomizer, ExtImpSynapseHX extImp) { + return impCustomizer.apply(Imp.builder() + .id("345") + .banner(Banner.builder().build()) + .ext(mapper.valueToTree(ExtPrebid.of(null, extImp)))) + .build(); + } + + private static BidderCall givenHttpCall(BidRequest bidRequest, String body) { + return BidderCall.succeededHttp( + HttpRequest.builder().payload(bidRequest).build(), + HttpResponse.of(200, null, body), + null); + } + + private BidderCall givenHttpCall(BidRequest bidRequest, BidResponse bidResponse) + throws JsonProcessingException { + return givenHttpCall(bidRequest, mapper.writeValueAsString(bidResponse)); + } + + private static BidResponse givenBidResponse(Bid... bids) { + return BidResponse.builder().seatbid(singletonList( + SeatBid.builder().bid(asList(bids)).build())).build(); + } + + private static Bid givenBid(String impid, BidType bidType) { + return Bid.builder() + .impid(impid) + .ext(mapper.createObjectNode() + .set("prebid", mapper.createObjectNode() + .put("type", bidType.getName()))) + .build(); + } + + private static Bid givenBidWithMType(String impid, BidType bidType) { + return Bid.builder().impid(impid).mtype(bidType.ordinal() + 1).build(); + } + +} diff --git a/src/test/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidderTest.java b/src/test/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidderTest.java index 46db30df08c..06e6c25817a 100644 --- a/src/test/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidderTest.java +++ b/src/test/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidderTest.java @@ -35,6 +35,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.tuple; +import static org.prebid.server.proto.openrtb.ext.response.BidType.audio; import static org.prebid.server.proto.openrtb.ext.response.BidType.banner; import static org.prebid.server.proto.openrtb.ext.response.BidType.video; import static org.prebid.server.proto.openrtb.ext.response.BidType.xNative; @@ -429,6 +430,21 @@ public void makeBidsShouldReturnxNativeBid() throws JsonProcessingException { .containsExactly(BidderBid.of(Bid.builder().impid("123").mtype(4).build(), xNative, "USD")); } + @Test + public void makeBidsShouldReturnAudioBid() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder.mtype(3).impid("123"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .containsExactly(BidderBid.of(Bid.builder().impid("123").mtype(3).build(), audio, "USD")); + } + @Test public void makeBidsShouldReturnBannerBid() throws JsonProcessingException { // given @@ -483,7 +499,7 @@ public void makeBidsShouldReturnValidBidsAndErrorsForMixedMediaTypes() throws Js .seatbid(singletonList(SeatBid.builder() .bid(Arrays.asList( Bid.builder().mtype(1).impid("valid1").build(), // valid banner - Bid.builder().mtype(3).impid("invalid1").build(), // invalid mtype + Bid.builder().mtype(5).impid("invalid1").build(), // invalid mtype Bid.builder().mtype(2).impid("valid2").build(), // valid video Bid.builder().mtype(null).impid("invalid2").build() // null mtype )) diff --git a/src/test/java/org/prebid/server/it/AdswagTest.java b/src/test/java/org/prebid/server/it/AdswagTest.java new file mode 100644 index 00000000000..a80748e57ad --- /dev/null +++ b/src/test/java/org/prebid/server/it/AdswagTest.java @@ -0,0 +1,32 @@ +package org.prebid.server.it; + +import io.restassured.response.Response; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.prebid.server.model.Endpoint; + +import java.io.IOException; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static java.util.Collections.singletonList; + +public class AdswagTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromAdswag() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/adswag-exchange")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/adswag/test-adswag-bid-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/adswag/test-adswag-bid-response.json")))); + + // when + final Response response = responseFor("openrtb2/adswag/test-auction-adswag-request.json", + Endpoint.openrtb2_auction); + + // then + assertJsonEquals("openrtb2/adswag/test-auction-adswag-response.json", response, singletonList("adswag")); + } +} diff --git a/src/test/java/org/prebid/server/it/AppMonstaMediaTest.java b/src/test/java/org/prebid/server/it/AppMonstaMediaTest.java new file mode 100644 index 00000000000..2c00ea3362f --- /dev/null +++ b/src/test/java/org/prebid/server/it/AppMonstaMediaTest.java @@ -0,0 +1,36 @@ +package org.prebid.server.it; + +import io.restassured.response.Response; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.prebid.server.model.Endpoint; + +import java.io.IOException; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; + +public class AppMonstaMediaTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromAppMonstaMedia() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/appMonstaMedia-exchange/")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/appMonstaMedia/test-appMonstaMedia-bid-request.json"))) + .willReturn(aResponse() + .withBody(jsonFrom("openrtb2/appMonstaMedia/test-appMonstaMedia-bid-response.json")))); + + // when + final Response response = responseFor( + "openrtb2/appMonstaMedia/test-auction-appMonstaMedia-request.json", + Endpoint.openrtb2_auction); + + // then + assertJsonEquals("openrtb2/appMonstaMedia/test-auction-appMonstaMedia-response.json", response, + List.of("appMonstaMedia")); + } + +} diff --git a/src/test/java/org/prebid/server/it/BidwaveTest.java b/src/test/java/org/prebid/server/it/BidwaveTest.java new file mode 100644 index 00000000000..3ad87828e83 --- /dev/null +++ b/src/test/java/org/prebid/server/it/BidwaveTest.java @@ -0,0 +1,32 @@ +package org.prebid.server.it; + +import io.restassured.response.Response; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.prebid.server.model.Endpoint; + +import java.io.IOException; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static java.util.Collections.singletonList; + +public class BidwaveTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromBidwave() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/bidwave-exchange")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/bidwave/test-bidwave-bid-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/bidwave/test-bidwave-bid-response.json")))); + + // when + final Response response = responseFor("openrtb2/bidwave/test-auction-bidwave-request.json", + Endpoint.openrtb2_auction); + + // then + assertJsonEquals("openrtb2/bidwave/test-auction-bidwave-response.json", response, singletonList("bidwave")); + } +} diff --git a/src/test/java/org/prebid/server/it/FloxisTest.java b/src/test/java/org/prebid/server/it/FloxisTest.java new file mode 100644 index 00000000000..429948bfb45 --- /dev/null +++ b/src/test/java/org/prebid/server/it/FloxisTest.java @@ -0,0 +1,32 @@ +package org.prebid.server.it; + +import io.restassured.response.Response; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.prebid.server.model.Endpoint; + +import java.io.IOException; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static java.util.Collections.singletonList; + +public class FloxisTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromFloxis() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/floxis-exchange")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/floxis/test-floxis-bid-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/floxis/test-floxis-bid-response.json")))); + + // when + final Response response = responseFor("openrtb2/floxis/test-auction-floxis-request.json", + Endpoint.openrtb2_auction); + + // then + assertJsonEquals("openrtb2/floxis/test-auction-floxis-response.json", response, singletonList("floxis")); + } +} diff --git a/src/test/java/org/prebid/server/it/ScaliburTest.java b/src/test/java/org/prebid/server/it/ScaliburTest.java new file mode 100644 index 00000000000..cd33f769458 --- /dev/null +++ b/src/test/java/org/prebid/server/it/ScaliburTest.java @@ -0,0 +1,33 @@ +package org.prebid.server.it; + +import io.restassured.response.Response; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.prebid.server.model.Endpoint; + +import java.io.IOException; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; + +public class ScaliburTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromScalibur() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/scalibur-exchange")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/scalibur/test-scalibur-bid-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/scalibur/test-scalibur-bid-response.json")))); + + // when + final Response response = responseFor( + "openrtb2/scalibur/test-auction-scalibur-request.json", + Endpoint.openrtb2_auction); + + // then + assertJsonEquals("openrtb2/scalibur/test-auction-scalibur-response.json", response, List.of("scalibur")); + } +} diff --git a/src/test/java/org/prebid/server/it/SevioTest.java b/src/test/java/org/prebid/server/it/SevioTest.java new file mode 100644 index 00000000000..d3cb42b7e53 --- /dev/null +++ b/src/test/java/org/prebid/server/it/SevioTest.java @@ -0,0 +1,37 @@ +package org.prebid.server.it; + +import io.restassured.response.Response; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.prebid.server.model.Endpoint; + +import java.io.IOException; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static java.util.Collections.singletonList; + +public class SevioTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromSevio() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/sevio-exchange")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/sevio/test-sevio-bid-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/sevio/test-sevio-bid-response.json")))); + + // when + final Response response = responseFor( + "openrtb2/sevio/test-auction-sevio-request.json", + Endpoint.openrtb2_auction); + + // then + assertJsonEquals( + "openrtb2/sevio/test-auction-sevio-response.json", + response, + singletonList("sevio")); + } + +} diff --git a/src/test/java/org/prebid/server/it/SynapseHXTest.java b/src/test/java/org/prebid/server/it/SynapseHXTest.java new file mode 100644 index 00000000000..8d7d51957e4 --- /dev/null +++ b/src/test/java/org/prebid/server/it/SynapseHXTest.java @@ -0,0 +1,33 @@ +package org.prebid.server.it; + +import io.restassured.response.Response; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.prebid.server.model.Endpoint; + +import java.io.IOException; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static java.util.Collections.singletonList; + +public class SynapseHXTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromSynapseHX() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/synapsehx-exchange")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/synapsehx/test-synapsehx-bid-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/synapsehx/test-synapsehx-bid-response.json")))); + + // when + final Response response = responseFor("openrtb2/synapsehx/test-auction-synapsehx-request.json", + Endpoint.openrtb2_auction); + + // then + assertJsonEquals("openrtb2/synapsehx/test-auction-synapsehx-response.json", + response, singletonList("synapsehx")); + } +} diff --git a/src/test/java/org/prebid/server/metric/MetricsTest.java b/src/test/java/org/prebid/server/metric/MetricsTest.java index 0eed97eab2d..79b434ec4d4 100644 --- a/src/test/java/org/prebid/server/metric/MetricsTest.java +++ b/src/test/java/org/prebid/server/metric/MetricsTest.java @@ -601,6 +601,15 @@ public void updateAdapterRequestGotbidsMetricsShouldIncrementMetrics() { .isEqualTo(2); } + @Test + public void updateAccountRequestGotbidsMetricsShouldIncrementMetric() { + // when + metrics.updateAccountRequestGotbidsMetric(ACCOUNT_ID); + + // then + assertThat(metricRegistry.counter("account.accountId.requests.gotbids").getCount()).isOne(); + } + @Test public void updateAdapterBidMetricsShouldUpdateMetrics() { // when diff --git a/src/test/java/org/prebid/server/validation/ImpValidatorTest.java b/src/test/java/org/prebid/server/validation/ImpValidatorTest.java index bbc329fbd07..a2143f42aaf 100644 --- a/src/test/java/org/prebid/server/validation/ImpValidatorTest.java +++ b/src/test/java/org/prebid/server/validation/ImpValidatorTest.java @@ -1298,15 +1298,15 @@ public void validateImpsShouldReturnValidationMessageWhenMetricTypeNullOrEmpty() } @Test - public void validateImpsShouldReturnValidationMessageWhenMetricValueIsNotValid() { + public void validateImpsShouldReturnValidationMessageWhenMetricValueNullOrEmpty() { // given final List givenImps = singletonList(validImpBuilder() - .metric(singletonList(Metric.builder().type("viewability").value(2.0f).build())).build()); + .metric(singletonList(Metric.builder().type("viewability").value(null).build())).build()); // when & then assertThatThrownBy(() -> target.validateImps(givenImps, Collections.emptyMap(), null)) .isInstanceOf(ValidationException.class) - .hasMessage("request.imp[0].metric[0].value must be in the range [0.0, 1.0]"); + .hasMessage("Missing request.imp[0].metric[0].value"); } @Test @@ -2370,15 +2370,15 @@ public void validateImpShouldReturnValidationMessageWhenMetricTypeNullOrEmpty() } @Test - public void validateImpShouldReturnValidationMessageWhenMetricValueIsNotValid() { + public void validateImpShouldReturnValidationMessageWhenMetricValueNullOrEmpty() { // given final Imp givenImp = validImpBuilder() - .metric(singletonList(Metric.builder().type("viewability").value(2.0f).build())).build(); + .metric(singletonList(Metric.builder().type("viewability").value(null).build())).build(); // when & then assertThatThrownBy(() -> target.validateImp(givenImp)) .isInstanceOf(ValidationException.class) - .hasMessage("imp[id=200].metric[0].value must be in the range [0.0, 1.0]"); + .hasMessage("Missing imp[id=200].metric[0].value"); } private static List givenImps(UnaryOperator nativeCustomizer) { diff --git a/src/test/resources/org/prebid/server/it/openrtb2/adswag/test-adswag-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/adswag/test-adswag-bid-request.json new file mode 100644 index 00000000000..152741f4075 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/adswag/test-adswag-bid-request.json @@ -0,0 +1,69 @@ +{ + "id": "test-request-banner", + "imp": [ + { + "id": "test-imp-banner", + "banner": { + "format": [ + { + "w": 300, + "h": 250 + } + ] + }, + "secure": 1, + "ext": { + "tid": "${json-unit.any-string}", + "adswag": { + "placement_id": "prebid-test-display" + } + } + } + ], + "site": { + "id": "demo-site", + "domain": "example.com", + "page": "https://example.com/demo", + "publisher": { + "id": "prebid-test", + "domain": "example.com" + }, + "ext": { + "amp": 0 + } + }, + "device": { + "dnt": 0, + "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "ip": "192.0.2.1", + "language": "en" + }, + "user": { + "id": "demo-user" + }, + "at": 1, + "tmax": "${json-unit.any-number}", + "cur": [ + "EUR" + ], + "source": { + "tid": "${json-unit.any-string}" + }, + "regs": { + "gdpr": 0 + }, + "ext": { + "prebid": { + "channel": { + "name": "web" + }, + "server": { + "externalurl": "http://localhost:8080", + "gvlid": 1, + "datacenter": "local", + "http_method": "POST", + "endpoint": "/openrtb2/auction" + } + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/adswag/test-adswag-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/adswag/test-adswag-bid-response.json new file mode 100644 index 00000000000..a78750b9c94 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/adswag/test-adswag-bid-response.json @@ -0,0 +1,25 @@ +{ + "id": "test-request-banner", + "cur": "EUR", + "seatbid": [ + { + "seat": "adswag", + "bid": [ + { + "id": "req-abc-1", + "impid": "test-imp-banner", + "price": 2.50, + "burl": "https://ads.adswag.ai/v1/win?sc=c2ln&sig=YWJj&price=${AUCTION_PRICE}", + "cid": "cmp-test-1", + "crid": "cr-test-1", + "adomain": ["advertiser.example"], + "ext": { + "adswag": { + "serve_url": "https://ads.adswag.ai/v1/ad?sc=c2ln&sig=YWJj" + } + } + } + ] + } + ] +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/adswag/test-auction-adswag-request.json b/src/test/resources/org/prebid/server/it/openrtb2/adswag/test-auction-adswag-request.json new file mode 100644 index 00000000000..f4573456514 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/adswag/test-auction-adswag-request.json @@ -0,0 +1,45 @@ +{ + "id": "test-request-banner", + "imp": [ + { + "id": "test-imp-banner", + "banner": { + "format": [ + { + "w": 300, + "h": 250 + } + ] + }, + "ext": { + "adswag": { + "publisherId": "prebid-test", + "placementId": "prebid-test-display" + } + } + } + ], + "site": { + "id": "demo-site", + "domain": "example.com", + "page": "https://example.com/demo", + "publisher": { + "id": "demo-publisher" + } + }, + "device": { + "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "ip": "192.0.2.1", + "language": "en", + "dnt": 0 + }, + "user": { + "id": "demo-user" + }, + "cur": ["EUR"], + "regs": { + "ext": { + "gdpr": 0 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/adswag/test-auction-adswag-response.json b/src/test/resources/org/prebid/server/it/openrtb2/adswag/test-auction-adswag-response.json new file mode 100644 index 00000000000..4dac5a13406 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/adswag/test-auction-adswag-response.json @@ -0,0 +1,48 @@ +{ + "id": "test-request-banner", + "seatbid": [ + { + "seat": "adswag", + "bid": [ + { + "id": "req-abc-1", + "impid": "test-imp-banner", + "price": 2.50, + "burl": "https://ads.adswag.ai/v1/win?sc=c2ln&sig=YWJj&price=${AUCTION_PRICE}", + "cid": "cmp-test-1", + "crid": "cr-test-1", + "adomain": ["advertiser.example"], + "adm": "", + "w": 300, + "h": 250, + "exp": 300, + "mtype": 1, + "ext": { + "adswag": { + "serve_url": "https://ads.adswag.ai/v1/ad?sc=c2ln&sig=YWJj" + }, + "origbidcpm": 2.50, + "origbidcur": "EUR", + "prebid": { + "type": "banner", + "meta": { + "adaptercode": "adswag" + } + } + } + } + ], + "group": 0 + } + ], + "cur": "EUR", + "ext": { + "responsetimemillis": { + "adswag": "{{ adswag.response_time_ms }}" + }, + "prebid": { + "auctiontimestamp": 0 + }, + "tmaxrequest": 5000 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/appMonstaMedia/test-appMonstaMedia-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/appMonstaMedia/test-appMonstaMedia-bid-request.json new file mode 100644 index 00000000000..3f7a5e60358 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/appMonstaMedia/test-appMonstaMedia-bid-request.json @@ -0,0 +1,57 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "banner": { + "w": 300, + "h": 250 + }, + "secure": 1, + "ext": { + "bidder": { + "type": "publisher", + "placementId": "testPlacementId" + } + } + } + ], + "site": { + "domain": "www.example.com", + "page": "http://www.example.com", + "publisher": { + "domain": "example.com" + }, + "ext": { + "amp": 0 + } + }, + "device": { + "ua": "userAgent", + "ip": "193.168.244.1" + }, + "at": 1, + "tmax": "${json-unit.any-number}", + "cur": [ + "USD" + ], + "source": { + "tid": "${json-unit.any-string}" + }, + "regs": { + "ext": { + "gdpr": 0 + } + }, + "ext": { + "prebid": { + "server": { + "externalurl": "http://localhost:8080", + "gvlid": 1, + "datacenter": "local", + "http_method": "POST", + "endpoint": "/openrtb2/auction" + } + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/appMonstaMedia/test-appMonstaMedia-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/appMonstaMedia/test-appMonstaMedia-bid-response.json new file mode 100644 index 00000000000..180173549d8 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/appMonstaMedia/test-appMonstaMedia-bid-response.json @@ -0,0 +1,21 @@ +{ + "id": "request_id", + "seatbid": [ + { + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "price": 3.33, + "crid": "creativeId", + "mtype": 1, + "ext": { + "prebid": { + "type": "banner" + } + } + } + ] + } + ] +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/appMonstaMedia/test-auction-appMonstaMedia-request.json b/src/test/resources/org/prebid/server/it/openrtb2/appMonstaMedia/test-auction-appMonstaMedia-request.json new file mode 100644 index 00000000000..dfc6fa5eb8b --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/appMonstaMedia/test-auction-appMonstaMedia-request.json @@ -0,0 +1,23 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "banner": { + "w": 300, + "h": 250 + }, + "ext": { + "appMonstaMedia": { + "placementId": "testPlacementId" + } + } + } + ], + "tmax": 5000, + "regs": { + "ext": { + "gdpr": 0 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/appMonstaMedia/test-auction-appMonstaMedia-response.json b/src/test/resources/org/prebid/server/it/openrtb2/appMonstaMedia/test-auction-appMonstaMedia-response.json new file mode 100644 index 00000000000..a35eca173d9 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/appMonstaMedia/test-auction-appMonstaMedia-response.json @@ -0,0 +1,38 @@ +{ + "id": "request_id", + "seatbid": [ + { + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "exp": 300, + "price": 3.33, + "crid": "creativeId", + "mtype": 1, + "ext": { + "origbidcpm": 3.33, + "prebid": { + "type": "banner", + "meta": { + "adaptercode": "appMonstaMedia" + } + } + } + } + ], + "seat": "appMonstaMedia", + "group": 0 + } + ], + "cur": "USD", + "ext": { + "responsetimemillis": { + "appMonstaMedia": "{{ appMonstaMedia.response_time_ms }}" + }, + "prebid": { + "auctiontimestamp": 0 + }, + "tmaxrequest": 5000 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/bidwave/test-auction-bidwave-request.json b/src/test/resources/org/prebid/server/it/openrtb2/bidwave/test-auction-bidwave-request.json new file mode 100644 index 00000000000..26df715cbd5 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/bidwave/test-auction-bidwave-request.json @@ -0,0 +1,23 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "banner": { + "w": 300, + "h": 250 + }, + "ext": { + "bidwave": { + "publisherId": "11111111-1111-4111-8111-111111111111" + } + } + } + ], + "tmax": 5000, + "regs": { + "ext": { + "gdpr": 0 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/bidwave/test-auction-bidwave-response.json b/src/test/resources/org/prebid/server/it/openrtb2/bidwave/test-auction-bidwave-response.json new file mode 100644 index 00000000000..59c661d9251 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/bidwave/test-auction-bidwave-response.json @@ -0,0 +1,44 @@ +{ + "id": "request_id", + "seatbid": [ + { + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "exp": 300, + "price": 3.33, + "adm": "adm001", + "adid": "adid001", + "cid": "cid001", + "crid": "crid001", + "mtype": 1, + "w": 300, + "h": 250, + "ext": { + "prebid": { + "type": "banner", + "meta": { + "adaptercode": "bidwave" + } + }, + "origbidcpm": 3.33, + "origbidcur": "USD" + } + } + ], + "seat": "bidwave", + "group": 0 + } + ], + "cur": "USD", + "ext": { + "responsetimemillis": { + "bidwave": "{{ bidwave.response_time_ms }}" + }, + "prebid": { + "auctiontimestamp": 0 + }, + "tmaxrequest": 5000 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/bidwave/test-bidwave-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/bidwave/test-bidwave-bid-request.json new file mode 100644 index 00000000000..31beccb6312 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/bidwave/test-bidwave-bid-request.json @@ -0,0 +1,58 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "secure": 1, + "banner": { + "w": 300, + "h": 250 + }, + "ext": { + "tid": "${json-unit.any-string}", + "bidder": { + "publisherId": "11111111-1111-4111-8111-111111111111" + } + } + } + ], + "source": { + "tid": "${json-unit.any-string}" + }, + "site": { + "domain": "www.example.com", + "page": "http://www.example.com", + "publisher": { + "domain": "example.com" + }, + "ext": { + "amp": 0 + } + }, + "device": { + "ua": "userAgent", + "ip": "193.168.244.1" + }, + "at": 1, + "tmax": "${json-unit.any-number}", + "cur": [ + "USD" + ], + "regs": { + "gdpr": 0 + }, + "ext": { + "prebid": { + "server": { + "externalurl": "http://localhost:8080", + "gvlid": 1, + "datacenter": "local", + "http_method": "POST", + "endpoint": "/openrtb2/auction" + } + }, + "bidwave": { + "pid": "11111111-1111-4111-8111-111111111111" + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/bidwave/test-bidwave-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/bidwave/test-bidwave-bid-response.json new file mode 100644 index 00000000000..6df6c15a57d --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/bidwave/test-bidwave-bid-response.json @@ -0,0 +1,22 @@ +{ + "id": "request_id", + "seatbid": [ + { + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "price": 3.33, + "adid": "adid001", + "crid": "crid001", + "cid": "cid001", + "adm": "adm001", + "mtype": 1, + "h": 250, + "w": 300 + } + ] + } + ], + "cur": "USD" +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/connectad/test-connectad-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/connectad/test-connectad-bid-request.json index 66e6fdbeddc..bd9651297b2 100644 --- a/src/test/resources/org/prebid/server/it/openrtb2/connectad/test-connectad-bid-request.json +++ b/src/test/resources/org/prebid/server/it/openrtb2/connectad/test-connectad-bid-request.json @@ -3,7 +3,7 @@ "imp": [ { "id": "imp_id", - "secure": 1, + "secure": 0, "banner": { "w": 300, "h": 250 @@ -11,14 +11,15 @@ "tagid": "15", "bidfloor": 14.7, "bidfloorcur": "USD", - "secure": 0, "ext": { "tid": "${json-unit.any-string}", "bidder": { "networkId": "12", "siteId": "15", "bidfloor": 14.7 - } + }, + "networkId": "12", + "siteId": "15" } } ], @@ -45,9 +46,7 @@ "USD" ], "regs": { - "ext": { - "gdpr": 0 - } + "gdpr": 0 }, "ext": { "prebid": { diff --git a/src/test/resources/org/prebid/server/it/openrtb2/floxis/test-auction-floxis-request.json b/src/test/resources/org/prebid/server/it/openrtb2/floxis/test-auction-floxis-request.json new file mode 100644 index 00000000000..4ce042710fa --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/floxis/test-auction-floxis-request.json @@ -0,0 +1,35 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "banner": { + "w": 300, + "h": 250 + }, + "ext": { + "floxis": { + "seat": "pub-seat-1" + } + } + } + ], + "source": { + "schain": { + "complete": 1, + "ver": "1.0", + "nodes": [ + { + "asi": "floxis.tech", + "sid": "pub-seat-1", + "hp": 1 + } + ] + } + }, + "regs": { + "ext": { + "gdpr": 0 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/floxis/test-auction-floxis-response.json b/src/test/resources/org/prebid/server/it/openrtb2/floxis/test-auction-floxis-response.json new file mode 100644 index 00000000000..07b4c9cb073 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/floxis/test-auction-floxis-response.json @@ -0,0 +1,44 @@ +{ + "id": "request_id", + "seatbid": [ + { + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "price": 3.33, + "adm": "adm001", + "adid": "adid001", + "cid": "cid001", + "crid": "crid001", + "w": 300, + "h": 250, + "exp": 300, + "mtype": 1, + "ext": { + "prebid": { + "type": "banner", + "meta": { + "adaptercode": "floxis" + } + }, + "origbidcpm": 3.33, + "origbidcur": "USD" + } + } + ], + "seat": "floxis", + "group": 0 + } + ], + "cur": "USD", + "ext": { + "responsetimemillis": { + "floxis": "{{ floxis.response_time_ms }}" + }, + "prebid": { + "auctiontimestamp": 0 + }, + "tmaxrequest": 5000 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/floxis/test-floxis-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/floxis/test-floxis-bid-request.json new file mode 100644 index 00000000000..cf7ba733859 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/floxis/test-floxis-bid-request.json @@ -0,0 +1,66 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "secure": 1, + "banner": { + "w": 300, + "h": 250 + }, + "ext": { + "tid": "${json-unit.any-string}", + "bidder": { + "seat": "pub-seat-1" + } + } + } + ], + "source": { + "tid": "${json-unit.any-string}", + "schain": { + "complete": 1, + "ver": "1.0", + "nodes": [ + { + "asi": "floxis.tech", + "sid": "pub-seat-1", + "hp": 1 + } + ] + } + }, + "site": { + "domain": "www.example.com", + "page": "http://www.example.com", + "publisher": { + "domain": "example.com" + }, + "ext": { + "amp": 0 + } + }, + "device": { + "ua": "userAgent", + "ip": "193.168.244.1" + }, + "at": 1, + "tmax": "${json-unit.any-number}", + "cur": [ + "USD" + ], + "regs": { + "gdpr": 0 + }, + "ext": { + "prebid": { + "server": { + "externalurl": "http://localhost:8080", + "gvlid": 1, + "datacenter": "local", + "http_method": "POST", + "endpoint": "/openrtb2/auction" + } + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/floxis/test-floxis-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/floxis/test-floxis-bid-response.json new file mode 100644 index 00000000000..c9c23670bfa --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/floxis/test-floxis-bid-response.json @@ -0,0 +1,23 @@ +{ + "id": "request_id", + "cur": "USD", + "seatbid": [ + { + "seat": "floxis", + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "price": 3.33, + "adid": "adid001", + "crid": "crid001", + "cid": "cid001", + "adm": "adm001", + "h": 250, + "w": 300, + "mtype": 1 + } + ] + } + ] +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/missena/test-auction-missena-request.json b/src/test/resources/org/prebid/server/it/openrtb2/missena/test-auction-missena-request.json index cacda5b188c..89c82b49442 100644 --- a/src/test/resources/org/prebid/server/it/openrtb2/missena/test-auction-missena-request.json +++ b/src/test/resources/org/prebid/server/it/openrtb2/missena/test-auction-missena-request.json @@ -11,7 +11,7 @@ "missena": { "apiKey": "apiKey", "placement": "placement", - "test": "test" + "sample": "sample" } } } diff --git a/src/test/resources/org/prebid/server/it/openrtb2/missena/test-auction-missena-response.json b/src/test/resources/org/prebid/server/it/openrtb2/missena/test-auction-missena-response.json index 344c58cb6e6..4b0511cc829 100644 --- a/src/test/resources/org/prebid/server/it/openrtb2/missena/test-auction-missena-response.json +++ b/src/test/resources/org/prebid/server/it/openrtb2/missena/test-auction-missena-response.json @@ -10,6 +10,8 @@ "price": 10.2, "adm": "adm", "crid": "id", + "w": 320, + "h": 250, "ext": { "origbidcpm": 10.2, "origbidcur": "USD", diff --git a/src/test/resources/org/prebid/server/it/openrtb2/missena/test-missena-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/missena/test-missena-bid-request.json index 2feeee36d73..f5adf2e85c1 100644 --- a/src/test/resources/org/prebid/server/it/openrtb2/missena/test-missena-bid-request.json +++ b/src/test/resources/org/prebid/server/it/openrtb2/missena/test-missena-bid-request.json @@ -5,8 +5,9 @@ "request_id": "request_id", "timeout": "${json-unit.any-number}", "params": { + "apiKey": "apiKey", "placement": "placement", - "test": "test" + "sample": "sample" }, "ortb2": { "id": "request_id", @@ -23,7 +24,7 @@ "bidder": { "apiKey": "apiKey", "placement": "placement", - "test": "test" + "sample": "sample" } } } diff --git a/src/test/resources/org/prebid/server/it/openrtb2/missena/test-missena-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/missena/test-missena-bid-response.json index 4dece3dba9b..1c4c0cbfacd 100644 --- a/src/test/resources/org/prebid/server/it/openrtb2/missena/test-missena-bid-response.json +++ b/src/test/resources/org/prebid/server/it/openrtb2/missena/test-missena-bid-response.json @@ -2,5 +2,7 @@ "requestId": "id", "cpm": 10.2, "ad": "adm", - "currency": "USD" + "currency": "USD", + "width": 320, + "height": 250 } diff --git a/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-auction-scalibur-request.json b/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-auction-scalibur-request.json new file mode 100644 index 00000000000..2435fe56373 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-auction-scalibur-request.json @@ -0,0 +1,25 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "banner": { + "w": 320, + "h": 250 + }, + "bidfloorcur": "USD", + "ext": { + "scalibur": { + "placementId": "placement_id", + "extraField": "extra_field" + } + } + } + ], + "tmax": 5000, + "regs": { + "ext": { + "gdpr": 0 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-auction-scalibur-response.json b/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-auction-scalibur-response.json new file mode 100644 index 00000000000..37da829cd13 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-auction-scalibur-response.json @@ -0,0 +1,41 @@ +{ + "id": "request_id", + "seatbid": [ + { + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "exp": 300, + "price": 0.01, + "adid": "2068416", + "cid": "8048", + "crid": "24080", + "mtype": 1, + "ext": { + "prebid": { + "type": "banner", + "meta": { + "adaptercode": "scalibur" + } + }, + "origbidcpm": 0.01, + "origbidcur": "USD" + } + } + ], + "seat": "scalibur", + "group": 0 + } + ], + "cur": "USD", + "ext": { + "responsetimemillis": { + "scalibur": "{{ scalibur.response_time_ms }}" + }, + "prebid": { + "auctiontimestamp": 0 + }, + "tmaxrequest": 5000 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-scalibur-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-scalibur-bid-request.json new file mode 100644 index 00000000000..96eb65c1e2e --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-scalibur-bid-request.json @@ -0,0 +1,40 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "banner": { + "w": 320, + "h": 250 + }, + "tagid" : "placement_id", + "bidfloorcur": "USD", + "secure" : 1, + "ext" : { + "extraField": "extra_field" + } + } + ], + "site": { + "domain": "www.example.com", + "page": "http://www.example.com", + "publisher": { + "domain": "example.com" + }, + "ext": { + "amp": 0 + } + }, + "device": { + "ua": "userAgent", + "ip": "193.168.244.1" + }, + "at": 1, + "tmax": "${json-unit.any-number}", + "source": { + "tid": "${json-unit.any-string}" + }, + "regs": { + "gdpr": 0 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-scalibur-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-scalibur-bid-response.json new file mode 100644 index 00000000000..47d4f8718ea --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-scalibur-bid-response.json @@ -0,0 +1,19 @@ +{ + "id": "tid", + "seatbid": [ + { + "bid": [ + { + "crid": "24080", + "adid": "2068416", + "price": 0.01, + "id": "bid_id", + "impid": "imp_id", + "cid": "8048", + "mtype": 1 + } + ], + "type": "banner" + } + ] +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/sevio/test-auction-sevio-request.json b/src/test/resources/org/prebid/server/it/openrtb2/sevio/test-auction-sevio-request.json new file mode 100644 index 00000000000..c42a183e75e --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/sevio/test-auction-sevio-request.json @@ -0,0 +1,23 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "banner": { + "w": 320, + "h": 250 + }, + "ext": { + "sevio": { + "placementId": "testPlacementId" + } + } + } + ], + "tmax": 5000, + "regs": { + "ext": { + "gdpr": 0 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/sevio/test-auction-sevio-response.json b/src/test/resources/org/prebid/server/it/openrtb2/sevio/test-auction-sevio-response.json new file mode 100644 index 00000000000..32c3ee19b64 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/sevio/test-auction-sevio-response.json @@ -0,0 +1,39 @@ +{ + "id": "request_id", + "seatbid": [ + { + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "exp": 300, + "price": 0.01, + "crid": "crid1", + "mtype": 1, + "ext": { + "prebid": { + "type": "banner", + "meta": { + "adaptercode": "sevio" + } + }, + "origbidcpm": 0.01, + "origbidcur": "USD" + } + } + ], + "seat": "sevio", + "group": 0 + } + ], + "cur": "USD", + "ext": { + "responsetimemillis": { + "sevio": "{{ sevio.response_time_ms }}" + }, + "prebid": { + "auctiontimestamp": 0 + }, + "tmaxrequest": 5000 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/sevio/test-sevio-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/sevio/test-sevio-bid-request.json new file mode 100644 index 00000000000..5a21bde73d9 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/sevio/test-sevio-bid-request.json @@ -0,0 +1,57 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "secure": 1, + "banner": { + "w": 320, + "h": 250 + }, + "ext": { + "tid": "${json-unit.any-string}", + "bidder": { + "placementId": "testPlacementId" + } + } + } + ], + "source": { + "tid": "${json-unit.any-string}" + }, + "site": { + "domain": "www.example.com", + "page": "http://www.example.com", + "publisher": { + "domain": "example.com" + }, + "ext": { + "amp": 0 + } + }, + "device": { + "ua": "userAgent", + "ip": "193.168.244.1" + }, + "at": 1, + "tmax": "${json-unit.any-number}", + "cur": [ + "USD" + ], + "regs": { + "ext": { + "gdpr": 0 + } + }, + "ext": { + "prebid": { + "server": { + "externalurl": "http://localhost:8080", + "gvlid": 1, + "datacenter": "local", + "http_method": "POST", + "endpoint": "/openrtb2/auction" + } + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/sevio/test-sevio-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/sevio/test-sevio-bid-response.json new file mode 100644 index 00000000000..53d65871871 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/sevio/test-sevio-bid-response.json @@ -0,0 +1,18 @@ +{ + "id": "tid", + "seatbid": [ + { + "bid": [ + { + "crid": "crid1", + "price": 0.01, + "id": "bid_id", + "impid": "imp_id", + "mtype": 1 + } + ], + "type": "banner" + } + ], + "cur": "USD" +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-auction-synapsehx-request.json b/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-auction-synapsehx-request.json new file mode 100644 index 00000000000..840c0894a31 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-auction-synapsehx-request.json @@ -0,0 +1,23 @@ +{ + "id": "tid", + "imp": [ + { + "id": "impId001", + "banner": { + "w": 300, + "h": 250 + }, + "ext": { + "synapsehx": { + "tenantId": "tenant-id-0" + } + } + } + ], + "tmax": 5000, + "regs": { + "ext": { + "gdpr": 0 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-auction-synapsehx-response.json b/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-auction-synapsehx-response.json new file mode 100644 index 00000000000..ddb27b81e7a --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-auction-synapsehx-response.json @@ -0,0 +1,40 @@ +{ + "id": "tid", + "seatbid": [ + { + "bid": [ + { + "id": "bid001", + "impid": "impId001", + "price": 3.33, + "crid": "crid001", + "w": 300, + "h": 250, + "exp" : 300, + "mtype": 1, + "ext": { + "prebid": { + "type": "banner", + "meta" : { + "adaptercode" : "synapsehx" + } + }, + "origbidcpm" : 3.33 + } + } + ], + "seat": "synapsehx", + "group": 0 + } + ], + "cur": "USD", + "ext": { + "responsetimemillis": { + "synapsehx": "{{ synapsehx.response_time_ms }}" + }, + "prebid": { + "auctiontimestamp": 1000 + }, + "tmaxrequest": 5000 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-synapsehx-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-synapsehx-bid-request.json new file mode 100644 index 00000000000..355afd6f14e --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-synapsehx-bid-request.json @@ -0,0 +1,57 @@ +{ + "id": "tid", + "imp": [ + { + "id": "impId001", + "banner": { + "w": 300, + "h": 250 + }, + "secure": 1, + "ext": { + "tid": "${json-unit.any-string}", + "bidder": { + "tenantId": "tenant-id-0" + } + } + } + ], + "site": { + "domain": "www.example.com", + "page": "http://www.example.com", + "publisher": { + "domain": "example.com" + }, + "ext": { + "amp": 0 + } + }, + "device": { + "ua": "userAgent", + "ip": "193.168.244.1" + }, + "at": 1, + "tmax": "${json-unit.any-number}", + "cur": [ + "USD" + ], + "source": { + "tid": "${json-unit.any-string}" + }, + "regs": { + "ext": { + "gdpr": 0 + } + }, + "ext": { + "prebid": { + "server": { + "externalurl": "http://localhost:8080", + "gvlid": 1, + "datacenter": "local", + "http_method" : "POST", + "endpoint": "/openrtb2/auction" + } + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-synapsehx-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-synapsehx-bid-response.json new file mode 100644 index 00000000000..72cab5f0364 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-synapsehx-bid-response.json @@ -0,0 +1,18 @@ +{ + "id": "tid", + "seatbid": [ + { + "bid": [ + { + "id": "bid001", + "impid": "impId001", + "price": 3.33, + "crid": "crid001", + "h": 250, + "w": 300, + "mtype": 1 + } + ] + } + ] +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/thetradedesk/test-auction-thetradedesk-request.json b/src/test/resources/org/prebid/server/it/openrtb2/thetradedesk/test-auction-thetradedesk-request.json index e3fba9cb248..8f944980ed1 100644 --- a/src/test/resources/org/prebid/server/it/openrtb2/thetradedesk/test-auction-thetradedesk-request.json +++ b/src/test/resources/org/prebid/server/it/openrtb2/thetradedesk/test-auction-thetradedesk-request.json @@ -17,8 +17,6 @@ ], "tmax": 5000, "regs": { - "ext": { - "gdpr": 0 - } + "gdpr": 0 } } diff --git a/src/test/resources/org/prebid/server/it/openrtb2/thetradedesk/test-thetradedesk-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/thetradedesk/test-thetradedesk-bid-request.json index 29e68d04d41..1fe5f17aa02 100644 --- a/src/test/resources/org/prebid/server/it/openrtb2/thetradedesk/test-thetradedesk-bid-request.json +++ b/src/test/resources/org/prebid/server/it/openrtb2/thetradedesk/test-thetradedesk-bid-request.json @@ -41,9 +41,7 @@ "USD" ], "regs": { - "ext": { - "gdpr": 0 - } + "gdpr": 0 }, "ext": { "prebid": { diff --git a/src/test/resources/org/prebid/server/it/test-application.properties b/src/test/resources/org/prebid/server/it/test-application.properties index 422675ce9f9..841d458c749 100644 --- a/src/test/resources/org/prebid/server/it/test-application.properties +++ b/src/test/resources/org/prebid/server/it/test-application.properties @@ -82,6 +82,8 @@ adapters.adprime.enabled=true adapters.adprime.endpoint=http://localhost:8090/adprime-exchange adapters.adquery.enabled=true adapters.adquery.endpoint=http://localhost:8090/adquery-exchange +adapters.adswag.enabled=true +adapters.adswag.endpoint=http://localhost:8090/adswag-exchange adapters.adtarget.enabled=true adapters.adtarget.endpoint=http://localhost:8090/adtarget-exchange adapters.adtelligent.enabled=true @@ -170,6 +172,8 @@ adapters.bidstack.enabled=true adapters.bidstack.endpoint=http://localhost:8090/bidstack-exchange adapters.bidtheatre.enabled=true adapters.bidtheatre.endpoint=http://localhost:8090/bidtheatre-exchange +adapters.bidwave.enabled=true +adapters.bidwave.endpoint=http://localhost:8090/bidwave-exchange adapters.bigoad.enabled=true adapters.bigoad.endpoint=http://localhost:8090/bigoad-exchange adapters.blasto.enabled=true @@ -261,6 +265,8 @@ adapters.flatads.enabled=true adapters.flatads.endpoint=http://localhost:8090/flatads-exchange adapters.flipp.enabled=true adapters.flipp.endpoint=http://localhost:8090/flipp-exchange +adapters.floxis.enabled=true +adapters.floxis.endpoint=http://localhost:8090/floxis-exchange adapters.audiencenetwork.enabled=true adapters.audiencenetwork.endpoint=http://localhost:8090/audienceNetwork-exchange adapters.audiencenetwork.timeout-notification-url-template=http://localhost:8090/audienceNetwork-timeout @@ -505,6 +511,8 @@ adapters.magnite.usersync.redirect.url=http://localhost:8090/magnite-usersync?gd adapters.magnite.usersync.redirect.uid-macro=magnite-macro adapters.salunamedia.enabled=true adapters.salunamedia.endpoint=http://localhost:8090/salunamedia-exchange +adapters.scalibur.enabled=true +adapters.scalibur.endpoint=http://localhost:8090/scalibur-exchange adapters.screencore.enabled=true adapters.screencore.endpoint=http://localhost:8090/screencore-exchange adapters.seedingAlliance.enabled=true @@ -515,6 +523,8 @@ adapters.seedingAlliance.aliases.finative.enabled=true adapters.seedingAlliance.aliases.finative.endpoint=http://localhost:8090/finative-exchange adapters.seedtag.enabled=true adapters.seedtag.endpoint=http://localhost:8090/seedtag-exchange +adapters.sevio.enabled=true +adapters.sevio.endpoint=http://localhost:8090/sevio-exchange adapters.smaato.enabled=true adapters.smaato.endpoint=http://localhost:8090/smaato-exchange adapters.smartadserver.enabled=true @@ -577,6 +587,8 @@ adapters.startio.enabled=true adapters.startio.endpoint=http://localhost:8090/startio-exchange adapters.stroeercore..enabled=true adapters.stroeercore.endpoint=http://localhost:8090/stroeercore-exchange +adapters.synapsehx.enabled=true +adapters.synapsehx.endpoint=http://localhost:8090/synapsehx-exchange adapters.suntContent.enabled=true adapters.suntContent.endpoint=http://localhost:8090/suntContent-exchange adapters.taboola.enabled=true @@ -609,6 +621,8 @@ adapters.teqblaze.aliases.mycodemedia.enabled=true adapters.teqblaze.aliases.mycodemedia.endpoint=http://localhost:8090/mycodemedia-exchange/ adapters.teqblaze.aliases.adsmovil.enabled=true adapters.teqblaze.aliases.adsmovil.endpoint=http://localhost:8090/adsmovil-exchange/ +adapters.teqblaze.aliases.appMonstaMedia.enabled=true +adapters.teqblaze.aliases.appMonstaMedia.endpoint=http://localhost:8090/appMonstaMedia-exchange/ adapters.theadx.enabled=true adapters.theadx.endpoint=http://localhost:8090/theadx-exchange adapters.tradplus.enabled=true