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> hooks; + + public OptableTargetingModule(Collection> hooks) { + this.hooks = hooks; + } + + @Override + public Collection> 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" + } +]