diff --git a/extra/bundle/pom.xml b/extra/bundle/pom.xml
index aa4726813ed..ca6bab9475c 100644
--- a/extra/bundle/pom.xml
+++ b/extra/bundle/pom.xml
@@ -55,6 +55,11 @@
pb-request-correction
${project.version}
+
+ org.prebid.server.hooks.modules
+ optable-targeting
+ ${project.version}
+
diff --git a/extra/modules/optable-targeting/README.md b/extra/modules/optable-targeting/README.md
new file mode 100644
index 00000000000..3ae7bd5f659
--- /dev/null
+++ b/extra/modules/optable-targeting/README.md
@@ -0,0 +1,284 @@
+## Overview
+Optable module operates using a DCN backend API. Please contact your account manager to get started.
+
+The optable-targeting module enriches an incoming OpenRTB request by adding to the `user.eids` and `user.data`
+objects. Under the hood the module extracts PPIDs (publisher provided IDs) from the incoming request's `user.ext.eids`,
+and also if present sha256-hashed email, sha256-hashed phone, zip or Optable Visitor ID provided correspondingly in the
+`user.ext.optable.email`, `.phone`, `.zip`, `.vid` fields (a full list of IDs is given in a table below). These IDs are
+sent as input to the Targeting API. The received response data is used to enrich the OpenRTB request and response.
+Targeting API endpoint is configurable per publisher.
+
+## Setup
+
+### Execution Plan
+
+This module runs at two stages:
+
+* Processed Auction Request: to enrich `user.eids` and `user.data`.
+* Auction Response: to inject ad server targeting.
+
+We recommend defining the execution plan in the account config so the module is only invoked for specific accounts. See
+below for an example.
+
+### Global Config
+
+There is no host-company level config for this module.
+
+### Account-Level Config
+
+To start using current module in PBS-Java you have to enable module and add
+`optable-targeting-processed-auction-request-hook` and `optable-targeting-auction-response-hook` into hooks execution
+plan inside your config file:
+Here's a general template for the account config used in PBS-Java:
+
+```yaml
+hooks:
+ optable-targeting:
+ enabled: true
+ host-execution-plan: >
+ {
+ "endpoints": {
+ "/openrtb2/auction": {
+ "stages": {
+ "processed-auction-request": {
+ "groups": [
+ {
+ "timeout": 100,
+ "hook-sequence": [
+ {
+ "module-code": "optable-targeting",
+ "hook-impl-code": "optable-targeting-processed-auction-request-hook"
+ }
+ ]
+ }
+ ]
+ },
+ "auction-response": {
+ "groups": [
+ {
+ "timeout": 10,
+ "hook-sequence": [
+ {
+ "module-code": "optable-targeting",
+ "hook-impl-code": "optable-targeting-auction-response-hook"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+```
+
+Sample module enablement configuration in JSON and YAML formats:
+
+```json
+{
+ "modules":
+ {
+ "optable-targeting":
+ {
+ "api-endpoint": "endpoint",
+ "api-key": "key",
+ "timeout": 50,
+ "ppid-mapping": {
+ "pubcid.org": "c"
+ },
+ "adserver-targeting": false
+ }
+ }
+}
+```
+
+```yaml
+ modules:
+ optable-targeting:
+ api-endpoint: endpoint
+ api-key: key
+ timeout: 50
+ ppid-mapping: {
+ "pubcid.org": "c"
+ }
+ adserver-targeting: false
+```
+
+### Timeout considerations
+
+The timeout value specified in the execution plan for the `processed-auction-request` hook is very important to be
+picked such that the hook has enough time to make a roundtrip to Optable Targeting Edge API over HTTP.
+
+**Note:** Do not confuse hook timeout value with the module timeout parameter which is optional. The hook timeout value
+would depend on the cloud/region where the PBS instance is hosted and the latency to reach the Optable's servers. This
+will need to be verified experimentally upon deployment.
+
+The timeout value for the `auction-response` can be set to 10 ms - usually it will be sub-millisecond time as there are
+no HTTP calls made in this hook - Optable-specific keywords are cached on the `processed-auction-request` stage and
+retrieved from the module invocation context later.
+
+## Module Configuration Parameters for PBS-Java
+
+The parameter names are specified with full path using dot-notation. F.e. `section-name` .`sub-section` .`param-name`
+would result in this nesting in the JSON configuration:
+
+```json
+{
+ "section-name": {
+ "sub-section": {
+ "param-name": "param-value"
+ }
+ }
+}
+```
+
+
+| Param Name | Required | Type | Default value | Description |
+|:-------------------|:---------|:--------|:---------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| api-endpoint | yes | string | none | Optable Targeting Edge API endpoint URL, required |
+| api-key | no | string | none | If the API is protected with a key - this param needs to be specified to be sent in the auth header |
+| ppid-mapping | no | map | none | This specifies PPID source (`user.ext.eids[].source`) to a custom identifier prefix mapping, f.e. `{"example.com" : "c"}`. See the section on ID Mapping below for more detail. |
+| adserver-targeting | no | boolean | false | If set to true - will add the Optable-specific adserver targeting keywords into the PBS response for every `seatbid[].bid[].ext.prebid.targeting` |
+| timeout | no | integer | false | A soft timeout (in ms) sent as a hint to the Targeting API endpoint to limit the request times to Optable's external tokenizer services |
+| id-prefix-order | no | string | none | An optional string of comma separated id prefixes that prioritizes and specifies the order in which ids are provided to Targeting API in a query string. F.e. "c,c1,id5" will guarantee that Targeting API will see id=c:...,c1:...,id5:... if these ids are provided. id-prefixes not mentioned in this list will be added in arbitrary order after the priority prefix ids. This affects Targeting API processing logic |
+
+## ID Mapping
+
+Internally the module sends requests to Optable Targeting API. The output of Targeting API is used to enrich the request
+and response. The below table describes the parameters that the module automatically fetches from OpenRTB request and
+then sends to the Targeting API. The module will use a prefix as specified in the table to prepend the corresponding ID
+value when sending it to the Targeting API in the form `id=prefix:value`.
+
+See [Optable documentation](https://docs.optable.co/optable-documentation/dmp/reference/identifier-types#type-prefixes)
+on identifier types. Targeting API accepts multiple id parameters - and their order may affect the results, thus
+`id-prefix-order` specifies the order of the ids.
+
+
+| Identifier Type | OpenRTB field | ID Type Prefix |
+|--------------------------------------------------------------------------------|-----------------------------------------------------------------------|------------------------------------------|
+| Email Address | `user.ext.optable.email` | `e:` |
+| Phone Number | `user.ext.optable.phone` | `p:` |
+| Postal Code | `user.ext.optable.zip` | `z:` |
+| IPv4 Address | `device.ip` | ~~i4:~~ Sent as `X-Forwarded-For` header |
+| IPv6 Address | `device.ipv6` | ~~i6:~~ Sent as `X-Forwarded-For` header |
+| Apple IDFA | `device.ifa if lcase(device.os) contains 'ios' and device.lmt!=1` | `a:` |
+| Google GAID | `device.ifa if lcase(device.os) contains 'android' and device.lmt!=1` | `g:` |
+| Roku RIDA | `device.ifa if lcase(device.os) contains 'roku' and device.lmt!=1` | `r:` |
+| Samsung TV TIFA | `device.ifa if lcase(device.os) contains 'tizen' and device.lmt!=1` | `s:` |
+| Amazon Fire AFAI | `device.ifa if lcase(device.os) contains 'fire' and device.lmt!=1` | `f:` |
+| [NetID](https://docs.prebid.org/dev-docs/modules/userid-submodules/netid.html) | `user.ext.eids[].uids[0] when user.ext.eids[].source="netid.de"` | `n:` |
+| [ID5](https://docs.prebid.org/dev-docs/modules/userid-submodules/id5.html) | `user.ext.eids[].uids[0] when user.ext.eids[].source="id5-sync.com"` | `id5:` |
+| [Utiq](https://docs.prebid.org/dev-docs/modules/userid-submodules/utiq.html) | `user.ext.eids[].uids[0] when user.ext.eids[].source="utiq.com"` | `utiq:` |
+| Optable VID | `user.ext.optable.vid` | `v:` |
+
+### Optable input erasure
+
+**Note**: `user.ext.optable.email`, `.phone`, `.zip`, `.vid` fields will be removed by the module from the original
+OpenRTB request before being sent to bidders.
+
+### Publisher Provided IDs (PPID) Mapping
+
+Custom user IDs are sent in the OpenRTB request in the
+[`user.ext.eids[]`](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/main/2.6.md#3227---object-eid-).
+The `ppid-mapping` allows to specify the mapping of a source to one of the custom identifier type prefixes `c`-`c19` -
+see [documentation](https://docs.optable.co/optable-documentation/dmp/reference/identifier-types#type-prefixes), f.e.:
+
+```yaml
+ppid-mapping: {"example.com": "c2", "test.com": "c3"}
+```
+
+It is also possible to override any of the automatically retrieved `user.ext.eids[]` mentioned in the table above (s.a.
+id5, utiq) so they are mapped to a different prefix. f.e. `id5-sync.com` can be mapped to a prefix other than `id5:`,
+like:
+
+```yaml
+ppid-mapping: {"id5-sync.com": "c1"}
+```
+
+This will lead to id5 ID supplied as `id=c1:...` to the Targeting API.
+
+## Analytics Tags
+
+The following 2 analytics tags are written by the module:
+
+* `optable-enrich-request`
+* `optable-enrich-response`
+
+The `status` is either `success` or `failure`. Where it is `failure` a `results[0].value.reason` is provided.
+For the `optable-enrich-request` activity the `execution-time` value is logged.
+Example:
+
+```json
+{
+ "analytics": {
+ "tags": [
+ {
+ "stage": "auction-response",
+ "module": "optable-targeting",
+ "analyticstags": {
+ "activities": [
+ {
+ "name": "optable-enrich-request",
+ "status": "success",
+ "results": [
+ {
+ "values": {
+ "execution-time": 33
+ }
+ }
+ ]
+ },
+ {
+ "name": "optable-enrich-response",
+ "status": "success",
+ "results": [
+ {
+ "values": {
+ "reason": "none"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ }
+}
+```
+
+If `adserver-targeting` was set to `false` in the config `optable-enrich-response` analytics tag is not written.
+
+## Running the demo (PBS-Java)
+
+1. Build the server bundle JAR as described in [Build Project](https://github.com/prebid/prebid-server-java/blob/master/docs/build.md#build-project), e.g.
+
+```bash
+mvn clean package --file extra/pom.xml
+```
+
+2. In the `sample/configs/prebid-config-optable.yaml` file specify the `api-endpoint` URL of your DCN, f.e.:
+
+```yaml
+api-endpoint: https://example.com/v2/targeting
+```
+
+3. Start server bundle JAR as described in [Running project](https://github.com/prebid/prebid-server-java/blob/master/docs/run.md#running-project), e.g.
+
+```bash
+java -jar target/prebid-server-bundle.jar --spring.config.additional-location=sample/configs/prebid-config-with-optable.yaml
+```
+
+4. Run sample request against the server as described in [the sample directory](https://github.com/prebid/prebid-server-java/tree/master/sample), e.g.
+
+```bash
+curl http://localhost:8080/openrtb2/auction --data @extra/modules/optable-targeting/sample-requests/data.json
+```
+
+5. Observe the `user.eids` and `user.data` objects enriched.
+
+## Maintainer contacts
+
+Any suggestions or questions can be directed to [prebid@optable.co](mailto:prebid@optable.co).
+
+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/optable-targeting/lombok.config b/extra/modules/optable-targeting/lombok.config
new file mode 100644
index 00000000000..efd92714219
--- /dev/null
+++ b/extra/modules/optable-targeting/lombok.config
@@ -0,0 +1 @@
+lombok.anyConstructor.addConstructorProperties = true
diff --git a/extra/modules/optable-targeting/pom.xml b/extra/modules/optable-targeting/pom.xml
new file mode 100644
index 00000000000..7b5b1727fc9
--- /dev/null
+++ b/extra/modules/optable-targeting/pom.xml
@@ -0,0 +1,15 @@
+
+
+ 4.0.0
+
+
+ org.prebid.server.hooks.modules
+ all-modules
+ 3.29.0-SNAPSHOT
+
+
+ optable-targeting
+
+ optable-targeting
+ Optable targeting module
+
diff --git a/extra/modules/optable-targeting/sample-requests/data.json b/extra/modules/optable-targeting/sample-requests/data.json
new file mode 100644
index 00000000000..f0ef7e2cf9c
--- /dev/null
+++ b/extra/modules/optable-targeting/sample-requests/data.json
@@ -0,0 +1,137 @@
+{
+ "test": 1,
+ "id": "1",
+ "imp":
+ [
+ {
+ "id": "1",
+ "banner":
+ {
+ "w": 300,
+ "h": 250
+ },
+ "ext":
+ {
+ "prebid":
+ {
+ "storedauctionresponse": { "id": "optable-stored-response" },
+ "bidder":
+ {
+ "appnexus":
+ {
+ "placementId": 0
+ }
+ }
+ }
+ }
+ }
+ ],
+ "site":
+ {
+ "domain": "test.com",
+ "publisher":
+ {
+ "domain": "test.com",
+ "id": "1"
+ },
+ "page": "https://www.test.com/"
+ },
+ "device":
+ {
+ "ip": "8.8.8.8"
+ },
+ "user":
+ {
+ "ext":
+ {
+ "optable":
+ {
+ "email": "fd911bd8cac2e603a80efafca2210b7a917c97410f0c29d9f2bfb99867e5a589"
+ },
+ "eids":
+ [
+ {
+ "source": "growthcode.io",
+ "uids":
+ [
+ {
+ "id": "fb58593e-7ac6-48bd-b2de-89a758726362",
+ "atype": 1
+ }
+ ]
+ },
+ {
+ "source": "pubcid.org",
+ "uids": [
+ {
+ "id": "test",
+ "atype": 1
+ }
+ ]
+ },
+ {
+ "source": "crwdcntrl.net",
+ "uids":
+ [
+ {
+ "id": "dd1b31e65f5e45548c11a0275ba3a8072c00e3a2a0493e8f5a8f54f8067e8b00",
+ "atype": 1
+ }
+ ]
+ },
+ {
+ "source": "id5-sync.com",
+ "uids":
+ [
+ {
+ "id": "ID5*dd1b31e65f5e45548c11a0275ba3a8072c00e3a2a0493e8f5a8f54f8067e8b00",
+ "atype": 1
+ }
+ ]
+ },
+ {
+ "source": "amxdt.net",
+ "uids":
+ [
+ {
+ "id": "amx*3*a583802a-e6fe-48d7-87c6-7db1b6a4a73a*70f06cdcf8ab0b4ac07a56860ed0e0b6ef0388dc0b0ab5a1dd725999d3b339cf",
+ "atype": 1
+ }
+ ]
+ },
+ {
+ "source": "audigent.com",
+ "uids":
+ [
+ {
+ "id": "f84456cd3c72296d7898f62e1c46dd964206ff4d47e64b690c3c5a1d6b1bd286",
+ "atype": 1
+ }
+ ]
+ },
+ {
+ "source": "adnxs.com",
+ "uids":
+ [
+ {
+ "id": "d4fd63f0f4f7ce0d128348cb145c7e0f"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "ext": {
+ "prebid": {
+ "targeting": {
+ "includebidderkeys": true
+ },
+ "analytics":
+ {
+ "options": {
+ "enableclientdetails": true
+ }
+ }
+ }
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/config/OptableTargetingConfig.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/config/OptableTargetingConfig.java
new file mode 100644
index 00000000000..608567af2b4
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/config/OptableTargetingConfig.java
@@ -0,0 +1,105 @@
+package org.prebid.server.hooks.modules.optable.targeting.config;
+
+import org.apache.commons.lang3.ObjectUtils;
+import org.prebid.server.auction.privacy.enforcement.mask.UserFpdActivityMask;
+import org.prebid.server.cache.PbcStorageService;
+import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties;
+import org.prebid.server.hooks.modules.optable.targeting.v1.OptableTargetingAuctionResponseHook;
+import org.prebid.server.hooks.modules.optable.targeting.v1.OptableTargetingModule;
+import org.prebid.server.hooks.modules.optable.targeting.v1.OptableTargetingProcessedAuctionRequestHook;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.Cache;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.ConfigResolver;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.IdsMapper;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.OptableTargeting;
+import org.prebid.server.hooks.modules.optable.targeting.v1.net.APIClientImpl;
+import org.prebid.server.hooks.modules.optable.targeting.v1.net.CachedAPIClient;
+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.beans.factory.annotation.Value;
+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." + OptableTargetingModule.CODE, name = "enabled", havingValue = "true")
+@Configuration
+public class OptableTargetingConfig {
+
+ @Bean
+ @ConfigurationProperties(prefix = "hooks.modules." + OptableTargetingModule.CODE)
+ OptableTargetingProperties properties() {
+ return new OptableTargetingProperties();
+ }
+
+ @Bean
+ IdsMapper queryParametersExtractor(@Value("${logging.sampling-rate:0.01}") double logSamplingRate) {
+ return new IdsMapper(ObjectMapperProvider.mapper(), logSamplingRate);
+ }
+
+ @Bean
+ APIClientImpl apiClient(HttpClient httpClient,
+ @Value("${logging.sampling-rate:0.01}")
+ double logSamplingRate,
+ OptableTargetingProperties properties,
+ JacksonMapper jacksonMapperr) {
+
+ return new APIClientImpl(
+ properties.getApiEndpoint(),
+ httpClient,
+ jacksonMapperr,
+ logSamplingRate);
+ }
+
+ @Bean
+ @ConditionalOnProperty(name = {"storage.pbc.enabled", "cache.module.enabled"}, havingValue = "true")
+ CachedAPIClient cachedApiClient(APIClientImpl apiClient,
+ Cache cache,
+ @Value("${http-client.circuit-breaker.enabled:false}")
+ boolean isCircuitBreakerEnabled) {
+
+ return new CachedAPIClient(apiClient, cache, isCircuitBreakerEnabled);
+ }
+
+ @Bean
+ @ConditionalOnProperty(name = {"storage.pbc.enabled", "cache.module.enabled"}, havingValue = "true")
+ Cache cache(PbcStorageService cacheService, JacksonMapper jacksonMapper) {
+ return new Cache(cacheService, jacksonMapper);
+ }
+
+ @Bean
+ OptableTargeting optableTargeting(IdsMapper parametersExtractor,
+ APIClientImpl apiClient,
+ @Autowired(required = false) CachedAPIClient cachedApiClient) {
+
+ return new OptableTargeting(
+ parametersExtractor,
+ ObjectUtils.firstNonNull(cachedApiClient, apiClient));
+ }
+
+ @Bean
+ ConfigResolver configResolver(JsonMerger jsonMerger, OptableTargetingProperties globalProperties) {
+ return new ConfigResolver(ObjectMapperProvider.mapper(), jsonMerger, globalProperties);
+ }
+
+ @Bean
+ OptableTargetingModule optableTargetingModule(ConfigResolver configResolver,
+ OptableTargeting optableTargeting,
+ UserFpdActivityMask userFpdActivityMask,
+ JsonMerger jsonMerger) {
+
+ return new OptableTargetingModule(List.of(
+ new OptableTargetingProcessedAuctionRequestHook(
+ configResolver,
+ optableTargeting,
+ userFpdActivityMask),
+ new OptableTargetingAuctionResponseHook(
+ configResolver,
+ ObjectMapperProvider.mapper(),
+ jsonMerger)));
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/EnrichmentStatus.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/EnrichmentStatus.java
new file mode 100644
index 00000000000..e0dc94d34cf
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/EnrichmentStatus.java
@@ -0,0 +1,19 @@
+package org.prebid.server.hooks.modules.optable.targeting.model;
+
+import lombok.Value;
+
+@Value(staticConstructor = "of")
+public class EnrichmentStatus {
+
+ Status status;
+
+ Reason reason;
+
+ public static EnrichmentStatus failure() {
+ return EnrichmentStatus.of(Status.FAIL, null);
+ }
+
+ public static EnrichmentStatus success() {
+ return EnrichmentStatus.of(Status.SUCCESS, null);
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Id.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Id.java
new file mode 100644
index 00000000000..d80057fddde
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Id.java
@@ -0,0 +1,42 @@
+package org.prebid.server.hooks.modules.optable.targeting.model;
+
+import lombok.Value;
+
+import javax.validation.constraints.NotNull;
+
+@Value(staticConstructor = "of")
+public class Id {
+
+ public static final String EMAIL = "e";
+
+ public static final String PHONE = "p";
+
+ public static final String ZIP = "z";
+
+ public static final String DEVICE_IP_V_4 = "i4";
+
+ public static final String DEVICE_IP_V_6 = "i6";
+
+ public static final String APPLE_IDFA = "a";
+
+ public static final String GOOGLE_GAID = "g";
+
+ public static final String ROKU_RIDA = "r";
+
+ public static final String SAMSUNG_TV_TIFA = "s";
+
+ public static final String AMAZON_FIRE_AFAI = "f";
+
+ public static final String NET_ID = "n";
+
+ public static final String ID5 = "id5";
+
+ public static final String UTIQ = "utiq";
+
+ public static final String OPTABLE_VID = "v";
+
+ @NotNull
+ String name;
+
+ String value;
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/ModuleContext.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/ModuleContext.java
new file mode 100644
index 00000000000..ed0264f0249
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/ModuleContext.java
@@ -0,0 +1,26 @@
+package org.prebid.server.hooks.modules.optable.targeting.model;
+
+import lombok.Data;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Audience;
+import org.prebid.server.hooks.v1.auction.AuctionInvocationContext;
+
+import java.util.List;
+
+@Data
+public class ModuleContext {
+
+ private List targeting;
+
+ private EnrichmentStatus enrichRequestStatus;
+
+ private EnrichmentStatus enrichResponseStatus;
+
+ private boolean adserverTargetingEnabled;
+
+ private long optableTargetingExecutionTime;
+
+ public static ModuleContext of(AuctionInvocationContext invocationContext) {
+ final ModuleContext moduleContext = (ModuleContext) invocationContext.moduleContext();
+ return moduleContext != null ? moduleContext : new ModuleContext();
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/OS.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/OS.java
new file mode 100644
index 00000000000..8af2273115e
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/OS.java
@@ -0,0 +1,24 @@
+package org.prebid.server.hooks.modules.optable.targeting.model;
+
+public enum OS {
+
+ IOS("ios"),
+
+ ANDROID("android"),
+
+ ROKU("roku"),
+
+ TIZEN("tizen"),
+
+ FIRE("fire");
+
+ private final String value;
+
+ OS(String value) {
+ this.value = value;
+ }
+
+ public String getValue() {
+ return value;
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/OptableAttributes.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/OptableAttributes.java
new file mode 100644
index 00000000000..5498457dcbb
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/OptableAttributes.java
@@ -0,0 +1,24 @@
+package org.prebid.server.hooks.modules.optable.targeting.model;
+
+import lombok.Builder;
+import lombok.Value;
+
+import java.util.List;
+import java.util.Set;
+
+@Value
+@Builder(toBuilder = true)
+public class OptableAttributes {
+
+ String gpp;
+
+ Set gppSid;
+
+ String gdprConsent;
+
+ boolean gdprApplies;
+
+ List ips;
+
+ Long timeout;
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Query.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Query.java
new file mode 100644
index 00000000000..54980f12c33
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Query.java
@@ -0,0 +1,20 @@
+package org.prebid.server.hooks.modules.optable.targeting.model;
+
+import lombok.Value;
+import org.apache.commons.lang3.StringUtils;
+
+@Value(staticConstructor = "of")
+public class Query {
+
+ String ids;
+
+ String attributes;
+
+ public String toQueryString() {
+ if (StringUtils.isEmpty(ids) && !StringUtils.isEmpty(attributes)) {
+ return attributes.substring(1);
+ }
+
+ return ids + attributes;
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Reason.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Reason.java
new file mode 100644
index 00000000000..b5b13f69364
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Reason.java
@@ -0,0 +1,17 @@
+package org.prebid.server.hooks.modules.optable.targeting.model;
+
+import lombok.Getter;
+
+public enum Reason {
+
+ NONE("none"),
+ NOBID("nobid"),
+ NOKEYWORD("nokeyword");
+
+ @Getter
+ private final String value;
+
+ Reason(String value) {
+ this.value = value;
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Status.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Status.java
new file mode 100644
index 00000000000..dcc03428a7a
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Status.java
@@ -0,0 +1,16 @@
+package org.prebid.server.hooks.modules.optable.targeting.model;
+
+import lombok.Getter;
+
+public enum Status {
+
+ SUCCESS("success"),
+ FAIL("fail");
+
+ @Getter
+ private final String value;
+
+ Status(String value) {
+ this.value = value;
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/config/CacheProperties.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/config/CacheProperties.java
new file mode 100644
index 00000000000..555c5b01277
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/config/CacheProperties.java
@@ -0,0 +1,13 @@
+package org.prebid.server.hooks.modules.optable.targeting.model.config;
+
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@NoArgsConstructor
+public class CacheProperties {
+
+ private boolean enabled = false;
+
+ private int ttlseconds = 86400;
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/config/OptableTargetingProperties.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/config/OptableTargetingProperties.java
new file mode 100644
index 00000000000..c13bf132ca9
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/config/OptableTargetingProperties.java
@@ -0,0 +1,35 @@
+package org.prebid.server.hooks.modules.optable.targeting.model.config;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.Map;
+
+@Data
+@NoArgsConstructor
+public final class OptableTargetingProperties {
+
+ @JsonProperty("api-endpoint")
+ String apiEndpoint;
+
+ @JsonProperty("api-key")
+ String apiKey;
+
+ String tenant;
+
+ String origin;
+
+ @JsonProperty("ppid-mapping")
+ Map ppidMapping;
+
+ @JsonProperty("adserver-targeting")
+ Boolean adserverTargeting = true;
+
+ Long timeout;
+
+ @JsonProperty("id-prefix-order")
+ String idPrefixOrder;
+
+ CacheProperties cache = new CacheProperties();
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/Audience.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/Audience.java
new file mode 100644
index 00000000000..566c46c2c35
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/Audience.java
@@ -0,0 +1,17 @@
+package org.prebid.server.hooks.modules.optable.targeting.model.openrtb;
+
+import lombok.Value;
+
+import java.util.List;
+
+@Value
+public class Audience {
+
+ String provider;
+
+ List ids;
+
+ String keyspace;
+
+ Integer rtbSegtax;
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/AudienceId.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/AudienceId.java
new file mode 100644
index 00000000000..73d7953fbdc
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/AudienceId.java
@@ -0,0 +1,9 @@
+package org.prebid.server.hooks.modules.optable.targeting.model.openrtb;
+
+import lombok.Value;
+
+@Value
+public class AudienceId {
+
+ String id;
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/ExtUserOptable.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/ExtUserOptable.java
new file mode 100644
index 00000000000..787b3adbf1a
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/ExtUserOptable.java
@@ -0,0 +1,20 @@
+package org.prebid.server.hooks.modules.optable.targeting.model.openrtb;
+
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Value;
+import org.prebid.server.proto.openrtb.ext.FlexibleExtension;
+
+@EqualsAndHashCode(callSuper = true)
+@Builder(toBuilder = true)
+@Value
+public class ExtUserOptable extends FlexibleExtension {
+
+ String email;
+
+ String phone;
+
+ String zip;
+
+ String vid;
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/Ortb2.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/Ortb2.java
new file mode 100644
index 00000000000..f8dace457af
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/Ortb2.java
@@ -0,0 +1,9 @@
+package org.prebid.server.hooks.modules.optable.targeting.model.openrtb;
+
+import lombok.Value;
+
+@Value
+public class Ortb2 {
+
+ User user;
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/TargetingResult.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/TargetingResult.java
new file mode 100644
index 00000000000..826ce2698ed
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/TargetingResult.java
@@ -0,0 +1,13 @@
+package org.prebid.server.hooks.modules.optable.targeting.model.openrtb;
+
+import lombok.Value;
+
+import java.util.List;
+
+@Value
+public class TargetingResult {
+
+ List audience;
+
+ Ortb2 ortb2;
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/User.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/User.java
new file mode 100644
index 00000000000..1ad2cdb220b
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/User.java
@@ -0,0 +1,15 @@
+package org.prebid.server.hooks.modules.optable.targeting.model.openrtb;
+
+import com.iab.openrtb.request.Data;
+import com.iab.openrtb.request.Eid;
+import lombok.Value;
+
+import java.util.List;
+
+@Value
+public class User {
+
+ List eids;
+
+ List data;
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java
new file mode 100644
index 00000000000..5a20f79a347
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java
@@ -0,0 +1,104 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.vertx.core.Future;
+import org.apache.commons.collections4.CollectionUtils;
+import org.prebid.server.hooks.execution.v1.InvocationResultImpl;
+import org.prebid.server.hooks.modules.optable.targeting.model.EnrichmentStatus;
+import org.prebid.server.hooks.modules.optable.targeting.model.ModuleContext;
+import org.prebid.server.hooks.modules.optable.targeting.model.Status;
+import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Audience;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.AnalyticTagsResolver;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.AuctionResponseValidator;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.BidResponseEnricher;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.ConfigResolver;
+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.AuctionResponseHook;
+import org.prebid.server.hooks.v1.auction.AuctionResponsePayload;
+import org.prebid.server.json.JsonMerger;
+
+import java.util.List;
+import java.util.Objects;
+
+public class OptableTargetingAuctionResponseHook implements AuctionResponseHook {
+
+ private static final String CODE = "optable-targeting-auction-response-hook";
+
+ private final ConfigResolver configResolver;
+ private final ObjectMapper objectMapper;
+ private final JsonMerger jsonMerger;
+
+ public OptableTargetingAuctionResponseHook(ConfigResolver configResolver,
+ ObjectMapper objectMapper,
+ JsonMerger jsonMerger) {
+
+ this.configResolver = Objects.requireNonNull(configResolver);
+ this.objectMapper = Objects.requireNonNull(objectMapper);
+ this.jsonMerger = Objects.requireNonNull(jsonMerger);
+ }
+
+ @Override
+ public Future> call(AuctionResponsePayload auctionResponsePayload,
+ AuctionInvocationContext invocationContext) {
+
+ final OptableTargetingProperties properties = configResolver.resolve(invocationContext.accountConfig());
+ final boolean adserverTargeting = properties.getAdserverTargeting();
+
+ final ModuleContext moduleContext = ModuleContext.of(invocationContext);
+ moduleContext.setAdserverTargetingEnabled(adserverTargeting);
+
+ if (!adserverTargeting) {
+ return success(moduleContext);
+ }
+
+ final EnrichmentStatus validationStatus = AuctionResponseValidator.checkEnrichmentPossibility(
+ auctionResponsePayload.bidResponse(), moduleContext.getTargeting());
+ moduleContext.setEnrichResponseStatus(validationStatus);
+
+ return validationStatus.getStatus() == Status.SUCCESS
+ ? enrichedPayload(moduleContext)
+ : success(moduleContext);
+ }
+
+ private Future> enrichedPayload(ModuleContext moduleContext) {
+ final List targeting = moduleContext.getTargeting();
+
+ return CollectionUtils.isNotEmpty(targeting)
+ ? update(BidResponseEnricher.of(targeting, objectMapper, jsonMerger), moduleContext)
+ : success(moduleContext);
+ }
+
+ private Future> update(
+ PayloadUpdate payloadUpdate,
+ ModuleContext moduleContext) {
+
+ return Future.succeededFuture(
+ InvocationResultImpl.builder()
+ .status(InvocationStatus.success)
+ .action(InvocationAction.update)
+ .payloadUpdate(payloadUpdate)
+ .moduleContext(moduleContext)
+ .analyticsTags(AnalyticTagsResolver.toEnrichResponseAnalyticTags(moduleContext))
+ .build());
+ }
+
+ private Future> success(ModuleContext moduleContext) {
+ return Future.succeededFuture(
+ InvocationResultImpl.builder()
+ .status(InvocationStatus.success)
+ .action(InvocationAction.no_action)
+ .moduleContext(moduleContext)
+ .analyticsTags(AnalyticTagsResolver.toEnrichResponseAnalyticTags(moduleContext))
+ .build());
+ }
+
+ @Override
+ public String code() {
+ return CODE;
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingModule.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingModule.java
new file mode 100644
index 00000000000..0b36b276b57
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingModule.java
@@ -0,0 +1,28 @@
+package org.prebid.server.hooks.modules.optable.targeting.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 class OptableTargetingModule implements Module {
+
+ public static final String CODE = "optable-targeting";
+
+ private final Collection extends Hook, ? extends InvocationContext>> hooks;
+
+ public OptableTargetingModule(Collection extends Hook, ? extends InvocationContext>> hooks) {
+ this.hooks = hooks;
+ }
+
+ @Override
+ public Collection extends Hook, ? extends InvocationContext>> hooks() {
+ return hooks;
+ }
+
+ @Override
+ public String code() {
+ return CODE;
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHook.java
new file mode 100644
index 00000000000..572133a177d
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHook.java
@@ -0,0 +1,153 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1;
+
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.Device;
+import com.iab.openrtb.request.User;
+import io.vertx.core.Future;
+import org.prebid.server.activity.Activity;
+import org.prebid.server.activity.ComponentType;
+import org.prebid.server.activity.infrastructure.ActivityInfrastructure;
+import org.prebid.server.activity.infrastructure.payload.ActivityInvocationPayload;
+import org.prebid.server.activity.infrastructure.payload.impl.ActivityInvocationPayloadImpl;
+import org.prebid.server.activity.infrastructure.payload.impl.BidRequestActivityInvocationPayload;
+import org.prebid.server.auction.model.AuctionContext;
+import org.prebid.server.auction.privacy.enforcement.mask.UserFpdActivityMask;
+import org.prebid.server.execution.timeout.Timeout;
+import org.prebid.server.hooks.execution.v1.InvocationResultImpl;
+import org.prebid.server.hooks.modules.optable.targeting.model.EnrichmentStatus;
+import org.prebid.server.hooks.modules.optable.targeting.model.ModuleContext;
+import org.prebid.server.hooks.modules.optable.targeting.model.OptableAttributes;
+import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.AnalyticTagsResolver;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.BidRequestCleaner;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.BidRequestEnricher;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.ConfigResolver;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.OptableAttributesResolver;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.OptableTargeting;
+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 java.util.Objects;
+
+public class OptableTargetingProcessedAuctionRequestHook implements ProcessedAuctionRequestHook {
+
+ public static final String CODE = "optable-targeting-processed-auction-request-hook";
+
+ private final ConfigResolver configResolver;
+ private final OptableTargeting optableTargeting;
+ private final UserFpdActivityMask userFpdActivityMask;
+
+ public OptableTargetingProcessedAuctionRequestHook(ConfigResolver configResolver,
+ OptableTargeting optableTargeting,
+ UserFpdActivityMask userFpdActivityMask) {
+
+ this.configResolver = Objects.requireNonNull(configResolver);
+ this.optableTargeting = Objects.requireNonNull(optableTargeting);
+ this.userFpdActivityMask = Objects.requireNonNull(userFpdActivityMask);
+ }
+
+ @Override
+ public Future> call(AuctionRequestPayload auctionRequestPayload,
+ AuctionInvocationContext invocationContext) {
+
+ final OptableTargetingProperties properties = configResolver.resolve(invocationContext.accountConfig());
+ final ModuleContext moduleContext = new ModuleContext();
+
+ final BidRequest bidRequest = applyActivityRestrictions(auctionRequestPayload.bidRequest(), invocationContext);
+
+ final Timeout timeout = getHookTimeout(invocationContext);
+ final OptableAttributes attributes = OptableAttributesResolver.resolveAttributes(
+ invocationContext.auctionContext(),
+ properties.getTimeout());
+
+ final long callTargetingAPITimestamp = System.currentTimeMillis();
+ return optableTargeting.getTargeting(properties, bidRequest, attributes, timeout)
+ .compose(targetingResult -> {
+ moduleContext.setOptableTargetingExecutionTime(
+ System.currentTimeMillis() - callTargetingAPITimestamp);
+ return enrichedPayload(targetingResult, moduleContext);
+ })
+ .recover(throwable -> {
+ moduleContext.setOptableTargetingExecutionTime(
+ System.currentTimeMillis() - callTargetingAPITimestamp);
+ moduleContext.setEnrichRequestStatus(EnrichmentStatus.failure());
+ return update(BidRequestCleaner.instance(), moduleContext);
+ });
+ }
+
+ private BidRequest applyActivityRestrictions(BidRequest bidRequest,
+ AuctionInvocationContext auctionInvocationContext) {
+
+ final AuctionContext auctionContext = auctionInvocationContext.auctionContext();
+ final ActivityInvocationPayload activityInvocationPayload = BidRequestActivityInvocationPayload.of(
+ ActivityInvocationPayloadImpl.of(ComponentType.GENERAL_MODULE, OptableTargetingModule.CODE),
+ bidRequest);
+ final ActivityInfrastructure activityInfrastructure = auctionContext.getActivityInfrastructure();
+
+ final boolean disallowTransmitUfpd = !activityInfrastructure.isAllowed(
+ Activity.TRANSMIT_UFPD, activityInvocationPayload);
+ final boolean disallowTransmitEids = !activityInfrastructure.isAllowed(
+ Activity.TRANSMIT_EIDS, activityInvocationPayload);
+ final boolean disallowTransmitGeo = !activityInfrastructure.isAllowed(
+ Activity.TRANSMIT_GEO, activityInvocationPayload);
+
+ return maskUserPersonalInfo(bidRequest, disallowTransmitUfpd, disallowTransmitEids, disallowTransmitGeo);
+ }
+
+ private BidRequest maskUserPersonalInfo(BidRequest bidRequest,
+ boolean disallowTransmitUfpd,
+ boolean disallowTransmitEids,
+ boolean disallowTransmitGeo) {
+
+ final User maskedUser = userFpdActivityMask.maskUser(
+ bidRequest.getUser(), disallowTransmitUfpd, disallowTransmitEids);
+ final Device maskedDevice = userFpdActivityMask.maskDevice(
+ bidRequest.getDevice(), disallowTransmitUfpd, disallowTransmitGeo);
+
+ return bidRequest.toBuilder()
+ .user(maskedUser)
+ .device(maskedDevice)
+ .build();
+ }
+
+ private Timeout getHookTimeout(AuctionInvocationContext invocationContext) {
+ return invocationContext.timeout();
+ }
+
+ private Future> enrichedPayload(TargetingResult targetingResult,
+ ModuleContext moduleContext) {
+
+ moduleContext.setTargeting(targetingResult.getAudience());
+ moduleContext.setEnrichRequestStatus(EnrichmentStatus.success());
+ return update(
+ BidRequestCleaner.instance()
+ .andThen(BidRequestEnricher.of(targetingResult))
+ ::apply,
+ moduleContext);
+ }
+
+ private static Future> update(
+ PayloadUpdate payloadUpdate,
+ ModuleContext moduleContext) {
+
+ return Future.succeededFuture(
+ InvocationResultImpl.builder()
+ .status(InvocationStatus.success)
+ .action(InvocationAction.update)
+ .analyticsTags(AnalyticTagsResolver.toEnrichRequestAnalyticTags(moduleContext))
+ .payloadUpdate(payloadUpdate)
+ .moduleContext(moduleContext)
+ .build());
+ }
+
+ @Override
+ public String code() {
+ return CODE;
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/AnalyticTagsResolver.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/AnalyticTagsResolver.java
new file mode 100644
index 00000000000..80905ce088c
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/AnalyticTagsResolver.java
@@ -0,0 +1,68 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.prebid.server.hooks.execution.v1.analytics.ActivityImpl;
+import org.prebid.server.hooks.execution.v1.analytics.ResultImpl;
+import org.prebid.server.hooks.execution.v1.analytics.TagsImpl;
+import org.prebid.server.hooks.modules.optable.targeting.model.EnrichmentStatus;
+import org.prebid.server.hooks.modules.optable.targeting.model.ModuleContext;
+import org.prebid.server.hooks.modules.optable.targeting.model.Reason;
+import org.prebid.server.hooks.modules.optable.targeting.model.Status;
+import org.prebid.server.hooks.v1.analytics.Activity;
+import org.prebid.server.hooks.v1.analytics.Result;
+import org.prebid.server.hooks.v1.analytics.Tags;
+import org.prebid.server.json.ObjectMapperProvider;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+public class AnalyticTagsResolver {
+
+ private static final String ACTIVITY_ENRICH_REQUEST = "optable-enrich-request";
+ private static final String ACTIVITY_ENRICH_RESPONSE = "optable-enrich-response";
+ private static final String STATUS_EXECUTION_TIME = "execution-time";
+ private static final String STATUS_REASON = "reason";
+
+ private AnalyticTagsResolver() {
+ }
+
+ public static Tags toEnrichRequestAnalyticTags(ModuleContext moduleContext) {
+ return TagsImpl.of(Collections.singletonList(ActivityImpl.of(
+ ACTIVITY_ENRICH_REQUEST,
+ toEnrichmentStatusValue(moduleContext.getEnrichRequestStatus()),
+ toResults(STATUS_EXECUTION_TIME, String.valueOf(moduleContext.getOptableTargetingExecutionTime())))));
+ }
+
+ public static Tags toEnrichResponseAnalyticTags(ModuleContext moduleContext) {
+ final List activities = new ArrayList<>();
+ if (moduleContext.isAdserverTargetingEnabled()) {
+ activities.add(ActivityImpl.of(
+ ACTIVITY_ENRICH_RESPONSE,
+ toEnrichmentStatusValue(moduleContext.getEnrichResponseStatus()),
+ toResults(STATUS_REASON, toEnrichmentStatusReason(moduleContext.getEnrichResponseStatus()))));
+ }
+
+ return TagsImpl.of(Collections.unmodifiableList(activities));
+ }
+
+ private static String toEnrichmentStatusValue(EnrichmentStatus enrichRequestStatus) {
+ return Optional.ofNullable(enrichRequestStatus)
+ .map(EnrichmentStatus::getStatus)
+ .map(Status::getValue)
+ .orElse(null);
+ }
+
+ private static String toEnrichmentStatusReason(EnrichmentStatus enrichmentStatus) {
+ return Optional.ofNullable(enrichmentStatus)
+ .map(EnrichmentStatus::getReason)
+ .map(Reason::getValue)
+ .orElse(null);
+ }
+
+ private static List toResults(String result, String value) {
+ final ObjectNode resultDetails = ObjectMapperProvider.mapper().createObjectNode().put(result, value);
+ return Collections.singletonList(ResultImpl.of(null, resultDetails, null));
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/AuctionResponseValidator.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/AuctionResponseValidator.java
new file mode 100644
index 00000000000..44b22f8b92e
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/AuctionResponseValidator.java
@@ -0,0 +1,50 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import com.iab.openrtb.response.BidResponse;
+import com.iab.openrtb.response.SeatBid;
+import org.apache.commons.collections4.CollectionUtils;
+import org.prebid.server.hooks.modules.optable.targeting.model.EnrichmentStatus;
+import org.prebid.server.hooks.modules.optable.targeting.model.Reason;
+import org.prebid.server.hooks.modules.optable.targeting.model.Status;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Audience;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+public class AuctionResponseValidator {
+
+ private AuctionResponseValidator() {
+ }
+
+ public static EnrichmentStatus checkEnrichmentPossibility(BidResponse bidResponse, List targeting) {
+ if (!hasKeywords(targeting)) {
+ return EnrichmentStatus.of(Status.FAIL, Reason.NOKEYWORD);
+ } else if (!hasBids(bidResponse)) {
+ return EnrichmentStatus.of(Status.FAIL, Reason.NOBID);
+ }
+
+ return EnrichmentStatus.of(Status.SUCCESS, Reason.NONE);
+ }
+
+ private static boolean hasKeywords(List targeting) {
+ if (CollectionUtils.isEmpty(targeting)) {
+ return false;
+ }
+
+ return targeting.stream()
+ .filter(Objects::nonNull)
+ .anyMatch(audience -> CollectionUtils.isNotEmpty(audience.getIds()));
+ }
+
+ private static boolean hasBids(BidResponse bidResponse) {
+ final List seatBids = Optional.ofNullable(bidResponse).map(BidResponse::getSeatbid).orElse(null);
+ if (CollectionUtils.isEmpty(seatBids)) {
+ return false;
+ }
+
+ return seatBids.stream()
+ .filter(Objects::nonNull)
+ .anyMatch(seatBid -> CollectionUtils.isNotEmpty(seatBid.getBid()));
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestCleaner.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestCleaner.java
new file mode 100644
index 00000000000..30652383942
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestCleaner.java
@@ -0,0 +1,49 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.User;
+import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl;
+import org.prebid.server.hooks.v1.PayloadUpdate;
+import org.prebid.server.hooks.v1.auction.AuctionRequestPayload;
+import org.prebid.server.proto.openrtb.ext.request.ExtUser;
+
+import java.util.List;
+
+public class BidRequestCleaner implements PayloadUpdate {
+
+ private static final String OPTABLE_FIELD = "optable";
+ private static final List FIELDS_TO_REMOVE = List.of("email", "phone", "zip", "vid");
+
+ public static BidRequestCleaner instance() {
+ return new BidRequestCleaner();
+ }
+
+ @Override
+ public AuctionRequestPayload apply(AuctionRequestPayload payload) {
+ return AuctionRequestPayloadImpl.of(clearExtUserOptable(payload.bidRequest()));
+ }
+
+ private static BidRequest clearExtUserOptable(BidRequest bidRequest) {
+ final User user = bidRequest.getUser();
+ final ExtUser extUser = user != null ? user.getExt() : null;
+ final JsonNode optable = extUser != null ? extUser.getProperty(OPTABLE_FIELD) : null;
+ if (optable == null || !optable.isObject() || optable.isEmpty()) {
+ return bidRequest;
+ }
+
+ final ObjectNode cleanedOptable = cleanOptable((ObjectNode) optable);
+ if (cleanedOptable.isEmpty()) {
+ extUser.addProperty(OPTABLE_FIELD, null);
+ } else {
+ extUser.addProperty(OPTABLE_FIELD, cleanedOptable);
+ }
+
+ return bidRequest;
+ }
+
+ public static ObjectNode cleanOptable(ObjectNode optable) {
+ return optable.deepCopy().remove(FIELDS_TO_REMOVE);
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestEnricher.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestEnricher.java
new file mode 100644
index 00000000000..b3ea3e6dce9
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestEnricher.java
@@ -0,0 +1,126 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.Data;
+import com.iab.openrtb.request.Eid;
+import com.iab.openrtb.request.Segment;
+import org.apache.commons.collections4.CollectionUtils;
+import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Ortb2;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.User;
+import org.prebid.server.hooks.v1.PayloadUpdate;
+import org.prebid.server.hooks.v1.auction.AuctionRequestPayload;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public class BidRequestEnricher implements PayloadUpdate {
+
+ private final TargetingResult targetingResult;
+
+ private BidRequestEnricher(TargetingResult targetingResult) {
+ this.targetingResult = targetingResult;
+ }
+
+ public static BidRequestEnricher of(TargetingResult targetingResult) {
+ return new BidRequestEnricher(targetingResult);
+ }
+
+ @Override
+ public AuctionRequestPayload apply(AuctionRequestPayload payload) {
+ return AuctionRequestPayloadImpl.of(enrichBidRequest(payload.bidRequest()));
+ }
+
+ private BidRequest enrichBidRequest(BidRequest bidRequest) {
+ if (bidRequest == null || targetingResult == null) {
+ return bidRequest;
+ }
+
+ final User optableUser = Optional.of(targetingResult)
+ .map(TargetingResult::getOrtb2)
+ .map(Ortb2::getUser)
+ .orElse(null);
+
+ if (optableUser == null) {
+ return bidRequest;
+ }
+
+ final com.iab.openrtb.request.User bidRequestUser = Optional.ofNullable(bidRequest.getUser())
+ .orElseGet(() -> com.iab.openrtb.request.User.builder().build());
+
+ return bidRequest.toBuilder()
+ .user(mergeUserData(bidRequestUser, optableUser))
+ .build();
+ }
+
+ private static com.iab.openrtb.request.User mergeUserData(com.iab.openrtb.request.User user, User optableUser) {
+ return user.toBuilder()
+ .eids(mergeEids(user.getEids(), optableUser.getEids()))
+ .data(mergeData(user.getData(), optableUser.getData()))
+ .build();
+ }
+
+ private static List mergeEids(List destination, List source) {
+ return merge(
+ destination,
+ source,
+ Eid::getSource);
+ }
+
+ private static List mergeData(List destination, List source) {
+ if (CollectionUtils.isEmpty(destination)) {
+ return source;
+ }
+
+ if (CollectionUtils.isEmpty(source)) {
+ return destination;
+ }
+
+ final Map idToSourceData = source.stream()
+ .collect(Collectors.toMap(Data::getId, Function.identity(), (a, b) -> b, HashMap::new));
+
+ final List mergedData = destination.stream()
+ .map(destinationData -> idToSourceData.containsKey(destinationData.getId())
+ ? mergeData(destinationData, idToSourceData.get(destinationData.getId()))
+ : destinationData)
+ .toList();
+
+ return merge(mergedData, source, Data::getId);
+ }
+
+ private static Data mergeData(Data destinationData, Data sourceData) {
+ return destinationData.toBuilder()
+ .segment(merge(destinationData.getSegment(), sourceData.getSegment(), Segment::getId))
+ .build();
+ }
+
+ private static List merge(List destination,
+ List source,
+ Function idExtractor) {
+
+ if (CollectionUtils.isEmpty(source)) {
+ return destination;
+ }
+
+ if (CollectionUtils.isEmpty(destination)) {
+ return source;
+ }
+
+ final Set existingIds = destination.stream()
+ .map(idExtractor)
+ .collect(Collectors.toSet());
+
+ return Stream.concat(
+ destination.stream(),
+ source.stream()
+ .filter(entry -> !existingIds.contains(idExtractor.apply(entry))))
+ .toList();
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidResponseEnricher.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidResponseEnricher.java
new file mode 100644
index 00000000000..ec9fe4ef0a7
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidResponseEnricher.java
@@ -0,0 +1,110 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.TextNode;
+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.exception.InvalidRequestException;
+import org.prebid.server.hooks.execution.v1.auction.AuctionResponsePayloadImpl;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Audience;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.AudienceId;
+import org.prebid.server.hooks.v1.PayloadUpdate;
+import org.prebid.server.hooks.v1.auction.AuctionResponsePayload;
+import org.prebid.server.json.JsonMerger;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+public class BidResponseEnricher implements PayloadUpdate {
+
+ private final List targeting;
+ private final ObjectMapper mapper;
+ private final JsonMerger jsonMerger;
+
+ private BidResponseEnricher(List targeting, ObjectMapper mapper, JsonMerger jsonMerger) {
+ this.targeting = targeting;
+ this.mapper = Objects.requireNonNull(mapper);
+ this.jsonMerger = Objects.requireNonNull(jsonMerger);
+ }
+
+ public static BidResponseEnricher of(List targeting, ObjectMapper mapper, JsonMerger jsonMerger) {
+ return new BidResponseEnricher(targeting, mapper, jsonMerger);
+ }
+
+ @Override
+ public AuctionResponsePayload apply(AuctionResponsePayload payload) {
+ return AuctionResponsePayloadImpl.of(enrichBidResponse(payload.bidResponse(), targeting));
+ }
+
+ private BidResponse enrichBidResponse(BidResponse bidResponse, List targeting) {
+ if (CollectionUtils.isEmpty(targeting)) {
+ return bidResponse;
+ }
+
+ final ObjectNode node = targetingToObjectNode(targeting);
+ if (node.isEmpty()) {
+ return bidResponse;
+ }
+
+ final List seatBids = CollectionUtils.emptyIfNull(bidResponse.getSeatbid()).stream()
+ .map(seatBid -> seatBid.toBuilder()
+ .bid(CollectionUtils.emptyIfNull(seatBid.getBid()).stream()
+ .map(bid -> applyTargeting(bid, node))
+ .toList())
+ .build())
+ .toList();
+
+ return bidResponse.toBuilder()
+ .seatbid(seatBids)
+ .build();
+ }
+
+ private ObjectNode targetingToObjectNode(List targeting) {
+ final ObjectNode node = mapper.createObjectNode();
+
+ for (Audience audience : targeting) {
+ final List ids = audience.getIds();
+ if (CollectionUtils.isEmpty(ids)) {
+ continue;
+ }
+
+ final String joinedIds = ids.stream()
+ .map(AudienceId::getId)
+ .collect(Collectors.joining(","));
+ node.putIfAbsent(audience.getKeyspace(), TextNode.valueOf(joinedIds));
+ }
+
+ return node;
+ }
+
+ private Bid applyTargeting(Bid bid, ObjectNode node) {
+ final ObjectNode ext = Optional.ofNullable(bid.getExt())
+ .map(ObjectNode::deepCopy)
+ .orElseGet(mapper::createObjectNode);
+
+ final ObjectNode prebid = newNodeIfNull(ext.get("prebid"));
+ final ObjectNode targeting;
+ try {
+ targeting = (ObjectNode) jsonMerger.merge(node, newNodeIfNull(prebid.get("targeting")));
+ } catch (InvalidRequestException e) {
+ return bid;
+ }
+
+ prebid.set("targeting", targeting);
+ ext.set("prebid", prebid);
+
+ return bid.toBuilder().ext(ext).build();
+ }
+
+ private ObjectNode newNodeIfNull(JsonNode node) {
+ return node == null || !node.isObject()
+ ? mapper.createObjectNode()
+ : (ObjectNode) node;
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Cache.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Cache.java
new file mode 100644
index 00000000000..6aab8698a4a
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Cache.java
@@ -0,0 +1,44 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import io.vertx.core.Future;
+import org.prebid.server.cache.PbcStorageService;
+import org.prebid.server.cache.proto.request.module.StorageDataType;
+import org.prebid.server.cache.proto.response.module.ModuleCacheResponse;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult;
+import org.prebid.server.json.JacksonMapper;
+
+import java.util.Objects;
+
+public class Cache {
+
+ private static final String APP_CODE = "prebid-Java";
+ private static final String APPLICATION = "optable-targeting";
+
+ private final PbcStorageService cacheService;
+ private final JacksonMapper mapper;
+
+ public Cache(PbcStorageService cacheService, JacksonMapper mapper) {
+ this.cacheService = Objects.requireNonNull(cacheService);
+ this.mapper = Objects.requireNonNull(mapper);
+ }
+
+ public Future get(String query) {
+ return cacheService.retrieveEntry(query, APP_CODE, APPLICATION)
+ .map(ModuleCacheResponse::getValue)
+ .map(body -> body != null ? mapper.decodeValue(body, TargetingResult.class) : null);
+ }
+
+ public Future put(String query, TargetingResult value, int ttlSeconds) {
+ if (value == null) {
+ return Future.succeededFuture();
+ }
+
+ return cacheService.storeEntry(
+ query,
+ mapper.encodeToString(value),
+ StorageDataType.TEXT,
+ ttlSeconds,
+ APPLICATION,
+ APP_CODE);
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/ConfigResolver.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/ConfigResolver.java
new file mode 100644
index 00000000000..219f37af2ab
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/ConfigResolver.java
@@ -0,0 +1,40 @@
+package org.prebid.server.hooks.modules.optable.targeting.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.optable.targeting.model.config.OptableTargetingProperties;
+import org.prebid.server.json.JsonMerger;
+
+import java.util.Objects;
+import java.util.Optional;
+
+public class ConfigResolver {
+
+ private final ObjectMapper mapper;
+ private final JsonMerger jsonMerger;
+ private final OptableTargetingProperties globalProperties;
+ private final JsonNode globalPropertiesObjectNode;
+
+ public ConfigResolver(ObjectMapper mapper, JsonMerger jsonMerger, OptableTargetingProperties globalProperties) {
+ this.mapper = Objects.requireNonNull(mapper);
+ this.jsonMerger = Objects.requireNonNull(jsonMerger);
+ this.globalProperties = Objects.requireNonNull(globalProperties);
+ this.globalPropertiesObjectNode = Objects.requireNonNull(mapper.valueToTree(globalProperties));
+ }
+
+ public OptableTargetingProperties resolve(ObjectNode configNode) {
+ final JsonNode mergedNode = jsonMerger.merge(configNode, globalPropertiesObjectNode);
+ return parse(mergedNode).orElse(globalProperties);
+ }
+
+ private Optional parse(JsonNode configNode) {
+ try {
+ return Optional.ofNullable(configNode)
+ .filter(node -> !node.isEmpty())
+ .map(node -> mapper.convertValue(node, OptableTargetingProperties.class));
+ } catch (IllegalArgumentException e) {
+ return Optional.empty();
+ }
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapper.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapper.java
new file mode 100644
index 00000000000..7214f6ce6a3
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapper.java
@@ -0,0 +1,133 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+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.collections4.CollectionUtils;
+import org.apache.commons.collections4.MapUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.prebid.server.hooks.modules.optable.targeting.model.Id;
+import org.prebid.server.hooks.modules.optable.targeting.model.OS;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.ExtUserOptable;
+import org.prebid.server.log.ConditionalLogger;
+import org.prebid.server.log.LoggerFactory;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+public class IdsMapper {
+
+ private static final ConditionalLogger conditionalLogger =
+ new ConditionalLogger(LoggerFactory.getLogger(IdsMapper.class));
+
+ private static final Map STATIC_PPID_MAPPING = Map.of(
+ "id5-sync.com", Id.ID5,
+ "utiq.com", Id.UTIQ,
+ "netid.de", Id.NET_ID);
+
+ private final ObjectMapper objectMapper;
+ private final double logSamplingRate;
+
+ public IdsMapper(ObjectMapper objectMapper, double logSamplingRate) {
+ this.objectMapper = Objects.requireNonNull(objectMapper);
+ this.logSamplingRate = logSamplingRate;
+ }
+
+ public List toIds(BidRequest bidRequest, Map ppidMapping) {
+ final User user = bidRequest.getUser();
+
+ final Map ids = new HashMap<>();
+ addOptableIds(ids, user);
+ addDeviceIds(ids, bidRequest.getDevice());
+ addEidsIds(ids, user, STATIC_PPID_MAPPING);
+ addEidsIds(ids, user, ppidMapping);
+
+ return ids.entrySet().stream()
+ .map(it -> Id.of(it.getKey(), it.getValue()))
+ .toList();
+ }
+
+ private void addOptableIds(Map ids, User user) {
+ final Optional extUserOptable = Optional.ofNullable(user)
+ .map(User::getExt)
+ .map(ext -> ext.getProperty("optable"))
+ .map(this::parseExtUserOptable);
+
+ extUserOptable.map(ExtUserOptable::getEmail).ifPresent(it -> ids.put(Id.EMAIL, it));
+ extUserOptable.map(ExtUserOptable::getPhone).ifPresent(it -> ids.put(Id.PHONE, it));
+ extUserOptable.map(ExtUserOptable::getZip).ifPresent(it -> ids.put(Id.ZIP, it));
+ extUserOptable.map(ExtUserOptable::getVid).ifPresent(it -> ids.put(Id.OPTABLE_VID, it));
+ }
+
+ private ExtUserOptable parseExtUserOptable(JsonNode node) {
+ try {
+ return objectMapper.treeToValue(node, ExtUserOptable.class);
+ } catch (JsonProcessingException e) {
+ conditionalLogger.warn("Can't parse $.ext.user.Optable tag", logSamplingRate);
+ return null;
+ }
+ }
+
+ private static void addDeviceIds(Map ids, Device device) {
+ final String ifa = device != null ? device.getIfa() : null;
+ final String os = device != null ? StringUtils.toRootLowerCase(device.getOs()) : null;
+ final int lmt = Optional.ofNullable(device).map(Device::getLmt).orElse(0);
+
+ if (ifa == null || StringUtils.isEmpty(os) || lmt == 1) {
+ return;
+ }
+
+ if (os.contains(OS.IOS.getValue())) {
+ ids.put(Id.APPLE_IDFA, ifa);
+ }
+ if (os.contains(OS.ANDROID.getValue())) {
+ ids.put(Id.GOOGLE_GAID, ifa);
+ }
+ if (os.contains(OS.ROKU.getValue())) {
+ ids.put(Id.ROKU_RIDA, ifa);
+ }
+ if (os.contains(OS.TIZEN.getValue())) {
+ ids.put(Id.SAMSUNG_TV_TIFA, ifa);
+ }
+ if (os.contains(OS.FIRE.getValue())) {
+ ids.put(Id.AMAZON_FIRE_AFAI, ifa);
+ }
+ }
+
+ private static void addEidsIds(Map ids, User user, Map ppidMapping) {
+ final List eids = user != null ? user.getEids() : null;
+ if (MapUtils.isEmpty(ppidMapping) || CollectionUtils.isEmpty(eids)) {
+ return;
+ }
+
+ for (Eid eid : eids) {
+ final String source = eid != null ? eid.getSource() : null;
+ if (source == null) {
+ continue;
+ }
+
+ final String idKey = ppidMapping.get(source);
+ if (idKey != null) {
+ firstUidId(eid).ifPresent(it -> ids.put(idKey, it));
+ }
+ }
+ }
+
+ private static Optional firstUidId(Eid eid) {
+ return Optional.ofNullable(eid.getUids())
+ .orElse(Collections.emptyList())
+ .stream()
+ .filter(Objects::nonNull)
+ .findFirst()
+ .map(Uid::getId);
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java
new file mode 100644
index 00000000000..6fda659278f
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java
@@ -0,0 +1,56 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import com.iab.openrtb.request.Device;
+import org.apache.commons.collections4.SetUtils;
+import org.prebid.server.auction.gpp.model.GppContext;
+import org.prebid.server.auction.model.AuctionContext;
+import org.prebid.server.hooks.modules.optable.targeting.model.OptableAttributes;
+import org.prebid.server.privacy.gdpr.model.TcfContext;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+public class OptableAttributesResolver {
+
+ private OptableAttributesResolver() {
+ }
+
+ public static OptableAttributes resolveAttributes(AuctionContext auctionContext, Long timeout) {
+ final TcfContext tcfContext = auctionContext.getPrivacyContext().getTcfContext();
+ final GppContext.Scope gppScope = auctionContext.getGppContext().scope();
+
+ final OptableAttributes.OptableAttributesBuilder builder = OptableAttributes.builder()
+ .ips(resolveIp(auctionContext))
+ .timeout(timeout);
+
+ if (tcfContext.isConsentValid()) {
+ builder
+ .gdprApplies(tcfContext.isInGdprScope())
+ .gdprConsent(tcfContext.getConsentString());
+ }
+
+ if (gppScope.getGppModel() != null) {
+ builder
+ .gpp(gppScope.getGppModel().encode())
+ .gppSid(SetUtils.emptyIfNull(gppScope.getSectionsIds()));
+ }
+
+ return builder.build();
+ }
+
+ public static List resolveIp(AuctionContext auctionContext) {
+ final List result = new ArrayList<>();
+
+ final Optional deviceOpt = Optional.ofNullable(auctionContext.getBidRequest().getDevice());
+ deviceOpt.map(Device::getIp).ifPresent(result::add);
+ deviceOpt.map(Device::getIpv6).ifPresent(result::add);
+
+ if (result.isEmpty()) {
+ Optional.ofNullable(auctionContext.getPrivacyContext().getIpAddress())
+ .ifPresent(result::add);
+ }
+
+ return result;
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableTargeting.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableTargeting.java
new file mode 100644
index 00000000000..c45ce8b6f9f
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableTargeting.java
@@ -0,0 +1,39 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import com.iab.openrtb.request.BidRequest;
+import io.vertx.core.Future;
+import org.prebid.server.execution.timeout.Timeout;
+import org.prebid.server.hooks.modules.optable.targeting.model.Id;
+import org.prebid.server.hooks.modules.optable.targeting.model.OptableAttributes;
+import org.prebid.server.hooks.modules.optable.targeting.model.Query;
+import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult;
+import org.prebid.server.hooks.modules.optable.targeting.v1.net.APIClient;
+
+import java.util.List;
+import java.util.Objects;
+
+public class OptableTargeting {
+
+ private final IdsMapper idsMapper;
+ private final APIClient apiClient;
+
+ public OptableTargeting(IdsMapper idsMapper, APIClient apiClient) {
+ this.idsMapper = Objects.requireNonNull(idsMapper);
+ this.apiClient = Objects.requireNonNull(apiClient);
+ }
+
+ public Future getTargeting(OptableTargetingProperties properties,
+ BidRequest bidRequest,
+ OptableAttributes attributes,
+ Timeout timeout) {
+
+ final List ids = idsMapper.toIds(bidRequest, properties.getPpidMapping());
+ final Query query = QueryBuilder.build(ids, attributes, properties.getIdPrefixOrder());
+ if (query == null) {
+ return Future.failedFuture("Can't get targeting");
+ }
+
+ return apiClient.getTargeting(properties, query, attributes.getIps(), timeout);
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java
new file mode 100644
index 00000000000..2cf964bf63b
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java
@@ -0,0 +1,86 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.prebid.server.hooks.modules.optable.targeting.model.Id;
+import org.prebid.server.hooks.modules.optable.targeting.model.OptableAttributes;
+import org.prebid.server.hooks.modules.optable.targeting.model.Query;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+public class QueryBuilder {
+
+ private QueryBuilder() {
+ }
+
+ public static Query build(List ids, OptableAttributes optableAttributes, String idPrefixOrder) {
+ if (CollectionUtils.isEmpty(ids) && CollectionUtils.isEmpty(optableAttributes.getIps())) {
+ return null;
+ }
+
+ return Query.of(buildIdsString(ids, idPrefixOrder), buildAttributesString(optableAttributes));
+ }
+
+ private static String buildIdsString(List ids, String idPrefixOrder) {
+ if (CollectionUtils.isEmpty(ids)) {
+ return StringUtils.EMPTY;
+ }
+
+ final List reorderedIds = reorderIds(ids, idPrefixOrder);
+
+ final StringBuilder sb = new StringBuilder();
+ for (Id id : reorderedIds) {
+ sb.append("&id=");
+ sb.append(URLEncoder.encode(
+ "%s:%s".formatted(id.getName(), id.getValue()),
+ StandardCharsets.UTF_8));
+ }
+
+ return sb.toString();
+ }
+
+ private static List reorderIds(List ids, String idPrefixOrder) {
+ if (StringUtils.isEmpty(idPrefixOrder)) {
+ return ids;
+ }
+
+ final String[] prefixOrder = idPrefixOrder.split(",");
+ final Map prefixToPriority = IntStream.range(0, prefixOrder.length).boxed()
+ .collect(Collectors.toMap(i -> prefixOrder[i], Function.identity()));
+
+ final List orderedIds = new ArrayList<>(ids);
+ orderedIds.sort(Comparator.comparing(item -> prefixToPriority.getOrDefault(item.getName(), Integer.MAX_VALUE)));
+
+ return orderedIds;
+ }
+
+ private static String buildAttributesString(OptableAttributes optableAttributes) {
+ final StringBuilder sb = new StringBuilder();
+
+ Optional.ofNullable(optableAttributes.getGdprConsent())
+ .ifPresent(consent -> sb.append("&gdpr_consent=").append(consent));
+ sb.append("&gdpr=").append(optableAttributes.isGdprApplies() ? 1 : 0);
+
+ Optional.ofNullable(optableAttributes.getGpp())
+ .ifPresent(gpp -> sb.append("&gpp=").append(gpp));
+ Optional.ofNullable(optableAttributes.getGppSid())
+ .filter(Predicate.not(Collection::isEmpty))
+ .ifPresent(gppSids -> sb.append("&gpp_sid=").append(gppSids.stream().findFirst()));
+
+ Optional.ofNullable(optableAttributes.getTimeout())
+ .ifPresent(timeout -> sb.append("&timeout=").append(timeout).append("ms"));
+
+ return sb.toString();
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/APIClient.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/APIClient.java
new file mode 100644
index 00000000000..9e0a4f3db17
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/APIClient.java
@@ -0,0 +1,17 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.net;
+
+import io.vertx.core.Future;
+import org.prebid.server.execution.timeout.Timeout;
+import org.prebid.server.hooks.modules.optable.targeting.model.Query;
+import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult;
+
+import java.util.List;
+
+public interface APIClient {
+
+ Future getTargeting(OptableTargetingProperties properties,
+ Query query,
+ List ips,
+ Timeout timeout);
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/APIClientImpl.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/APIClientImpl.java
new file mode 100644
index 00000000000..e0e6ac94fec
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/APIClientImpl.java
@@ -0,0 +1,109 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.net;
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.vertx.core.Future;
+import io.vertx.core.MultiMap;
+import io.vertx.core.http.impl.headers.HeadersMultiMap;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.prebid.server.execution.timeout.Timeout;
+import org.prebid.server.hooks.modules.optable.targeting.model.Query;
+import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult;
+import org.prebid.server.json.JacksonMapper;
+import org.prebid.server.log.ConditionalLogger;
+import org.prebid.server.log.Logger;
+import org.prebid.server.log.LoggerFactory;
+import org.prebid.server.util.HttpUtil;
+import org.prebid.server.validation.ValidationException;
+import org.prebid.server.vertx.httpclient.HttpClient;
+import org.prebid.server.vertx.httpclient.model.HttpClientResponse;
+
+import java.util.List;
+import java.util.Objects;
+
+public class APIClientImpl implements APIClient {
+
+ private static final Logger logger = LoggerFactory.getLogger(APIClientImpl.class);
+ private static final ConditionalLogger conditionalLogger = new ConditionalLogger(logger);
+
+ private static final String TENANT = "{TENANT}";
+ private static final String ORIGIN = "{ORIGIN}";
+
+ private final String endpoint;
+ private final HttpClient httpClient;
+ private final JacksonMapper mapper;
+ private final double logSamplingRate;
+
+ public APIClientImpl(String endpoint,
+ HttpClient httpClient,
+ JacksonMapper mapper,
+ double logSamplingRate) {
+
+ this.endpoint = HttpUtil.validateUrl(Objects.requireNonNull(endpoint));
+ this.httpClient = Objects.requireNonNull(httpClient);
+ this.mapper = Objects.requireNonNull(mapper);
+ this.logSamplingRate = logSamplingRate;
+ }
+
+ public Future getTargeting(OptableTargetingProperties properties,
+ Query query,
+ List ips,
+ Timeout timeout) {
+
+ final String uri = resolveEndpoint(properties.getTenant(), properties.getOrigin());
+ final String queryAsString = query.toQueryString();
+ final MultiMap headers = headers(properties, ips);
+
+ return httpClient.get(uri + queryAsString, headers, timeout.remaining())
+ .compose(this::validateResponse)
+ .map(this::parseResponse)
+ .onFailure(exception -> logError(exception, uri));
+ }
+
+ private String resolveEndpoint(String tenant, String origin) {
+ return endpoint
+ .replace(TENANT, tenant)
+ .replace(ORIGIN, origin);
+ }
+
+ private static MultiMap headers(OptableTargetingProperties properties, List ips) {
+ final MultiMap headers = HeadersMultiMap.headers()
+ .add(HttpUtil.ACCEPT_HEADER, "application/json");
+
+ final String apiKey = properties.getApiKey();
+ if (StringUtils.isNotEmpty(apiKey)) {
+ headers.add(HttpUtil.AUTHORIZATION_HEADER, "Bearer %s".formatted(apiKey));
+ }
+
+ CollectionUtils.emptyIfNull(ips)
+ .forEach(ip -> headers.add(HttpUtil.X_FORWARDED_FOR_HEADER, ip));
+
+ return headers;
+ }
+
+ private Future validateResponse(HttpClientResponse response) {
+ if (response.getStatusCode() != HttpResponseStatus.OK.code()) {
+ return Future.failedFuture(new ValidationException("Invalid status code: %d", response.getStatusCode()));
+ }
+
+ if (StringUtils.isBlank(response.getBody())) {
+ return Future.failedFuture(new ValidationException("Empty body"));
+ }
+
+ return Future.succeededFuture(response);
+ }
+
+ private TargetingResult parseResponse(HttpClientResponse httpResponse) {
+ return mapper.decodeValue(httpResponse.getBody(), TargetingResult.class);
+ }
+
+ private void logError(Throwable exception, String url) {
+ final String errorPrefix = "Error occurred while sending HTTP request to the Optable url:";
+
+ final String error = errorPrefix + " %s with message: %s".formatted(url, exception.getMessage());
+ conditionalLogger.warn(error, logSamplingRate);
+
+ logger.debug(errorPrefix + " {}", exception, url);
+ }
+}
diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClient.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClient.java
new file mode 100644
index 00000000000..73ade87e5e8
--- /dev/null
+++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClient.java
@@ -0,0 +1,63 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.net;
+
+import io.vertx.core.Future;
+import org.prebid.server.execution.timeout.Timeout;
+import org.prebid.server.hooks.modules.optable.targeting.model.Query;
+import org.prebid.server.hooks.modules.optable.targeting.model.config.CacheProperties;
+import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.Cache;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Objects;
+
+public class CachedAPIClient implements APIClient {
+
+ private final APIClient apiClient;
+ private final Cache cache;
+ private final boolean isCircuitBreakerEnabled;
+
+ public CachedAPIClient(APIClient apiClient, Cache cache, boolean isCircuitBreakerEnabled) {
+ this.apiClient = Objects.requireNonNull(apiClient);
+ this.cache = Objects.requireNonNull(cache);
+ this.isCircuitBreakerEnabled = isCircuitBreakerEnabled;
+ }
+
+ public Future getTargeting(OptableTargetingProperties properties,
+ Query query,
+ List ips,
+ Timeout timeout) {
+
+ final CacheProperties cacheProperties = properties.getCache();
+ if (!cacheProperties.isEnabled()) {
+ return apiClient.getTargeting(properties, query, ips, timeout);
+ }
+
+ final String tenant = properties.getTenant();
+ final String origin = properties.getOrigin();
+
+ return cache.get(createCachingKey(tenant, origin, ips, query, true))
+ .recover(ignore -> apiClient.getTargeting(properties, query, ips, timeout)
+ .recover(throwable -> isCircuitBreakerEnabled
+ ? Future.succeededFuture(new TargetingResult(null, null))
+ : Future.failedFuture(throwable))
+ .compose(result -> cache.put(
+ createCachingKey(tenant, origin, ips, query, false),
+ result,
+ cacheProperties.getTtlseconds())
+ .otherwiseEmpty()
+ .map(result)));
+ }
+
+ private String createCachingKey(String tenant, String origin, List ips, Query query, boolean encodeQuery) {
+ return "%s:%s:%s:%s".formatted(
+ tenant,
+ origin,
+ ips.getFirst(),
+ encodeQuery
+ ? URLEncoder.encode(query.getIds(), StandardCharsets.UTF_8)
+ : query.getIds());
+ }
+}
diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/BaseOptableTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/BaseOptableTest.java
new file mode 100644
index 00000000000..8a5c653cbcb
--- /dev/null
+++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/BaseOptableTest.java
@@ -0,0 +1,250 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.TextNode;
+import com.iab.gpp.encoder.GppModel;
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.Data;
+import com.iab.openrtb.request.Device;
+import com.iab.openrtb.request.Eid;
+import com.iab.openrtb.request.Geo;
+import com.iab.openrtb.request.Segment;
+import com.iab.openrtb.request.Uid;
+import com.iab.openrtb.request.User;
+import com.iab.openrtb.response.Bid;
+import com.iab.openrtb.response.BidResponse;
+import com.iab.openrtb.response.SeatBid;
+import io.vertx.core.MultiMap;
+import io.vertx.core.http.impl.headers.HeadersMultiMap;
+import org.apache.commons.io.IOUtils;
+import org.apache.http.HttpStatus;
+import org.prebid.server.activity.infrastructure.ActivityInfrastructure;
+import org.prebid.server.auction.gpp.model.GppContext;
+import org.prebid.server.auction.model.AuctionContext;
+import org.prebid.server.auction.model.TimeoutContext;
+import org.prebid.server.execution.timeout.Timeout;
+import org.prebid.server.hooks.modules.optable.targeting.model.EnrichmentStatus;
+import org.prebid.server.hooks.modules.optable.targeting.model.ModuleContext;
+import org.prebid.server.hooks.modules.optable.targeting.model.Query;
+import org.prebid.server.hooks.modules.optable.targeting.model.config.CacheProperties;
+import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Audience;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.AudienceId;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Ortb2;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult;
+import org.prebid.server.json.JacksonMapper;
+import org.prebid.server.json.JsonMerger;
+import org.prebid.server.json.ObjectMapperProvider;
+import org.prebid.server.privacy.gdpr.model.TcfContext;
+import org.prebid.server.privacy.model.Privacy;
+import org.prebid.server.privacy.model.PrivacyContext;
+import org.prebid.server.proto.openrtb.ext.request.ExtUser;
+import org.prebid.server.vertx.httpclient.model.HttpClientResponse;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.UnaryOperator;
+
+public abstract class BaseOptableTest {
+
+ protected final ObjectMapper mapper = ObjectMapperProvider.mapper();
+
+ protected final JsonMerger jsonMerger = new JsonMerger(new JacksonMapper(mapper));
+
+ protected ModuleContext givenModuleContext() {
+ return givenModuleContext(null);
+ }
+
+ protected ModuleContext givenModuleContext(List audiences) {
+ final ModuleContext moduleContext = new ModuleContext();
+ moduleContext.setTargeting(audiences);
+ moduleContext.setEnrichRequestStatus(EnrichmentStatus.success());
+
+ return moduleContext;
+ }
+
+ protected AuctionContext givenAuctionContext(ActivityInfrastructure activityInfrastructure, Timeout timeout) {
+ final GppModel gppModel = new GppModel();
+ final TcfContext tcfContext = TcfContext.builder().build();
+ final GppContext gppContext = new GppContext(
+ GppContext.Scope.of(gppModel, Set.of(1)),
+ GppContext.Regions.builder().build());
+
+ return AuctionContext.builder()
+ .bidRequest(givenBidRequest())
+ .activityInfrastructure(activityInfrastructure)
+ .privacyContext(PrivacyContext.of(Privacy.builder().build(), tcfContext, "8.8.8.8"))
+ .gppContext(gppContext)
+ .timeoutContext(TimeoutContext.of(0, timeout, 1))
+ .build();
+ }
+
+ protected BidRequest givenBidRequest() {
+ return givenBidRequestWithUserEids(null);
+ }
+
+ protected static BidRequest givenBidRequest(UnaryOperator bidRequestCustomizer) {
+ return bidRequestCustomizer.apply(BidRequest.builder().id("requestId")).build();
+ }
+
+ protected BidRequest givenBidRequestWithUserEids(List eids) {
+ return BidRequest.builder()
+ .user(givenUser(eids))
+ .device(givenDevice())
+ .cur(List.of("USD"))
+ .build();
+ }
+
+ protected BidRequest givenBidRequestWithUserData(List data) {
+ return BidRequest.builder()
+ .user(givenUserWithData(data))
+ .device(givenDevice())
+ .cur(List.of("USD"))
+ .build();
+ }
+
+ protected BidResponse givenBidResponse() {
+ final ObjectNode targetingNode = mapper.createObjectNode();
+ targetingNode.set("attribute1", TextNode.valueOf("value1"));
+ targetingNode.set("attribute2", TextNode.valueOf("value1"));
+ final ObjectNode bidderNode = mapper.createObjectNode();
+ bidderNode.set("targeting", targetingNode);
+ final ObjectNode bidExtNode = mapper.createObjectNode();
+ bidExtNode.set("prebid", bidderNode);
+
+ return BidResponse.builder()
+ .seatbid(List.of(SeatBid.builder()
+ .bid(List.of(Bid.builder().ext(bidExtNode).build()))
+ .build()))
+ .build();
+ }
+
+ protected TargetingResult givenTargetingResultWithEids(List eids) {
+ return givenTargetingResult(eids, null);
+ }
+
+ protected TargetingResult givenTargetingResultWithData(List data) {
+ return givenTargetingResult(null, data);
+ }
+
+ protected TargetingResult givenTargetingResult() {
+ return givenTargetingResult(
+ List.of(Eid.builder()
+ .source("source")
+ .uids(List.of(Uid.builder().id("id").build()))
+ .build()),
+ List.of(Data.builder()
+ .id("id")
+ .segment(List.of(Segment.builder().id("id").build()))
+ .build()));
+ }
+
+ protected TargetingResult givenTargetingResult(List eids, List data) {
+ return new TargetingResult(
+ List.of(new Audience(
+ "provider",
+ List.of(new AudienceId("id")),
+ "keyspace",
+ 1)),
+ new Ortb2(new org.prebid.server.hooks.modules.optable.targeting.model.openrtb.User(eids, data)));
+ }
+
+ protected TargetingResult givenEmptyTargetingResult() {
+ return new TargetingResult(Collections.emptyList(), new Ortb2(null));
+ }
+
+ protected User givenUser() {
+ return givenUser(null);
+ }
+
+ protected User givenUser(List eids) {
+ final ObjectNode optable = mapper.createObjectNode();
+ optable.set("email", TextNode.valueOf("email"));
+ optable.set("phone", TextNode.valueOf("phone"));
+ optable.set("zip", TextNode.valueOf("zip"));
+ optable.set("vid", TextNode.valueOf("vid"));
+
+ final ExtUser extUser = ExtUser.builder().build();
+ extUser.addProperty("optable", optable);
+
+ return User.builder()
+ .eids(eids)
+ .geo(Geo.builder().country("country-u").region("region-u").build())
+ .ext(extUser)
+ .build();
+ }
+
+ protected User givenUserWithData(List data) {
+ return User.builder()
+ .data(data)
+ .build();
+ }
+
+ protected Device givenDevice() {
+ return Device.builder().geo(Geo.builder().country("country-d").region("region-d").build()).build();
+ }
+
+ protected HttpClientResponse givenSuccessHttpResponse(String fileName) {
+ final MultiMap headers = HeadersMultiMap.headers().add("Content-Type", "application/json");
+ return HttpClientResponse.of(HttpStatus.SC_OK, headers, givenBodyFromFile(fileName));
+ }
+
+ protected HttpClientResponse givenFailHttpResponse(String fileName) {
+ return givenFailHttpResponse(HttpStatus.SC_BAD_REQUEST, fileName);
+ }
+
+ protected HttpClientResponse givenFailHttpResponse(int statusCode, String fileName) {
+ return HttpClientResponse.of(statusCode, null, givenBodyFromFile(fileName));
+ }
+
+ protected String givenBodyFromFile(String fileName) {
+ InputStream inputStream = null;
+ try {
+ inputStream = Files.newInputStream(Paths.get("src/test/resources/" + fileName));
+ return IOUtils.toString(inputStream, StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ return null;
+ } finally {
+ if (inputStream != null) {
+ try {
+ inputStream.close();
+ } catch (IOException e) {
+ // ignore
+ }
+ }
+ }
+ }
+
+ protected OptableTargetingProperties givenOptableTargetingProperties(boolean enableCache) {
+ return givenOptableTargetingProperties("key", enableCache);
+ }
+
+ protected OptableTargetingProperties givenOptableTargetingProperties(String key, boolean enableCache) {
+ final CacheProperties cacheProperties = new CacheProperties();
+ cacheProperties.setEnabled(enableCache);
+
+ final OptableTargetingProperties optableTargetingProperties = new OptableTargetingProperties();
+ optableTargetingProperties.setApiEndpoint("endpoint");
+ optableTargetingProperties.setTenant("accountId");
+ optableTargetingProperties.setOrigin("origin");
+ optableTargetingProperties.setApiKey(key);
+ optableTargetingProperties.setPpidMapping(Map.of("c", "id"));
+ optableTargetingProperties.setAdserverTargeting(true);
+ optableTargetingProperties.setTimeout(100L);
+ optableTargetingProperties.setCache(cacheProperties);
+
+ return optableTargetingProperties;
+ }
+
+ protected Query givenQuery() {
+ return Query.of("que", "ry");
+ }
+}
diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java
new file mode 100644
index 00000000000..af4a809df78
--- /dev/null
+++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java
@@ -0,0 +1,145 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.iab.openrtb.response.BidResponse;
+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.execution.v1.auction.AuctionResponsePayloadImpl;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Audience;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.AudienceId;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.ConfigResolver;
+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 java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mock.Strictness.LENIENT;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class OptableTargetingAuctionResponseHookTest extends BaseOptableTest {
+
+ private ConfigResolver configResolver;
+ private AuctionResponseHook target;
+
+ @Mock
+ private AuctionResponsePayload auctionResponsePayload;
+ @Mock(strictness = LENIENT)
+ private AuctionInvocationContext invocationContext;
+
+ @BeforeEach
+ public void setUp() {
+ when(invocationContext.accountConfig()).thenReturn(givenAccountConfig(true));
+ configResolver = new ConfigResolver(mapper, jsonMerger, givenOptableTargetingProperties(false));
+ target = new OptableTargetingAuctionResponseHook(
+ configResolver,
+ mapper,
+ jsonMerger);
+ }
+
+ @Test
+ public void shouldHaveCode() {
+ // when and then
+ assertThat(target.code()).isEqualTo("optable-targeting-auction-response-hook");
+
+ }
+
+ @Test
+ public void shouldReturnResultWithNoActionAndWithPBSAnalyticsTags() {
+ // given
+ when(invocationContext.moduleContext()).thenReturn(givenModuleContext());
+
+ // when
+ final Future> future =
+ target.call(auctionResponsePayload, invocationContext);
+
+ // then
+ assertThat(future).isNotNull();
+ assertThat(future.succeeded()).isTrue();
+
+ final InvocationResult result = future.result();
+ assertThat(result).isNotNull();
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.no_action);
+ assertThat(result.analyticsTags().activities().getFirst()
+ .results().getFirst().values().get("reason")).isNotNull();
+ assertThat(result.errors()).isNull();
+ }
+
+ @Test
+ public void shouldReturnResultWithUpdateActionWhenAdvertiserTargetingOptionIsOn() {
+ // given
+ when(invocationContext.moduleContext()).thenReturn(givenModuleContext(List.of(
+ new Audience(
+ "provider",
+ List.of(new AudienceId("audienceId")),
+ "keyspace",
+ 1))));
+ when(auctionResponsePayload.bidResponse()).thenReturn(givenBidResponse());
+
+ // when
+ final Future> future =
+ target.call(auctionResponsePayload, invocationContext);
+ final InvocationResult result = future.result();
+ final BidResponse bidResponse = result
+ .payloadUpdate()
+ .apply(AuctionResponsePayloadImpl.of(givenBidResponse()))
+ .bidResponse();
+ final ObjectNode targeting = (ObjectNode) bidResponse.getSeatbid()
+ .getFirst()
+ .getBid()
+ .getFirst()
+ .getExt()
+ .get("prebid")
+ .get("targeting");
+
+ // then
+ assertThat(future).isNotNull();
+ assertThat(future.succeeded()).isTrue();
+ assertThat(result).isNotNull()
+ .returns(InvocationStatus.success, InvocationResult::status)
+ .returns(InvocationAction.update, InvocationResult::action);
+
+ assertThat(targeting)
+ .isNotNull()
+ .hasSize(3);
+
+ assertThat(targeting.get("keyspace").asText()).isEqualTo("audienceId");
+ }
+
+ @Test
+ public void shouldReturnResultWithNoActionWhenAdvertiserTargetingOptionIsOff() {
+ // given
+ when(invocationContext.moduleContext()).thenReturn(givenModuleContext(List.of(
+ new Audience(
+ "provider",
+ List.of(new AudienceId("audienceId")),
+ "keyspace",
+ 1))));
+
+ // when
+ final Future> future =
+ target.call(auctionResponsePayload, invocationContext);
+ final InvocationResult result = future.result();
+
+ // then
+ assertThat(future).isNotNull();
+ assertThat(future.succeeded()).isTrue();
+ assertThat(result).isNotNull()
+ .returns(InvocationStatus.success, InvocationResult::status)
+ .returns(InvocationAction.no_action, InvocationResult::action);
+ }
+
+ private ObjectNode givenAccountConfig(boolean cacheEnabled) {
+ return mapper.valueToTree(givenOptableTargetingProperties(cacheEnabled));
+ }
+}
diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHookTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHookTest.java
new file mode 100644
index 00000000000..f3463d677cf
--- /dev/null
+++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHookTest.java
@@ -0,0 +1,185 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.iab.openrtb.request.BidRequest;
+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.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.prebid.server.activity.infrastructure.ActivityInfrastructure;
+import org.prebid.server.auction.privacy.enforcement.mask.UserFpdActivityMask;
+import org.prebid.server.execution.timeout.Timeout;
+import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.ConfigResolver;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.OptableTargeting;
+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.AuctionRequestPayload;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+public class OptableTargetingProcessedAuctionRequestHookTest extends BaseOptableTest {
+
+ private ConfigResolver configResolver;
+
+ @Mock
+ private OptableTargeting optableTargeting;
+
+ @Mock
+ private UserFpdActivityMask userFpdActivityMask;
+
+ private OptableTargetingProcessedAuctionRequestHook target;
+
+ @Mock
+ private AuctionRequestPayload auctionRequestPayload;
+
+ @Mock
+ private AuctionInvocationContext invocationContext;
+
+ @Mock
+ private ActivityInfrastructure activityInfrastructure;
+
+ @Mock
+ private Timeout timeout;
+
+ @BeforeEach
+ public void setUp() {
+ when(userFpdActivityMask.maskDevice(any(), anyBoolean(), anyBoolean()))
+ .thenAnswer(answer -> answer.getArgument(0));
+ configResolver = new ConfigResolver(mapper, jsonMerger, givenOptableTargetingProperties(false));
+ target = new OptableTargetingProcessedAuctionRequestHook(
+ configResolver,
+ optableTargeting,
+ userFpdActivityMask);
+
+ when(invocationContext.accountConfig()).thenReturn(givenAccountConfig(true));
+ when(invocationContext.auctionContext()).thenReturn(givenAuctionContext(activityInfrastructure, timeout));
+ when(invocationContext.timeout()).thenReturn(timeout);
+ when(activityInfrastructure.isAllowed(any(), any())).thenReturn(true);
+ when(timeout.remaining()).thenReturn(1000L);
+ }
+
+ @Test
+ public void shouldHaveRightCode() {
+ // when and then
+ assertThat(target.code()).isEqualTo("optable-targeting-processed-auction-request-hook");
+ }
+
+ @Test
+ public void shouldReturnResultWithPBSAnalyticsTags() {
+ // given
+ when(auctionRequestPayload.bidRequest()).thenReturn(givenBidRequest());
+ when(optableTargeting.getTargeting(any(), any(), any(), any()))
+ .thenReturn(Future.succeededFuture(givenTargetingResult()));
+
+ // when
+ final Future> future = target.call(auctionRequestPayload,
+ invocationContext);
+
+ // then
+ assertThat(future).isNotNull();
+ assertThat(future.succeeded()).isTrue();
+
+ final InvocationResult result = future.result();
+ assertThat(result).isNotNull();
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.update);
+ assertThat(result.errors()).isNull();
+ assertThat(result.analyticsTags().activities().getFirst()
+ .results().getFirst().values().get("execution-time")).isNotNull();
+ }
+
+ @Test
+ public void shouldReturnResultWithUpdateActionWhenOptableTargetingReturnTargeting() {
+ // given
+ when(auctionRequestPayload.bidRequest()).thenReturn(givenBidRequest());
+ when(optableTargeting.getTargeting(any(), any(), any(), any()))
+ .thenReturn(Future.succeededFuture(givenTargetingResult()));
+
+ // when
+ final Future> future = target.call(auctionRequestPayload,
+ invocationContext);
+
+ // then
+ assertThat(future).isNotNull();
+ assertThat(future.succeeded()).isTrue();
+
+ final InvocationResult result = future.result();
+ assertThat(result).isNotNull();
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.update);
+ assertThat(result.errors()).isNull();
+ final BidRequest bidRequest = result
+ .payloadUpdate()
+ .apply(AuctionRequestPayloadImpl.of(givenBidRequest()))
+ .bidRequest();
+ assertThat(bidRequest.getUser().getEids().getFirst().getUids().getFirst().getId()).isEqualTo("id");
+ assertThat(bidRequest.getUser().getData().getFirst().getSegment().getFirst().getId()).isEqualTo("id");
+ }
+
+ @Test
+ public void shouldReturnResultWithCleanedUpUserExtOptableTag() {
+ // given
+ when(invocationContext.timeout()).thenReturn(timeout);
+ when(auctionRequestPayload.bidRequest()).thenReturn(givenBidRequest());
+ when(optableTargeting.getTargeting(any(), any(), any(), any()))
+ .thenReturn(Future.succeededFuture(givenTargetingResult()));
+
+ // when
+ final Future> future = target.call(auctionRequestPayload,
+ invocationContext);
+
+ // then
+ assertThat(future).isNotNull();
+ assertThat(future.succeeded()).isTrue();
+
+ final InvocationResult result = future.result();
+ assertThat(result).isNotNull();
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.update);
+ assertThat(result.errors()).isNull();
+ final ObjectNode optable = (ObjectNode) result
+ .payloadUpdate()
+ .apply(AuctionRequestPayloadImpl.of(givenBidRequest()))
+ .bidRequest()
+ .getUser().getExt().getProperty("optable");
+
+ assertThat(optable).isNull();
+ }
+
+ @Test
+ public void shouldReturnResultWithUpdateWhenOptableTargetingDoesntReturnResult() {
+ // given
+ when(auctionRequestPayload.bidRequest()).thenReturn(givenBidRequest());
+ when(optableTargeting.getTargeting(any(), any(), any(), any())).thenReturn(Future.succeededFuture(null));
+
+ // when
+ final Future> future = target.call(auctionRequestPayload,
+ invocationContext);
+
+ // then
+ assertThat(future).isNotNull();
+ assertThat(future.succeeded()).isTrue();
+
+ final InvocationResult result = future.result();
+ assertThat(result).isNotNull();
+ assertThat(result.status()).isEqualTo(InvocationStatus.success);
+ assertThat(result.action()).isEqualTo(InvocationAction.update);
+ assertThat(result.errors()).isNull();
+ }
+
+ private ObjectNode givenAccountConfig(boolean cacheEnabled) {
+ return mapper.valueToTree(givenOptableTargetingProperties(cacheEnabled));
+ }
+}
diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/AuctionResponseValidatorTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/AuctionResponseValidatorTest.java
new file mode 100644
index 00000000000..51c5c33890f
--- /dev/null
+++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/AuctionResponseValidatorTest.java
@@ -0,0 +1,74 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+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.hooks.modules.optable.targeting.model.EnrichmentStatus;
+import org.prebid.server.hooks.modules.optable.targeting.model.Reason;
+import org.prebid.server.hooks.modules.optable.targeting.model.Status;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Audience;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.AudienceId;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class AuctionResponseValidatorTest {
+
+ @Test
+ public void shouldReturnNobidStatusWhenBidResponseIsEmpty() {
+ // given
+ final BidResponse bidResponse = BidResponse.builder().build();
+
+ // when
+ final EnrichmentStatus result = AuctionResponseValidator.checkEnrichmentPossibility(
+ bidResponse,
+ givenTargeting());
+
+ // then
+ assertThat(result).isNotNull()
+ .returns(Status.FAIL, EnrichmentStatus::getStatus)
+ .returns(Reason.NOBID, EnrichmentStatus::getReason);
+ }
+
+ @Test
+ public void shouldReturnNoKeywordsStatusWhenTargetingHasNoIds() {
+ // given
+ final BidResponse bidResponse = BidResponse.builder().build();
+
+ // when
+ final EnrichmentStatus result = AuctionResponseValidator.checkEnrichmentPossibility(
+ bidResponse,
+ givenTargeting());
+
+ // then
+ assertThat(result).isNotNull()
+ .returns(Status.FAIL, EnrichmentStatus::getStatus)
+ .returns(Reason.NOBID, EnrichmentStatus::getReason);
+ }
+
+ @Test
+ public void shouldReturnSuccessStatus() {
+ // given
+ final BidResponse bidResponse = BidResponse.builder()
+ .seatbid(List.of(SeatBid.builder()
+ .bid(List.of(Bid.builder().build()))
+ .build()))
+ .build();
+
+ // when
+ final EnrichmentStatus result = AuctionResponseValidator.checkEnrichmentPossibility(
+ bidResponse,
+ givenTargeting());
+
+ // then
+ assertThat(result).isNotNull()
+ .returns(Status.SUCCESS, EnrichmentStatus::getStatus)
+ .returns(Reason.NONE, EnrichmentStatus::getReason);
+ }
+
+ private static List givenTargeting() {
+ return List.of(new Audience("provider", List.of(new AudienceId("id")), "keyspace", 1));
+ }
+}
diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestCleanerTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestCleanerTest.java
new file mode 100644
index 00000000000..b6d72748fbd
--- /dev/null
+++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestCleanerTest.java
@@ -0,0 +1,30 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.User;
+import org.junit.jupiter.api.Test;
+import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl;
+import org.prebid.server.hooks.modules.optable.targeting.v1.BaseOptableTest;
+import org.prebid.server.hooks.v1.auction.AuctionRequestPayload;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class BidRequestCleanerTest extends BaseOptableTest {
+
+ @Test
+ public void shouldRemoveUserExtOptableTag() {
+ // given
+ final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(givenBidRequest(bidRequest ->
+ bidRequest.user(givenUser())));
+
+ // when
+ final AuctionRequestPayload result = BidRequestCleaner.instance().apply(auctionRequestPayload);
+
+ // then
+ assertThat(result).extracting(AuctionRequestPayload::bidRequest)
+ .extracting(BidRequest::getUser)
+ .extracting(User::getExt)
+ .extracting(it -> it.getProperty("optable"))
+ .isEqualTo(null);
+ }
+}
diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestEnricherTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestEnricherTest.java
new file mode 100644
index 00000000000..038a0958acc
--- /dev/null
+++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestEnricherTest.java
@@ -0,0 +1,374 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.iab.openrtb.request.BidRequest;
+import com.iab.openrtb.request.Data;
+import com.iab.openrtb.request.Eid;
+import com.iab.openrtb.request.Segment;
+import com.iab.openrtb.request.Uid;
+import com.iab.openrtb.request.User;
+import org.junit.jupiter.api.Test;
+import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult;
+import org.prebid.server.hooks.modules.optable.targeting.v1.BaseOptableTest;
+import org.prebid.server.hooks.v1.auction.AuctionRequestPayload;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class BidRequestEnricherTest extends BaseOptableTest {
+
+ @Test
+ public void shouldReturnOriginBidRequestWhenNoTargetingResults() {
+ // given
+ final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(givenBidRequest());
+
+ // when
+ final AuctionRequestPayload result = BidRequestEnricher.of(null)
+ .apply(auctionRequestPayload);
+
+ // then
+ assertThat(result).isNotNull();
+ final User user = result.bidRequest().getUser();
+ assertThat(user).isNotNull();
+ assertThat(user.getEids()).isNull();
+ assertThat(user.getData()).isNull();
+ }
+
+ @Test
+ public void shouldNotFailIfBidRequestIsNull() {
+ // given
+ final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(null);
+ final TargetingResult targetingResult = givenTargetingResult();
+
+ // when
+ final AuctionRequestPayload result = BidRequestEnricher.of(targetingResult)
+ .apply(auctionRequestPayload);
+
+ // then
+ assertThat(result.bidRequest()).isNull();
+ }
+
+ @Test
+ public void shouldReturnEnrichedBidRequestWhenTargetingResultsIsPresent() {
+ // given
+ final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(givenBidRequest());
+ final TargetingResult targetingResult = givenTargetingResult();
+
+ // when
+ final AuctionRequestPayload result = BidRequestEnricher.of(targetingResult)
+ .apply(auctionRequestPayload);
+
+ // then
+ assertThat(result.bidRequest()).isNotNull();
+ final User user = result.bidRequest().getUser();
+ assertThat(user).isNotNull();
+ assertThat(user.getEids().getFirst().getUids().getFirst().getId()).isEqualTo("id");
+ assertThat(user.getData().getFirst().getSegment().getFirst().getId()).isEqualTo("id");
+ }
+
+ @Test
+ public void shouldNotAddEidWhenSourceAlreadyPresent() {
+ // given
+ final TargetingResult targetingResult = givenTargetingResultWithEids(List.of(
+ givenEid("source", List.of(givenUid("id2", 3, null)), null)));
+
+ final BidRequest bidRequest = givenBidRequestWithUserEids(List.of(
+ givenEid("source", List.of(givenUid("id", null, null)), null),
+ givenEid("source1", List.of(givenUid("id", null, null)), null)));
+ final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest);
+
+ // when
+ final AuctionRequestPayload result = BidRequestEnricher.of(targetingResult)
+ .apply(auctionRequestPayload);
+
+ // then
+ assertThat(result.bidRequest()).isNotNull();
+ final List eids = result.bidRequest().getUser().getEids();
+ assertThat(eids.size()).isEqualTo(2);
+ assertThat(eids).filteredOn(it -> it.getSource().equals("source")).hasSize(1);
+ }
+
+ @Test
+ public void shouldAddEidWhenSourceIsNotAlreadyPresent() {
+ // given
+ final TargetingResult targetingResult = givenTargetingResultWithEids(List.of(
+ givenEid("source3", List.of(givenUid("id2", 3, null)), null)));
+
+ final BidRequest bidRequest = givenBidRequestWithUserEids(List.of(
+ givenEid("source1", List.of(givenUid("id", null, null)), null),
+ givenEid("source2", List.of(givenUid("id", null, null)), null)));
+ final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest);
+
+ // when
+ final AuctionRequestPayload result = BidRequestEnricher.of(targetingResult)
+ .apply(auctionRequestPayload);
+
+ // then
+ assertThat(result.bidRequest()).isNotNull();
+ final List eids = result.bidRequest().getUser().getEids();
+ assertThat(eids.size()).isEqualTo(3);
+ assertThat(eids.stream()).extracting(Eid::getSource).containsExactly("source1", "source2", "source3");
+ }
+
+ @Test
+ public void shouldNotMergeOriginEidsWithTheSameSource() {
+ // given
+ final TargetingResult targetingResult = givenTargetingResultWithEids(List.of(
+ givenEid("source3", List.of(givenUid("id2", 3, null)), null)));
+
+ final BidRequest bidRequest = givenBidRequestWithUserEids(List.of(
+ givenEid("source", List.of(givenUid("id", null, null)), null),
+ givenEid("source", List.of(givenUid("id", null, null)), null)));
+ final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest);
+
+ // when
+ final AuctionRequestPayload result = BidRequestEnricher.of(targetingResult)
+ .apply(auctionRequestPayload);
+
+ // then
+ assertThat(result.bidRequest()).isNotNull();
+ final List eids = result.bidRequest().getUser().getEids();
+ assertThat(eids.size()).isEqualTo(3);
+ assertThat(eids.stream()).extracting(Eid::getSource).containsExactly("source", "source", "source3");
+ }
+
+ @Test
+ public void shouldApplyOriginEidsWhenTargetingIsEmpty() {
+ // given
+ final TargetingResult targetingResult = givenTargetingResultWithEids(List.of(
+ givenEid("source3", List.of(givenUid("id2", 3, null)), null)));
+
+ final BidRequest bidRequest = givenBidRequestWithUserEids(Collections.emptyList());
+ final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest);
+
+ // when
+ final AuctionRequestPayload result = BidRequestEnricher.of(targetingResult)
+ .apply(auctionRequestPayload);
+
+ // then
+ assertThat(result.bidRequest()).isNotNull();
+ final List eids = result.bidRequest().getUser().getEids();
+ assertThat(eids.size()).isEqualTo(1);
+ assertThat(eids).extracting(Eid::getSource).containsExactly("source3");
+ }
+
+ @Test
+ public void shouldApplyTargetingEidsWhenOriginListIsEmpty() {
+ // given
+ final TargetingResult targetingResult = givenTargetingResultWithEids(Collections.emptyList());
+
+ final BidRequest bidRequest = givenBidRequestWithUserEids(List.of(
+ givenEid("source", List.of(givenUid("id", null, null)), null),
+ givenEid("source1", List.of(givenUid("id", null, null)), null)));
+ final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest);
+
+ // when
+ final AuctionRequestPayload result = BidRequestEnricher.of(targetingResult)
+ .apply(auctionRequestPayload);
+
+ // then
+ assertThat(result.bidRequest()).isNotNull();
+ final List eids = result.bidRequest().getUser().getEids();
+ assertThat(eids.size()).isEqualTo(2);
+ assertThat(eids).extracting(Eid::getSource).containsExactly("source", "source1");
+ }
+
+ @Test
+ public void shouldNotApplyEidsWhenOriginAndTargetingEidsAreEmpty() {
+ // given
+ final BidRequest bidRequest = givenBidRequestWithUserEids(Collections.emptyList());
+ final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest);
+ final TargetingResult targetingResult = givenTargetingResultWithEids(Collections.emptyList());
+
+ // when
+ final AuctionRequestPayload result = BidRequestEnricher.of(targetingResult)
+ .apply(auctionRequestPayload);
+
+ // then
+ final List eids = result.bidRequest().getUser().getEids();
+ assertThat(eids).isEmpty();
+ }
+
+ @Test
+ public void shouldMergeDataWithTheSameId() {
+ // given
+ final BidRequest bidRequest = givenBidRequestWithUserData(List.of(
+ givenData("id", List.of(givenSegment("id1", "value1"))),
+ givenData("id", List.of(givenSegment("id2", "value2")))));
+ final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest);
+ final TargetingResult targetingResult = givenTargetingResultWithData(List.of(
+ givenData("id", List.of(givenSegment("id3", "value3")))));
+
+ // when
+ final AuctionRequestPayload result = BidRequestEnricher.of(targetingResult)
+ .apply(auctionRequestPayload);
+
+ // then
+ final List data = result.bidRequest().getUser().getData();
+ assertThat(data.size()).isEqualTo(2);
+ assertThat(data).extracting(Data::getId).containsExactly("id", "id");
+ assertThat(data).extracting(Data::getSegment).satisfies(segments -> {
+ assertThat(segments.getFirst()).extracting(Segment::getId).containsExactly("id1", "id3");
+ assertThat(segments.getLast()).extracting(Segment::getId).containsExactly("id2", "id3");
+ });
+ }
+
+ @Test
+ public void shouldMergeDistinctSegmentsWithinTheSameData() {
+ // given
+ final BidRequest bidRequest = givenBidRequestWithUserData(List.of(
+ givenData("id", List.of(givenSegment("id1", "value1"))),
+ givenData("id1", List.of(givenSegment("id2", "value2")))));
+ final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest);
+ final TargetingResult targetingResult = givenTargetingResultWithData(List.of(
+ givenData("id", List.of(givenSegment("id1", "value3"))),
+ givenData("id", List.of(givenSegment("id4", "value4")))));
+
+ // when
+ final AuctionRequestPayload result = BidRequestEnricher.of(targetingResult)
+ .apply(auctionRequestPayload);
+
+ // then
+ final List data = result.bidRequest().getUser().getData();
+ assertThat(data.size()).isEqualTo(2);
+ assertThat(data).extracting(Data::getId).containsExactly("id", "id1");
+ assertThat(data).extracting(Data::getSegment).satisfies(segments -> {
+ assertThat(segments.getFirst()).extracting(Segment::getId).containsExactly("id1", "id4");
+ assertThat(segments.getFirst()).filteredOn(it -> it.getId().equals("id1"))
+ .extracting(Segment::getValue).containsExactly("value1");
+ assertThat(segments.getLast()).extracting(Segment::getId).containsExactly("id2");
+ });
+ }
+
+ @Test
+ public void shouldAppendDataWithNewId() {
+ // given
+ final BidRequest bidRequest = givenBidRequestWithUserData(List.of(
+ givenData("id", List.of(givenSegment("id1", "value1"))),
+ givenData("id", List.of(givenSegment("id2", "value2")))));
+ final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest);
+ final TargetingResult targetingResult = givenTargetingResultWithData(List.of(
+ givenData("id1", List.of(givenSegment("id3", "value3")))));
+
+ // when
+ final AuctionRequestPayload result = BidRequestEnricher.of(targetingResult)
+ .apply(auctionRequestPayload);
+
+ // then
+ final List data = result.bidRequest().getUser().getData();
+ assertThat(data.size()).isEqualTo(3);
+ assertThat(data).extracting(Data::getId).containsExactly("id", "id", "id1");
+ assertThat(data).extracting(Data::getSegment).satisfies(segments -> {
+ assertThat(segments.getFirst()).extracting(Segment::getId).containsExactly("id1");
+ assertThat(segments.get(1)).extracting(Segment::getId).containsExactly("id2");
+ assertThat(segments.getLast()).extracting(Segment::getId).containsExactly("id3");
+ });
+ }
+
+ @Test
+ public void shouldApplyOriginDataWhenTargetingIsEmpty() {
+ // given
+ final BidRequest bidRequest = givenBidRequestWithUserData(List.of(
+ givenData("id", List.of(givenSegment("id1", "value1"))),
+ givenData("id", List.of(givenSegment("id2", "value2")))));
+ final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest);
+ final TargetingResult targetingResult = givenTargetingResultWithData(Collections.emptyList());
+
+ // when
+ final AuctionRequestPayload result = BidRequestEnricher.of(targetingResult)
+ .apply(auctionRequestPayload);
+
+ // then
+ final List data = result.bidRequest().getUser().getData();
+ assertThat(data.size()).isEqualTo(2);
+ assertThat(data).extracting(Data::getSegment).satisfies(segments -> {
+ assertThat(segments.getFirst()).extracting(Segment::getId).containsExactly("id1");
+ assertThat(segments.get(1)).extracting(Segment::getId).containsExactly("id2");
+ });
+ }
+
+ @Test
+ public void shouldApplyTargetingDataWhenOriginIsEmpty() {
+ // given
+ final BidRequest bidRequest = givenBidRequestWithUserData(Collections.emptyList());
+ final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest);
+ final TargetingResult targetingResult = givenTargetingResultWithData(List.of(
+ givenData("id", List.of(givenSegment("id1", "value1")))));
+
+ // when
+ final AuctionRequestPayload result = BidRequestEnricher.of(targetingResult)
+ .apply(auctionRequestPayload);
+
+ // then
+ final List data = result.bidRequest().getUser().getData();
+ assertThat(data.size()).isEqualTo(1);
+ assertThat(data).flatMap(Data::getSegment).extracting(Segment::getId).containsExactly("id1");
+ }
+
+ @Test
+ public void shouldApplyNothingWhenOriginAndTargetingDataAreEmpty() {
+ // given
+ final BidRequest bidRequest = givenBidRequestWithUserData(Collections.emptyList());
+ final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest);
+ final TargetingResult targetingResult = givenTargetingResultWithData(Collections.emptyList());
+
+ // when
+ final AuctionRequestPayload result = BidRequestEnricher.of(targetingResult)
+ .apply(auctionRequestPayload);
+
+ // then
+ final List data = result.bidRequest().getUser().getData();
+ assertThat(data).isEmpty();
+ }
+
+ @Test
+ public void shouldReturnOriginBidRequestWhenTargetingResultsIsEmpty() {
+ // given
+ final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(givenBidRequest());
+ final TargetingResult targetingResult = givenEmptyTargetingResult();
+
+ // when
+ final AuctionRequestPayload result = BidRequestEnricher.of(targetingResult)
+ .apply(auctionRequestPayload);
+
+ // then
+ assertThat(result.bidRequest()).isNotNull();
+ final User user = result.bidRequest().getUser();
+ assertThat(user).isNotNull();
+ assertThat(user.getEids()).isNull();
+ assertThat(user.getData()).isNull();
+ }
+
+ private Eid givenEid(String source, List uids, ObjectNode ext) {
+ return Eid.builder()
+ .source(source)
+ .uids(uids)
+ .ext(ext)
+ .build();
+ }
+
+ private Uid givenUid(String id, Integer atype, ObjectNode ext) {
+ return Uid.builder()
+ .id(id)
+ .atype(atype)
+ .ext(ext)
+ .build();
+ }
+
+ private Data givenData(String id, List segments) {
+ return Data.builder()
+ .id(id)
+ .segment(segments)
+ .build();
+ }
+
+ private Segment givenSegment(String id, String value) {
+ return Segment.builder()
+ .id(id)
+ .value(value)
+ .build();
+ }
+}
diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidResponseEnricherTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidResponseEnricherTest.java
new file mode 100644
index 00000000000..8b2e4b14432
--- /dev/null
+++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidResponseEnricherTest.java
@@ -0,0 +1,66 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.junit.jupiter.api.Test;
+import org.prebid.server.hooks.execution.v1.auction.AuctionResponsePayloadImpl;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Audience;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.AudienceId;
+import org.prebid.server.hooks.modules.optable.targeting.v1.BaseOptableTest;
+import org.prebid.server.hooks.v1.auction.AuctionResponsePayload;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class BidResponseEnricherTest extends BaseOptableTest {
+
+ @Test
+ public void shouldEnrichBidResponseByTargetingKeywords() {
+ // given
+ final AuctionResponsePayload auctionResponsePayload = AuctionResponsePayloadImpl.of(givenBidResponse());
+
+ // when
+ final AuctionResponsePayload result = BidResponseEnricher.of(givenTargeting(), mapper, jsonMerger)
+ .apply(auctionResponsePayload);
+ final ObjectNode targeting = (ObjectNode) result.bidResponse().getSeatbid()
+ .getFirst()
+ .getBid()
+ .getFirst()
+ .getExt()
+ .get("prebid")
+ .get("targeting");
+
+ // then
+ assertThat(result).isNotNull();
+ assertThat(targeting.get("keyspace").asText()).isEqualTo("audienceId,audienceId2");
+ }
+
+ @Test
+ public void shouldReturnOriginBidResponseWhenNoTargetingKeywords() {
+ // given
+ final AuctionResponsePayload auctionResponsePayload = AuctionResponsePayloadImpl.of(givenBidResponse());
+
+ // when
+ final AuctionResponsePayload result = BidResponseEnricher.of(null, mapper, jsonMerger)
+ .apply(auctionResponsePayload);
+ final ObjectNode targeting = (ObjectNode) result.bidResponse().getSeatbid()
+ .getFirst()
+ .getBid()
+ .getFirst()
+ .getExt()
+ .get("prebid")
+ .get("targeting");
+
+ // then
+ assertThat(result).isNotNull();
+ assertThat(targeting.get("keyspace")).isNull();
+ }
+
+ private static List givenTargeting() {
+ return List.of(new Audience(
+ "provider",
+ List.of(new AudienceId("audienceId"), new AudienceId("audienceId2")),
+ "keyspace",
+ 1));
+ }
+}
diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/CacheTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/CacheTest.java
new file mode 100644
index 00000000000..1917fd6ca27
--- /dev/null
+++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/CacheTest.java
@@ -0,0 +1,114 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import io.vertx.core.Future;
+import org.assertj.core.api.Assertions;
+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.Spy;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.prebid.server.cache.PbcStorageService;
+import org.prebid.server.cache.proto.request.module.StorageDataType;
+import org.prebid.server.cache.proto.response.module.ModuleCacheResponse;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Audience;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.AudienceId;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Ortb2;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.User;
+import org.prebid.server.json.JacksonMapper;
+import org.prebid.server.json.ObjectMapperProvider;
+
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class CacheTest {
+
+ @Mock
+ private PbcStorageService pbcStorageService;
+
+ @Spy
+ private final JacksonMapper jacksonMapper = new JacksonMapper(ObjectMapperProvider.mapper());
+
+ private final JacksonMapper mapper = new JacksonMapper(ObjectMapperProvider.mapper());
+
+ private Cache target;
+
+ @BeforeEach
+ public void setUp() {
+ target = new Cache(pbcStorageService, jacksonMapper);
+ }
+
+ @Test
+ public void cacheShouldNotCallMapperIfNoEntry() {
+ // given
+ when(pbcStorageService.retrieveEntry(any(), any(), any()))
+ .thenReturn(Future.succeededFuture(ModuleCacheResponse.empty()));
+
+ // when
+ final TargetingResult result = target.get("key").result();
+
+ // then
+ Assertions.assertThat(result).isNull();
+ verify(jacksonMapper, times(0)).decodeValue(anyString(), eq(TargetingResult.class));
+ }
+
+ @Test
+ public void cacheShouldReturnEntry() {
+ // given
+ final TargetingResult targetingResult = givenTargetingResult();
+ when(pbcStorageService.retrieveEntry(any(), any(), any()))
+ .thenReturn(Future.succeededFuture(ModuleCacheResponse.of(
+ "key",
+ StorageDataType.TEXT,
+ mapper.encodeToString(targetingResult))));
+
+ // when
+ final TargetingResult result = target.get("key").result();
+
+ // then
+ Assertions.assertThat(result)
+ .isNotNull()
+ .isEqualTo(targetingResult);
+
+ verify(jacksonMapper, times(1)).decodeValue(anyString(), eq(TargetingResult.class));
+ }
+
+ @Test
+ public void cacheShouldStoreEntry() {
+ // given
+ final TargetingResult targetingResult = givenTargetingResult();
+
+ // when
+ when(pbcStorageService.storeEntry(any(), any(), any(), any(), any(), any()))
+ .thenReturn(Future.succeededFuture());
+ final boolean result = target.put("key", targetingResult, 86400).succeeded();
+
+ // then
+ Assertions.assertThat(result).isTrue();
+ verify(pbcStorageService, times(1)).storeEntry(
+ eq("key"),
+ eq(mapper.encodeToString(targetingResult)),
+ eq(StorageDataType.TEXT),
+ eq(86400),
+ any(),
+ any());
+ }
+
+ private TargetingResult givenTargetingResult() {
+ return new TargetingResult(
+ List.of(new Audience(
+ "provider",
+ List.of(new AudienceId("1")),
+ "keyspace",
+ 0)),
+ new Ortb2(new User(null, null)));
+ }
+}
diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapperTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapperTest.java
new file mode 100644
index 00000000000..39693661629
--- /dev/null
+++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapperTest.java
@@ -0,0 +1,124 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+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.Uid;
+import com.iab.openrtb.request.User;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.prebid.server.hooks.modules.optable.targeting.model.Id;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.ExtUserOptable;
+import org.prebid.server.json.ObjectMapperProvider;
+import org.prebid.server.proto.openrtb.ext.request.ExtUser;
+
+import java.util.List;
+import java.util.Map;
+import java.util.function.UnaryOperator;
+
+import static java.util.Collections.singletonList;
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class IdsMapperTest {
+
+ private final ObjectMapper objectMapper = ObjectMapperProvider.mapper();
+
+ private IdsMapper target;
+
+ private final Map ppidMapping = Map.of("test.com", "c");
+
+ @BeforeEach
+ public void setUp() {
+ target = new IdsMapper(objectMapper, 0.01);
+ }
+
+ @Test
+ public void shouldMapBidRequestToAllPossibleIds() {
+ //given
+ final BidRequest bidRequest = givenBidRequestWithEids(Map.of(
+ "id5-sync.com", "id5_id",
+ "test.com", "test_id",
+ "utiq.com", "utiq_id"));
+
+ // when
+ final List ids = target.toIds(bidRequest, ppidMapping);
+
+ // then
+ assertThat(ids).isNotNull()
+ .contains(Id.of(Id.EMAIL, "email"))
+ .contains(Id.of(Id.PHONE, "123"))
+ .contains(Id.of(Id.ZIP, "321"))
+ .contains(Id.of(Id.OPTABLE_VID, "vid"))
+ .contains(Id.of(Id.GOOGLE_GAID, "ifa"))
+ .doesNotContain(Id.of(Id.APPLE_IDFA, "ifa"))
+ .contains(Id.of(Id.ID5, "id5_id"))
+ .contains(Id.of(Id.UTIQ, "utiq_id"))
+ .contains(Id.of("c", "test_id"));
+ }
+
+ @Test
+ public void shouldMapNothing() {
+ //given
+ final BidRequest bidRequest = givenBidRequest(bidRequestBuilder -> bidRequestBuilder);
+
+ // when
+ final List ids = target.toIds(bidRequest, ppidMapping);
+
+ // then
+ assertThat(ids).isNotNull();
+ }
+
+ private BidRequest givenBidRequestWithEids(Map eids) {
+ final JsonNode extUserOptable = objectMapper.convertValue(givenOptable(), JsonNode.class);
+ final ExtUser extUser = ExtUser.builder().build();
+ extUser.addProperty("optable", extUserOptable);
+
+ final User user = givenUser(userBuilder -> userBuilder.eids(toEids(eids)).ext(extUser));
+ return givenBidRequest(builder -> builder.device(givenDevice()).user(user));
+ }
+
+ private static BidRequest givenBidRequest(UnaryOperator bidRequestCustomizer) {
+ return bidRequestCustomizer.apply(BidRequest.builder()
+ .id("requestId")
+ .imp(singletonList(Imp.builder()
+ .id("impId")
+ .build())))
+ .build();
+ }
+
+ private ExtUserOptable givenOptable() {
+ return ExtUserOptable.builder()
+ .email("email")
+ .phone("123")
+ .zip("321")
+ .vid("vid")
+ .build();
+ }
+
+ private Device givenDevice() {
+ return Device.builder()
+ .ip("127.0.0.1")
+ .ipv6("0:0:0:0:0:0:0:1")
+ .lmt(0)
+ .os("android")
+ .ifa("ifa")
+ .build();
+ }
+
+ private User givenUser(UnaryOperator userCustomizer) {
+ return userCustomizer.apply(User.builder()).build();
+ }
+
+ private List toEids(Map eids) {
+ return eids.entrySet()
+ .stream()
+ .map(it -> Eid.builder()
+ .source(it.getKey())
+ .uids(List.of(Uid.builder().id(it.getValue()).build()))
+ .build())
+ .toList();
+ }
+}
diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolverTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolverTest.java
new file mode 100644
index 00000000000..de2c01948fb
--- /dev/null
+++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolverTest.java
@@ -0,0 +1,115 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import com.iab.gpp.encoder.GppModel;
+import com.iab.openrtb.request.BidRequest;
+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.auction.gpp.model.GppContext;
+import org.prebid.server.auction.model.AuctionContext;
+import org.prebid.server.hooks.modules.optable.targeting.model.OptableAttributes;
+import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties;
+import org.prebid.server.hooks.modules.optable.targeting.v1.BaseOptableTest;
+import org.prebid.server.privacy.gdpr.model.TcfContext;
+import org.prebid.server.privacy.model.Privacy;
+import org.prebid.server.privacy.model.PrivacyContext;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mock.Strictness.LENIENT;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class OptableAttributesResolverTest extends BaseOptableTest {
+
+ @Mock(strictness = LENIENT)
+ private TcfContext tcfContext;
+
+ @Mock(strictness = LENIENT)
+ private GppContext gppContext;
+
+ @Mock
+ private OptableTargetingProperties properties;
+
+ @BeforeEach
+ public void setUp() {
+ when(properties.getTimeout()).thenReturn(100L);
+ }
+
+ @Test
+ public void shouldResolveTcfAttributesWhenConsentIsValid() {
+ // given
+ final GppModel gppModel = mock();
+ when(tcfContext.isConsentValid()).thenReturn(true);
+ when(tcfContext.isInGdprScope()).thenReturn(true);
+ when(tcfContext.getConsentString()).thenReturn("consent");
+ when(gppModel.encode()).thenReturn("consent");
+ when(gppContext.scope()).thenReturn(GppContext.Scope.of(gppModel, Set.of(1)));
+ final AuctionContext auctionContext = givenAuctionContext(givenBidRequest(), tcfContext, gppContext);
+
+ // when
+ final OptableAttributes result = OptableAttributesResolver.resolveAttributes(
+ auctionContext, properties.getTimeout());
+
+ // then
+ assertThat(result).isNotNull()
+ .returns(true, OptableAttributes::isGdprApplies)
+ .returns("consent", OptableAttributes::getGdprConsent);
+ }
+
+ @Test
+ public void shouldNotResolveTcfAttributesWhenConsentIsNotValid() {
+ // given
+ final GppModel gppModel = mock();
+ when(tcfContext.isConsentValid()).thenReturn(false);
+ when(tcfContext.getConsentString()).thenReturn("consent");
+ when(tcfContext.getIpAddress()).thenReturn("8.8.8.8");
+ when(gppModel.encode()).thenReturn("consent");
+ when(gppContext.scope()).thenReturn(GppContext.Scope.of(gppModel, Set.of(1)));
+ final AuctionContext auctionContext = givenAuctionContext(givenBidRequest(), tcfContext, gppContext);
+
+ // when
+ final OptableAttributes result = OptableAttributesResolver.resolveAttributes(
+ auctionContext, properties.getTimeout());
+
+ // then
+ assertThat(result).isNotNull()
+ .returns(false, OptableAttributes::isGdprApplies)
+ .returns(null, OptableAttributes::getGdprConsent)
+ .returns(List.of("8.8.8.8"), OptableAttributes::getIps);
+ }
+
+ @Test
+ public void shouldResolveGppAttributes() {
+ // given
+ final GppModel gppModel = mock();
+ when(tcfContext.isConsentValid()).thenReturn(false);
+ when(tcfContext.getConsentString()).thenReturn("consent");
+ when(gppModel.encode()).thenReturn("consent");
+ when(gppContext.scope()).thenReturn(GppContext.Scope.of(gppModel, Set.of(1)));
+ final AuctionContext auctionContext = givenAuctionContext(givenBidRequest(), tcfContext, gppContext);
+
+ // when
+ final OptableAttributes result = OptableAttributesResolver.resolveAttributes(
+ auctionContext, properties.getTimeout());
+
+ // then
+ assertThat(result).isNotNull()
+ .returns(false, OptableAttributes::isGdprApplies)
+ .returns("consent", OptableAttributes::getGpp)
+ .returns(Set.of(1), OptableAttributes::getGppSid);
+ }
+
+ public AuctionContext givenAuctionContext(BidRequest bidRequest, TcfContext tcfContext, GppContext gppContext) {
+ return AuctionContext.builder()
+ .bidRequest(bidRequest)
+ .privacyContext(PrivacyContext.of(Privacy.builder().build(), tcfContext, "8.8.8.8"))
+ .gppContext(gppContext)
+ .build();
+ }
+}
diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableTargetingTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableTargetingTest.java
new file mode 100644
index 00000000000..9f793605f12
--- /dev/null
+++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableTargetingTest.java
@@ -0,0 +1,97 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import com.iab.openrtb.request.BidRequest;
+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.execution.timeout.Timeout;
+import org.prebid.server.hooks.modules.optable.targeting.model.Id;
+import org.prebid.server.hooks.modules.optable.targeting.model.OptableAttributes;
+import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult;
+import org.prebid.server.hooks.modules.optable.targeting.v1.BaseOptableTest;
+import org.prebid.server.hooks.modules.optable.targeting.v1.net.APIClientImpl;
+import org.prebid.server.hooks.modules.optable.targeting.v1.net.CachedAPIClient;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class OptableTargetingTest extends BaseOptableTest {
+
+ @Mock
+ private IdsMapper idsMapper;
+
+ @Mock(strictness = Mock.Strictness.LENIENT)
+ private Cache cache;
+
+ @Mock
+ private APIClientImpl apiClient;
+
+ private OptableTargeting target;
+
+ @Mock
+ private Timeout timeout;
+
+ @BeforeEach
+ public void setUp() {
+ final CachedAPIClient cachingAPIClient = new CachedAPIClient(apiClient, cache, false);
+ target = new OptableTargeting(idsMapper, cachingAPIClient);
+ }
+
+ @Test
+ public void shouldCallNonCachedAPIClient() {
+ // given
+ when(idsMapper.toIds(any(), any())).thenReturn(List.of(Id.of(Id.ID5, "id")));
+ when(apiClient.getTargeting(any(), any(), any(), any()))
+ .thenReturn(Future.succeededFuture(givenTargetingResult()));
+
+ final BidRequest bidRequest = givenBidRequest();
+ final OptableTargetingProperties properties = givenOptableTargetingProperties(false);
+ final OptableAttributes optableAttributes = givenOptableAttributes();
+
+ // when
+ final Future targetingResult = target.getTargeting(
+ properties, bidRequest, optableAttributes, timeout);
+
+ // then
+ assertThat(targetingResult.result()).isNotNull();
+ verify(apiClient).getTargeting(any(), any(), any(), any());
+ }
+
+ @Test
+ public void shouldUseCachedAPIClient() {
+ // given
+ when(idsMapper.toIds(any(), any())).thenReturn(List.of(Id.of(Id.ID5, "id")));
+ when(cache.get(any())).thenReturn(Future.failedFuture(new NullPointerException()));
+ when(apiClient.getTargeting(any(), any(), any(), any()))
+ .thenReturn(Future.succeededFuture(givenTargetingResult()));
+
+ final BidRequest bidRequest = givenBidRequest();
+ final OptableTargetingProperties properties = givenOptableTargetingProperties(true);
+ final OptableAttributes optableAttributes = givenOptableAttributes();
+
+ // when
+ target.getTargeting(properties, bidRequest, optableAttributes, timeout);
+
+ // then
+ verify(cache).get(any());
+ verify(apiClient).getTargeting(any(), any(), any(), any());
+ }
+
+ private OptableAttributes givenOptableAttributes() {
+ return OptableAttributes.builder()
+ .gpp("gpp")
+ .ips(List.of("8.8.8.8"))
+ .gppSid(Set.of(2))
+ .build();
+ }
+}
diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java
new file mode 100644
index 00000000000..e7212fb5884
--- /dev/null
+++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java
@@ -0,0 +1,120 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.core;
+
+import org.junit.jupiter.api.Test;
+import org.prebid.server.hooks.modules.optable.targeting.model.Id;
+import org.prebid.server.hooks.modules.optable.targeting.model.OptableAttributes;
+import org.prebid.server.hooks.modules.optable.targeting.model.Query;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class QueryBuilderTest {
+
+ private final OptableAttributes optableAttributes = givenOptableAttributes();
+
+ private final String idPrefixOrder = "c,c1";
+
+ @Test
+ public void shouldSeparateAttributesFromIds() {
+ // given
+ final List ids = List.of(Id.of(Id.EMAIL, "email"), Id.of(Id.PHONE, "123"));
+
+ // when
+ final Query query = QueryBuilder.build(ids, optableAttributes, idPrefixOrder);
+
+ // then
+ assertThat(query.getIds()).isEqualTo("&id=e%3Aemail&id=p%3A123");
+ assertThat(query.getAttributes()).isEqualTo("&gdpr_consent=tcf&gdpr=1&timeout=100ms");
+ }
+
+ @Test
+ public void shouldBuildFullQueryString() {
+ // given
+ final List ids = List.of(Id.of(Id.EMAIL, "email"), Id.of(Id.PHONE, "123"));
+
+ // when
+ final Query query = QueryBuilder.build(ids, optableAttributes, idPrefixOrder);
+
+ // then
+ assertThat(query.getIds()).isEqualTo("&id=e%3Aemail&id=p%3A123");
+ assertThat(query.getAttributes()).isEqualTo("&gdpr_consent=tcf&gdpr=1&timeout=100ms");
+ }
+
+ @Test
+ public void shouldBuildQueryStringWhenHaveIds() {
+ // given
+ final List ids = List.of(Id.of(Id.EMAIL, "email"), Id.of(Id.PHONE, "123"));
+
+ // when
+ final String query = QueryBuilder.build(ids, optableAttributes, idPrefixOrder).toQueryString();
+
+ // then
+ assertThat(query).contains("e%3Aemail", "p%3A123");
+ }
+
+ @Test
+ public void shouldBuildQueryStringWithExtraAttributes() {
+ // given
+ final List ids = List.of(Id.of(Id.EMAIL, "email"), Id.of(Id.PHONE, "123"));
+
+ // when
+ final String query = QueryBuilder.build(ids, optableAttributes, idPrefixOrder).toQueryString();
+
+ // then
+ assertThat(query).contains("&gdpr=1", "&gdpr_consent=tcf", "&timeout=100ms");
+ }
+
+ @Test
+ public void shouldBuildQueryStringWithRightOrder() {
+ // given
+ final List ids = List.of(
+ Id.of(Id.ID5, "ID5"),
+ Id.of(Id.EMAIL, "email"),
+ Id.of("c1", "123"),
+ Id.of("c", "234"));
+
+ // when
+ final String query = QueryBuilder.build(ids, optableAttributes, idPrefixOrder).toQueryString();
+
+ // then
+ assertThat(query).startsWith("&id=c%3A234&id=c1%3A123&id=id5%3AID5&id=e%3Aemail");
+ }
+
+ @Test
+ public void shouldBuildQueryStringWhenIdsListIsEmptyAndIpIsPresent() {
+ // given
+ final List ids = List.of();
+ final OptableAttributes attributes = OptableAttributes.builder()
+ .ips(List.of("8.8.8.8"))
+ .build();
+
+ // when
+ final Query query = QueryBuilder.build(ids, attributes, idPrefixOrder);
+
+ // then
+ assertThat(query).isNotNull();
+ assertThat(query.toQueryString()).isEqualTo("gdpr=0");
+ }
+
+ @Test
+ public void shouldNotBuildQueryStringWhenIdsListIsEmptyAndIpIsAbsent() {
+ // given
+ final List ids = List.of();
+ final OptableAttributes attributes = OptableAttributes.builder().build();
+
+ // when
+ final Query query = QueryBuilder.build(ids, attributes, idPrefixOrder);
+
+ // then
+ assertThat(query).isNull();
+ }
+
+ private OptableAttributes givenOptableAttributes() {
+ return OptableAttributes.builder()
+ .timeout(100L)
+ .gdprApplies(true)
+ .gdprConsent("tcf")
+ .build();
+ }
+}
diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/APIClientImplTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/APIClientImplTest.java
new file mode 100644
index 00000000000..82bb1ca2e3a
--- /dev/null
+++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/APIClientImplTest.java
@@ -0,0 +1,217 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.net;
+
+import io.vertx.core.Future;
+import io.vertx.core.MultiMap;
+import org.apache.http.HttpStatus;
+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.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.prebid.server.execution.timeout.Timeout;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.User;
+import org.prebid.server.hooks.modules.optable.targeting.v1.BaseOptableTest;
+import org.prebid.server.json.JacksonMapper;
+import org.prebid.server.util.HttpUtil;
+import org.prebid.server.vertx.httpclient.HttpClient;
+
+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.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class APIClientImplTest extends BaseOptableTest {
+
+ @Mock
+ private HttpClient httpClient;
+
+ private final JacksonMapper jacksonMapper = new JacksonMapper(mapper);
+
+ private APIClient target;
+
+ @Mock
+ private Timeout timeout;
+
+ @BeforeEach
+ public void setUp() {
+ target = new APIClientImpl("http://endpoint.optable.com", httpClient, jacksonMapper, 100);
+ }
+
+ @Test
+ public void shouldReturnTargetingResult() {
+ // given
+ when(httpClient.get(any(), any(), anyLong()))
+ .thenReturn(Future.succeededFuture(givenSuccessHttpResponse("targeting_response.json")));
+
+ // when
+ final Future result = target.getTargeting(
+ givenOptableTargetingProperties(false),
+ givenQuery(),
+ List.of("8.8.8.8"),
+ timeout);
+
+ // then
+ assertThat(result.result()).isNotNull();
+ final TargetingResult res = result.result();
+ final User user = res.getOrtb2().getUser();
+ assertThat(user.getEids().getFirst().getUids().getFirst().getId()).isEqualTo("uid_id1");
+ assertThat(user.getData().getFirst().getSegment().getFirst().getId()).isEqualTo("segment_id");
+ }
+
+ @Test
+ public void shouldReturnNullWhenEndpointRespondsWithError() {
+ // given
+ when(httpClient.get(any(), any(), anyLong()))
+ .thenReturn(Future.succeededFuture(givenFailHttpResponse("error_response.json")));
+
+ // when
+ final Future result = target.getTargeting(
+ givenOptableTargetingProperties(false),
+ givenQuery(),
+ List.of("8.8.8.8"),
+ timeout);
+
+ // then
+ assertThat(result.result()).isNull();
+ }
+
+ @Test
+ public void shouldNotFailWhenEndpointRespondsWithWrongData() {
+ // given
+ when(httpClient.get(any(), any(), anyLong()))
+ .thenReturn(Future.succeededFuture(givenSuccessHttpResponse("plain_text_response.json")));
+
+ // when
+ final Future result = target.getTargeting(
+ givenOptableTargetingProperties(false),
+ givenQuery(),
+ List.of("8.8.8.8"),
+ timeout);
+
+ // then
+ assertThat(result.result()).isNull();
+ }
+
+ @Test
+ public void shouldNotFailWhenHttpClientIsCrashed() {
+ // given
+ when(httpClient.get(any(), any(), anyLong()))
+ .thenReturn(Future.failedFuture(new NullPointerException()));
+
+ // when
+ final Future result = target.getTargeting(
+ givenOptableTargetingProperties(false),
+ givenQuery(),
+ List.of("8.8.8.8"),
+ timeout);
+
+ // then
+ assertThat(result.result()).isNull();
+ }
+
+ @Test
+ public void shouldNotFailWhenInternalErrorOccurs() {
+ // given
+ when(httpClient.get(any(), any(), anyLong())).thenReturn(Future.succeededFuture(
+ givenFailHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR, "plain_text_response.json")));
+
+ // when
+ final Future result = target.getTargeting(
+ givenOptableTargetingProperties(false),
+ givenQuery(),
+ List.of("8.8.8.8"),
+ timeout);
+
+ // then
+ assertThat(result.result()).isNull();
+ }
+
+ @Test
+ public void shouldUseAuthorizationHeaderIfApiKeyIsPresent() {
+ // given
+ target = new APIClientImpl("http://endpoint.optable.com", httpClient, jacksonMapper, 10);
+
+ when(httpClient.get(any(), any(), anyLong()))
+ .thenReturn(Future.succeededFuture(givenFailHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR,
+ "plain_text_response.json")));
+
+ // when
+ final Future result = target.getTargeting(givenOptableTargetingProperties(false),
+ givenQuery(), List.of("8.8.8.8"), timeout);
+
+ // then
+ final ArgumentCaptor headersCaptor = ArgumentCaptor.forClass(MultiMap.class);
+ verify(httpClient).get(any(), headersCaptor.capture(), anyLong());
+ assertThat(headersCaptor.getValue().get(HttpUtil.ACCEPT_HEADER)).isEqualTo("application/json");
+ assertThat(headersCaptor.getValue().get(HttpUtil.AUTHORIZATION_HEADER)).isEqualTo("Bearer key");
+ assertThat(result.result()).isNull();
+ }
+
+ @Test
+ public void shouldNotUseAuthorizationHeaderIfApiKeyIsAbsent() {
+ // given
+ when(httpClient.get(any(), any(), anyLong())).thenReturn(Future.succeededFuture(
+ givenFailHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR, "plain_text_response.json")));
+
+ // when
+ final Future result = target.getTargeting(
+ givenOptableTargetingProperties(null, false),
+ givenQuery(),
+ List.of("8.8.8.8"),
+ timeout);
+
+ // then
+ final ArgumentCaptor headersCaptor = ArgumentCaptor.forClass(MultiMap.class);
+ verify(httpClient).get(any(), headersCaptor.capture(), anyLong());
+ assertThat(headersCaptor.getValue().get(HttpUtil.ACCEPT_HEADER)).isEqualTo("application/json");
+ assertThat(headersCaptor.getValue().get(HttpUtil.AUTHORIZATION_HEADER)).isNull();
+ assertThat(result.result()).isNull();
+ }
+
+ @Test
+ public void shouldPassThroughIpAddresses() {
+ // given
+ when(httpClient.get(any(), any(), anyLong())).thenReturn(Future.succeededFuture(
+ givenFailHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR, "plain_text_response.json")));
+
+ // when
+ final Future result = target.getTargeting(
+ givenOptableTargetingProperties(false),
+ givenQuery(),
+ List.of("8.8.8.8", "2001:4860:4860::8888"),
+ timeout);
+
+ // then
+ final ArgumentCaptor headersCaptor = ArgumentCaptor.forClass(MultiMap.class);
+ verify(httpClient).get(any(), headersCaptor.capture(), anyLong());
+ assertThat(headersCaptor.getValue().getAll(HttpUtil.X_FORWARDED_FOR_HEADER))
+ .contains("8.8.8.8", "2001:4860:4860::8888");
+ assertThat(result.result()).isNull();
+ }
+
+ @Test
+ public void shouldNotPassThroughIpAddressWhenNotSpecified() {
+ // given
+ when(httpClient.get(any(), any(), anyLong())).thenReturn(Future.succeededFuture(
+ givenFailHttpResponse(HttpStatus.SC_INTERNAL_SERVER_ERROR, "plain_text_response.json")));
+
+ // when
+ final Future result = target.getTargeting(
+ givenOptableTargetingProperties(false),
+ givenQuery(),
+ null,
+ timeout);
+
+ // then
+ final ArgumentCaptor headersCaptor = ArgumentCaptor.forClass(MultiMap.class);
+ verify(httpClient).get(any(), headersCaptor.capture(), anyLong());
+ assertThat(headersCaptor.getValue().get(HttpUtil.X_FORWARDED_FOR_HEADER)).isNull();
+ assertThat(result.result()).isNull();
+ }
+}
diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClientTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClientTest.java
new file mode 100644
index 00000000000..6c365f2c2c0
--- /dev/null
+++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClientTest.java
@@ -0,0 +1,168 @@
+package org.prebid.server.hooks.modules.optable.targeting.v1.net;
+
+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.execution.timeout.Timeout;
+import org.prebid.server.hooks.modules.optable.targeting.model.Query;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult;
+import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.User;
+import org.prebid.server.hooks.modules.optable.targeting.v1.BaseOptableTest;
+import org.prebid.server.hooks.modules.optable.targeting.v1.core.Cache;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class CachedAPIClientTest extends BaseOptableTest {
+
+ @Mock
+ private APIClientImpl apiClient;
+
+ @Mock(strictness = Mock.Strictness.LENIENT)
+ private Cache cache;
+
+ private CachedAPIClient target;
+
+ @Mock(strictness = Mock.Strictness.LENIENT)
+ private Timeout timeout;
+
+ @BeforeEach
+ public void setUp() {
+ target = new CachedAPIClient(apiClient, cache, false);
+ when(timeout.remaining()).thenReturn(1000L);
+ }
+
+ @Test
+ public void shouldCallAPIAndAddTargetingResultsToCache() {
+ // given
+ when(cache.get(any())).thenReturn(Future.failedFuture("error"));
+ when(cache.put(any(), any(), anyInt())).thenReturn(Future.succeededFuture());
+ final Query query = givenQuery();
+ when(apiClient.getTargeting(any(), any(), any(), any()))
+ .thenReturn(Future.succeededFuture(givenTargetingResult()));
+
+ // when
+ final Future targetingResult = target.getTargeting(
+ givenOptableTargetingProperties(true),
+ query,
+ List.of("8.8.8.8"),
+ timeout);
+
+ // then
+ final User user = targetingResult.result().getOrtb2().getUser();
+ assertThat(user).isNotNull()
+ .returns("source", it -> it.getEids().getFirst().getSource())
+ .returns("id", it -> it.getEids().getFirst().getUids().getFirst().getId())
+ .returns("id", it -> it.getData().getFirst().getId())
+ .returns("id", it -> it.getData().getFirst().getSegment().getFirst().getId());
+ verify(cache).put(any(), eq(targetingResult.result()), anyInt());
+ }
+
+ @Test
+ public void shouldCallAPIAndAddTargetingResultsToCacheWhenCacheReturnsFailure() {
+ // given
+ when(cache.get(any())).thenReturn(Future.failedFuture(new IllegalArgumentException("message")));
+ when(cache.put(any(), any(), anyInt())).thenReturn(Future.succeededFuture());
+ final Query query = givenQuery();
+ when(apiClient.getTargeting(any(), any(), any(), any()))
+ .thenReturn(Future.succeededFuture(givenTargetingResult()));
+
+ // when
+ final Future targetingResult = target.getTargeting(
+ givenOptableTargetingProperties(true),
+ query,
+ List.of("8.8.8.8"),
+ timeout);
+
+ // then
+ final User user = targetingResult.result().getOrtb2().getUser();
+ assertThat(user).isNotNull()
+ .returns("source", it -> it.getEids().getFirst().getSource())
+ .returns("id", it -> it.getEids().getFirst().getUids().getFirst().getId())
+ .returns("id", it -> it.getData().getFirst().getId())
+ .returns("id", it -> it.getData().getFirst().getSegment().getFirst().getId());
+ verify(apiClient, times(1)).getTargeting(any(), any(), any(), any());
+ verify(cache).put(any(), eq(targetingResult.result()), anyInt());
+ }
+
+ @Test
+ public void shouldUseCachedResult() {
+ // given
+ when(cache.get(any())).thenReturn(Future.succeededFuture(givenTargetingResult()));
+ final Query query = givenQuery();
+
+ // when
+ final Future targetingResult = target.getTargeting(
+ givenOptableTargetingProperties(true),
+ query,
+ List.of("8.8.8.8"),
+ timeout);
+
+ // then
+ final User user = targetingResult.result().getOrtb2().getUser();
+ assertThat(user).isNotNull()
+ .returns("source", it -> it.getEids().getFirst().getSource())
+ .returns("id", it -> it.getEids().getFirst().getUids().getFirst().getId())
+ .returns("id", it -> it.getData().getFirst().getId())
+ .returns("id", it -> it.getData().getFirst().getSegment().getFirst().getId());
+ verify(cache, times(1)).get(any());
+ verify(apiClient, times(0)).getTargeting(any(), any(), any(), any());
+ verify(cache, times(0)).put(any(), eq(targetingResult.result()), anyInt());
+ }
+
+ @Test
+ public void shouldNotFailWhenApiClientIsFailed() {
+ // given
+ final Query query = givenQuery();
+ when(cache.get(any())).thenReturn(Future.failedFuture("empty"));
+ when(apiClient.getTargeting(any(), any(), any(), any()))
+ .thenReturn(Future.failedFuture(new NullPointerException()));
+
+ // when
+ final Future targetingResult = target.getTargeting(
+ givenOptableTargetingProperties(true),
+ query,
+ List.of("8.8.8.8"),
+ timeout);
+
+ // then
+ assertThat(targetingResult.result()).isNull();
+ verify(cache, times(0)).put(any(), eq(targetingResult.result()), anyInt());
+ }
+
+ @Test
+ public void shouldCacheEmptyResultWhenCircuitBreakerIsOn() {
+ // given
+ final Query query = givenQuery();
+ when(cache.get(any())).thenReturn(Future.failedFuture("empty"));
+ when(apiClient.getTargeting(any(), any(), any(), any()))
+ .thenReturn(Future.failedFuture(new NullPointerException()));
+ when(cache.put(any(), any(), anyInt())).thenReturn(Future.succeededFuture());
+
+ // when
+ target = new CachedAPIClient(apiClient, cache, true);
+ final Future targetingResult = target.getTargeting(
+ givenOptableTargetingProperties(true),
+ query,
+ List.of("8.8.8.8"),
+ timeout);
+
+ // then
+ final TargetingResult result = targetingResult.result();
+ assertThat(result).isNotNull();
+ assertThat(result.getOrtb2()).isNull();
+ assertThat(result.getAudience()).isNull();
+ verify(cache, times(1)).put(any(), eq(targetingResult.result()), anyInt());
+ }
+}
diff --git a/extra/modules/optable-targeting/src/test/resources/error_response.json b/extra/modules/optable-targeting/src/test/resources/error_response.json
new file mode 100644
index 00000000000..4250099e7ba
--- /dev/null
+++ b/extra/modules/optable-targeting/src/test/resources/error_response.json
@@ -0,0 +1 @@
+{"details": "Error message"}
diff --git a/extra/modules/optable-targeting/src/test/resources/plaint_text_response.json b/extra/modules/optable-targeting/src/test/resources/plaint_text_response.json
new file mode 100644
index 00000000000..ec6816d6f25
--- /dev/null
+++ b/extra/modules/optable-targeting/src/test/resources/plaint_text_response.json
@@ -0,0 +1 @@
+Plain text
diff --git a/extra/modules/optable-targeting/src/test/resources/targeting_response.json b/extra/modules/optable-targeting/src/test/resources/targeting_response.json
new file mode 100644
index 00000000000..a5959de45c9
--- /dev/null
+++ b/extra/modules/optable-targeting/src/test/resources/targeting_response.json
@@ -0,0 +1,62 @@
+{
+ "user": [
+
+ ],
+ "audience": [
+ {
+ "provider": "optable.co",
+ "ids": [
+ {
+ "id": "audience_id"
+ }
+ ],
+ "keyspace": "keyspace",
+ "rtb_segtax": 1
+ }
+ ],
+ "ortb2": {
+ "user": {
+ "data": [
+ {
+ "id": "data_id",
+ "segment": [
+ {
+ "id": "segment_id"
+ }
+ ]
+ }
+ ],
+ "eids": [
+ {
+ "source": "eid_source1",
+ "uids": [
+ {
+ "id": "uid_id1",
+ "atype": 3,
+ "ext": {
+ "advertising_token": "advertising_token",
+ "refresh_token": "refresh_token",
+ "identity_expires": 1739281106209,
+ "refresh_from": 1739025506209,
+ "refresh_expires": 1741613906209,
+ "refresh_response_key": "refresh_response_key"
+ }
+ }
+ ]
+ },
+ {
+ "source": "eid_source2",
+ "uids": [
+ {
+ "id": "uid_id2",
+ "atype": 3,
+ "ext": {
+ "stype": "cto_bundle_hem_api"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+}
diff --git a/extra/modules/pom.xml b/extra/modules/pom.xml
index 307f5d7b204..df35533a141 100644
--- a/extra/modules/pom.xml
+++ b/extra/modules/pom.xml
@@ -24,6 +24,7 @@
pb-response-correction
greenbids-real-time-data
pb-request-correction
+ optable-targeting
diff --git a/sample/configs/prebid-config-with-optable.yaml b/sample/configs/prebid-config-with-optable.yaml
new file mode 100644
index 00000000000..cd2d3b7d4ec
--- /dev/null
+++ b/sample/configs/prebid-config-with-optable.yaml
@@ -0,0 +1,53 @@
+status-response: "ok"
+adapters:
+ appnexus:
+ enabled: true
+ ix:
+ enabled: true
+ openx:
+ enabled: true
+ pubmatic:
+ enabled: true
+ rubicon:
+ enabled: true
+ improvedigital:
+ enabled: true
+ colossus:
+ enabled: true
+ triplelift:
+ enabled: true
+metrics:
+ prefix: prebid
+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-optable.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:
+ optable-targeting:
+ enabled: true
+ modules:
+ optable-targeting:
+ api-endpoint: https://na.edge.optable.co/v2/targeting?t={TENANT}&o={ORIGIN}
diff --git a/sample/configs/sample-app-settings-optable.yaml b/sample/configs/sample-app-settings-optable.yaml
new file mode 100644
index 00000000000..7a533da3697
--- /dev/null
+++ b/sample/configs/sample-app-settings-optable.yaml
@@ -0,0 +1,63 @@
+accounts:
+ - id: 1
+ status: active
+ auction:
+ price-granularity: low
+ privacy:
+ ccpa:
+ enabled: true
+ gdpr:
+ enabled: true
+ cookie-sync:
+ default-limit: 8
+ max-limit: 15
+ coop-sync:
+ default: true
+ analytics:
+ allow-client-details: true
+ hooks:
+ modules:
+ optable-targeting:
+ api-key: key
+ tenant: optable
+ origin: web-sdk-demo
+ ppid-mapping: { "pubcid.org": "c" }
+ adserver-targeting: true
+ cache:
+ enabled: false
+ ttlseconds: 86400
+ execution-plan:
+ {
+ "endpoints": {
+ "/openrtb2/auction": {
+ "stages": {
+ "processed-auction-request": {
+ "groups": [
+ {
+ "timeout": 600,
+ "hook-sequence": [
+ {
+ "module-code": "optable-targeting",
+ "hook-impl-code": "optable-targeting-processed-auction-request-hook"
+ }
+ ]
+ }
+ ]
+ },
+ "auction-response": {
+ "groups": [
+ {
+ "timeout": 10,
+ "hook-sequence": [
+ {
+ "module-code": "optable-targeting",
+ "hook-impl-code": "optable-targeting-auction-response-hook"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
diff --git a/sample/stored/optable-stored-response.json b/sample/stored/optable-stored-response.json
new file mode 100644
index 00000000000..66c6a86b13b
--- /dev/null
+++ b/sample/stored/optable-stored-response.json
@@ -0,0 +1,166 @@
+[
+ {
+ "bid":
+ [
+ {
+ "adomain":
+ [
+ "domain.com"
+ ],
+ "cid": "EFwGoMegjvRgamXpkklIsu",
+ "crid": "EIDzGDmsQQ64qWxicOan",
+ "exp": 900,
+ "ext":
+ {
+ "prebid":
+ {
+ "bidid": "f1b11176-a8de-4842-b16c-edef4ad4b53a",
+ "events":
+ {},
+ "meta":
+ {
+ "adaptercode": "bidder"
+ },
+ "targeting":
+ {
+ "hb_bidder": "bidder",
+ "hb_cache_host": "example.com",
+ "hb_cache_id": "12323-0dd8-443e-997b-a03440261a86",
+ "hb_cache_path": "",
+ "hb_pb": "1.50",
+ "hb_size": "300x450"
+ },
+ "type": "banner"
+ }
+ },
+ "h": 450,
+ "id": "a961e06a-cee0-4160-9894-7a5ce7823c23",
+ "impid": "035917ec-b770-4ecf-b762-0b12c5443b68",
+ "iurl": "https://example.com/i/3dc5016d-c23a-4740-9f6a-4b00c87f47ac/RPjQMBzajgbIvlj.jpeg",
+ "price": 1.50,
+ "w": 300
+ },
+ {
+ "adomain":
+ [
+ "domain.com"
+ ],
+ "cid": "EFwGoMegjvRgamXpkklIsu",
+ "crid": "EIDzGDmsQQ64qWxicOan",
+ "exp": 900,
+ "ext":
+ {
+ "prebid":
+ {
+ "bidid": "f1b11176-a8de-4842-b16c-edef4ad4b53a",
+ "events":
+ {},
+ "meta":
+ {
+ "adaptercode": "bidder"
+ },
+ "targeting":
+ {
+ "hb_bidder": "bidder",
+ "hb_cache_host": "example.com",
+ "hb_cache_id": "50a4bd72-0dd8-443e-997b-a03440261a86",
+ "hb_cache_path": "",
+ "hb_pb": "1.00",
+ "hb_size": "300x250"
+ },
+ "type": "banner"
+ }
+ },
+ "h": 250,
+ "id": "7fec12e0-e1d2-428f-b43c-0c2283843586",
+ "impid": "035917ec-b770-4ecf-b762-0b12c5443b68",
+ "iurl": "https://example.com/i/3dc5016d-c23a-4740-9f6a-4b00c87f47ac/RPjQMBzajgbIvlj.jpeg",
+ "price": 1.00,
+ "w": 300
+ }
+ ],
+ "seat": "bidder1"
+ },
+ {
+ "bid":
+ [
+ {
+ "adomain":
+ [
+ "domain.com"
+ ],
+ "cid": "EFwGoMegjvRgamXpkklIsu",
+ "crid": "EIDzGDmsQQ64qWxicOan",
+ "exp": 900,
+ "ext":
+ {
+ "prebid":
+ {
+ "bidid": "f1b11176-a8de-4842-b16c-edef4ad4b53a",
+ "events":
+ {},
+ "meta":
+ {
+ "adaptercode": "bidder"
+ },
+ "targeting":
+ {
+ "hb_bidder": "bidder",
+ "hb_cache_host": "example.com",
+ "hb_cache_id": "12323-0dd8-443e-997b-a03440261a86",
+ "hb_cache_path": "",
+ "hb_pb": "1.50",
+ "hb_size": "300x450"
+ },
+ "type": "banner"
+ }
+ },
+ "h": 450,
+ "id": "ba65ff9c-25f5-41ff-8c1d-21443479d7b9",
+ "impid": "035917ec-b770-4ecf-b762-0b12c5443b68",
+ "iurl": "https://example.com/i/3dc5016d-c23a-4740-9f6a-4b00c87f47ac/RPjQMBzajgbIvlj.jpeg",
+ "price": 1.50,
+ "w": 300
+ },
+ {
+ "adomain":
+ [
+ "domain.com"
+ ],
+ "cid": "EFwGoMegjvRgamXpkklIsu",
+ "crid": "EIDzGDmsQQ64qWxicOan",
+ "exp": 900,
+ "ext":
+ {
+ "prebid":
+ {
+ "bidid": "f1b11176-a8de-4842-b16c-edef4ad4b53a",
+ "events":
+ {},
+ "meta":
+ {
+ "adaptercode": "bidder"
+ },
+ "targeting":
+ {
+ "hb_bidder": "bidder",
+ "hb_cache_host": "example.com",
+ "hb_cache_id": "50a4bd72-0dd8-443e-997b-a03440261a86",
+ "hb_cache_path": "",
+ "hb_pb": "1.00",
+ "hb_size": "300x250"
+ },
+ "type": "banner"
+ }
+ },
+ "h": 250,
+ "id": "374b5f85-e0b9-4f25-98ca-863c0698005a",
+ "impid": "035917ec-b770-4ecf-b762-0b12c5443b68",
+ "iurl": "https://example.com/i/3dc5016d-c23a-4740-9f6a-4b00c87f47ac/RPjQMBzajgbIvlj.jpeg",
+ "price": 1.00,
+ "w": 300
+ }
+ ],
+ "seat": "bidder2"
+ }
+]