diff --git a/.gitignore b/.gitignore index 5f0817bd269..b1ceb24647f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,19 @@ *.classpath .vscode/ +.vscode_old/ *.project *.factorypath +# AI assistant / editor local configuration +.cursorrules +.cursor/ +.gemini/ +GEMINI.md +CLAUDE.md +AGENTS.md +.claude/ + .vertx/ target/ diff --git a/extra/modules/optable-targeting/README.md b/extra/modules/optable-targeting/README.md index 3ae7bd5f659..586627d1288 100644 --- a/extra/modules/optable-targeting/README.md +++ b/extra/modules/optable-targeting/README.md @@ -12,10 +12,11 @@ Targeting API endpoint is configurable per publisher. ### Execution Plan -This module runs at two stages: +This module runs at three stages: -* Processed Auction Request: to enrich `user.eids` and `user.data`. -* Auction Response: to inject ad server targeting. +* Raw Auction Request: initiates a non-blocking Optable API call early in the auction lifecycle. +* Bidder Request: awaits the API response and enriches individual bidder requests with `user.eids` and `user.data`. +* Auction Response: injects 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. @@ -26,10 +27,8 @@ 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: +To start using the module in PBS-Java you have to enable it and add the hooks into the execution plan in your config +file. Here's the recommended configuration: ```yaml hooks: @@ -40,14 +39,27 @@ hooks: "endpoints": { "/openrtb2/auction": { "stages": { - "processed-auction-request": { + "raw-auction-request": { "groups": [ { - "timeout": 100, + "timeout": 50, "hook-sequence": [ { "module-code": "optable-targeting", - "hook-impl-code": "optable-targeting-processed-auction-request-hook" + "hook-impl-code": "optable-targeting-raw-auction-request-hook" + } + ] + } + ] + }, + "bidder-request": { + "groups": [ + { + "timeout": 50, + "hook-sequence": [ + { + "module-code": "optable-targeting", + "hook-impl-code": "optable-targeting-bidder-request-hook" } ] } @@ -83,6 +95,15 @@ Sample module enablement configuration in JSON and YAML formats: "api-endpoint": "endpoint", "api-key": "key", "timeout": 50, + "enrichment-percentage": 100, + "bidder-enrichment-percentages": { + "appnexus": 75, + "rubicon": 75, + "pubmatic": 100, + "criteo": 0 + }, + "enrich-web": true, + "enrich-app": true, "ppid-mapping": { "pubcid.org": "c" }, @@ -98,24 +119,65 @@ Sample module enablement configuration in JSON and YAML formats: api-endpoint: endpoint api-key: key timeout: 50 + enrichment-percentage: 100 + bidder-enrichment-percentages: + appnexus: 75 + rubicon: 75 + pubmatic: 100 + criteo: 0 + enrich-web: true + enrich-app: true ppid-mapping: { "pubcid.org": "c" } adserver-targeting: false ``` +### Migrating from legacy configuration + +Previous versions of the module used a `processed-auction-request` hook (alongside the `auction-response` hook) that both +made the API call and enriched the request synchronously in one step, blocking the auction pipeline. The new +configuration replaces it with two hooks: `raw-auction-request` (initiates the API call early) and `bidder-request` +(awaits the result and enriches per-bidder), while the `auction-response` hook remains unchanged. If your execution plan contains the following fragment, it should +be replaced with the `raw-auction-request` and `bidder-request` hooks shown above: + +```json +"processed-auction-request": { + "groups": [ + { + "timeout": 600, + "hook-sequence": [ + { + "module-code": "optable-targeting", + "hook-impl-code": "optable-targeting-processed-auction-request-hook" + } + ] + } + ] +} +``` + +The `processed-auction-request` hook is still supported for backwards compatibility. It detects whether the new hooks +(`raw-auction-request` and `bidder-request`) are present in the execution plan. If both are active, it passes through +immediately without blocking the pipeline. If the new hooks are absent, it falls back to the legacy synchronous +behavior. This means the legacy fragment can be kept during migration without negating the latency benefit of the new +configuration. + ### 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. +The `bidder-request` hook timeout is used as the timeout budget for the Optable Targeting API call Future that is +initiated in the `raw-auction-request` stage. The API call runs in parallel with other auction processing, so the +effective wait time at the `bidder-request` stage is typically much shorter than the full API roundtrip. The +`raw-auction-request` hook timeout only needs to cover its own lightweight setup (validation, sampling) and can be kept +short. **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. +no HTTP calls made in this hook - Optable-specific keywords are cached on earlier stages and retrieved from the module +invocation context later. ## Module Configuration Parameters for PBS-Java @@ -133,14 +195,19 @@ would result in this nesting in the JSON configuration: ``` -| 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 | +| 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 | none | 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 | +| hid-prefixes | no | string | none | An optional string of comma separated id prefixes that should additionally be sent to the Targeting API as resolver hints in `hid=prefix:value` query parameters. See the section on Resolver Hints (hid) below for more detail. | +| enrichment-percentage | no | integer | 100 | Default percentage (0-100) of bid requests per bidder that will receive enrichment data. Set to 100 to enrich all requests, 0 to disable enrichment by default. | +| bidder-enrichment-percentages | no | map | none | Per-bidder overrides for `enrichment-percentage`. Keys are bidder names, values are percentages (0-100). F.e. `{"appnexus": 75, "criteo": 0}` enriches 75% of appnexus requests and none for criteo. Bidders not listed in this map fall back to the default `enrichment-percentage` (100% unless overridden). | +| enrich-web | no | boolean | true | Whether to enrich web traffic (requests with a `site` object). | +| enrich-app | no | boolean | true | Whether to enrich app traffic (requests with an `app` object). | ## ID Mapping @@ -173,8 +240,8 @@ on identifier types. Targeting API accepts multiple id parameters - and their or ### 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. +**Note**: `user.ext.optable.email`, `.phone`, `.zip`, `.vid` and `.id5_signature` fields will be removed by the module +from the original OpenRTB request before being sent to bidders. ### Publisher Provided IDs (PPID) Mapping @@ -197,6 +264,52 @@ ppid-mapping: {"id5-sync.com": "c1"} This will lead to id5 ID supplied as `id=c1:...` to the Targeting API. +### Resolver Hints (`hid`) + +In addition to the regular `id=prefix:value` parameters, the module can forward selected identifiers to the Targeting +API as resolver hints using the `hid=prefix:value` query parameter form. The set of prefixes that should be sent as +`hid` is configured via the `hid-prefixes` parameter, which accepts a comma-separated list of prefix names, f.e.: + +```yaml +hid-prefixes: "c, i6" +``` + +## Targeting API Query Attributes + +In addition to the identifier parameters, the module forwards the following attributes as query string parameters to +the Targeting API: + +| Attribute | Source | +|------------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| `gdpr` | `1` if GDPR applies to the request, `0` otherwise. | +| `gdpr_consent` | TCF consent string, sent when available and the consent is valid. | +| `gpp` | GPP string, sent when available in the resolved GPP context. | +| `gpp_sid` | Comma-separated list of active GPP section IDs (limited to the first two), sent when the set is non-empty. | +| `timeout` | Soft timeout hint (in ms, suffixed with `ms`), sent when the module-level `timeout` property is configured. | +| `osdk` | Always set to `prebid-server`, identifying the caller. | +| `bundle` | App bundle identifier, URL-encoded. Sent when the incoming request has an `app` object (`request.app.bundle`) and the bundle is non-empty. | +| `ver` | App version, URL-encoded. Sent only when `bundle` is non-empty and `request.app.ver` is present and non-empty. | +| `id5_signature` | ID5 signature, URL-encoded. Sent when the resolved `user.ext.optable.id5_signature` is present in the incoming request | + +### App bundle and version + +For app traffic (requests with an `app` object) the module forwards the application's bundle identifier as the +`bundle=` query parameter and, when available, its version as `ver=`. Both values are URL-encoded. The `ver` parameter +is only sent when `bundle` is present and non-empty; requests with a version but no bundle will not produce either +parameter. + +### ID5 signature + +The ID5 signature is propagated to the Targeting API in two ways: + +1. **Request-side signature**: when the incoming OpenRTB request carries `user.ext.optable.id5_signature`, that value + is sent to the Targeting API as the `id5_signature=` query parameter. +2. **Response-side signature**: when the Targeting API response contains refs with an ID5 signature, the module + resolves the signature through the matching optable-eid and propagates it back into the request context, where it + is later injected into the bid response (`ext.prebid.passthrough.optable.id5_signature`) for downstream use. + +The `id5_signature` field is also part of the Optable input erasure, see above. + ## Analytics Tags The following 2 analytics tags are written by the module: diff --git a/extra/modules/optable-targeting/pom.xml b/extra/modules/optable-targeting/pom.xml index 66ed6f1d1ca..644829188ed 100644 --- a/extra/modules/optable-targeting/pom.xml +++ b/extra/modules/optable-targeting/pom.xml @@ -12,4 +12,12 @@ optable-targeting Optable targeting module + + + + io.vertx + vertx-junit5 + test + + diff --git a/extra/modules/optable-targeting/sample-requests/data.json b/extra/modules/optable-targeting/sample-requests/data.json index d05f9a5eebc..b59400cc6d4 100644 --- a/extra/modules/optable-targeting/sample-requests/data.json +++ b/extra/modules/optable-targeting/sample-requests/data.json @@ -1,127 +1,269 @@ { - "test": 1, - "id": "1", - "imp": - [ + "imp": [ { - "id": "1", - "banner": - { - "w": 300, - "h": 250 + "ext": { + "prebid": { + "bidder": { + "triplelift": { + "inventoryCode": "123" + }, + "pubmatic": { + "publisherId": "156209", + "adSlot": "/1234567/test" + }, + "yieldmo": { + "placementId": "12314235" + }, + "openx": { + "delDomain": "domain.openx.net", + "unit": "12324324", + "customParams": { + "sens": [ + "alc" + ] + } + }, + "grid": { + "uid": 123 + }, + "unruly": { + "siteId": 567890 + }, + "conversant": { + "tag_id": "some_id", + "site_id": "345678" + }, + "33across": { + "siteId": "some_id", + "productId": "ddff" + }, + "adform": { + "mname": "some_name", + "mid": 123456789, + "adxDomain": "adx2.adform.net" + }, + "improvedigital": { + "publisherId": 1234, + "placementId": 123456789 + }, + "colossus": { + "groupId": "123" + } + }, + "adunitcode": "AdUnit_Name_1" + } }, - "ext": - { - "prebid": - { - "storedauctionresponse": { "id": "optable-stored-response" }, - "bidder": + "id": "imp_id", + "banner": { + "topframe": 1, + "format": [ { - "appnexus": - { - "placementId": 0 - } + "w": 320, + "h": 50 } - } - } + ] + }, + "bidfloor": 1.0, + "bidfloorcur": "USD" } ], - "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": "5837d278eabede28e37b5766399ed0d1a4cdc36acee8d35710a255032f45beda" + "cur": [ + "USD" + ], + "at": 1, + "device": { + "w": 591, + "h": 771, + "dnt": 0, + "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", + "language": "en", + "js": 1, + "ip": "82.212.42.32", + "sua": { + "source": 2, + "platform": { + "brand": "macOS", + "version": [ + "14", + "0", + "0" + ] }, - "eids": - [ + "browsers": [ { - "source": "growthcode.io", - "uids": - [ - { - "id": "fb58593e-7ac6-48bd-b2de-89a758726362", - "atype": 1 - } - ] - }, - { - "source": "pubcid.org", - "uids": [ - { - "id": "test", - "atype": 1 - } + "brand": "Chromium", + "version": [ + "120", + "0", + "0", + "0" ] }, { - "source": "crwdcntrl.net", - "uids": - [ - { - "id": "dd1b31e65f5e45548c11a0275ba3a8072c00e3a2a0493e8f5a8f54f8067e8b00", - "atype": 1 - } + "brand": "Not-A.Brand", + "version": [ + "24", + "0", + "0", + "0" ] }, { - "source": "amxdt.net", - "uids": - [ - { - "id": "amx*3*a583802a-e6fe-48d7-87c6-7db1b6a4a73a*70f06cdcf8ab0b4ac07a56860ed0e0b6ef0388dc0b0ab5a1dd725999d3b339cf", - "atype": 1 - } + "brand": "Google Chrome", + "version": [ + "120", + "0", + "0", + "0" ] - }, + } + ], + "mobile": 0, + "model": "", + "architecture": "arm" + } + }, + "site": { + "domain": "example-site.com", + "publisher": { + "domain": "example-publisher.com", + "id": "1" + }, + "page": "https://example-site.com/sample-article-slug/", + "content": { + "language": "en" + }, + "cat": [ + "IAB8" + ], + "pagecat": [ + "IAB8" + ], + "privacypolicy": 1, + "mobile": 1 + }, + "user": { + "data": [], + "ext": { + "optable": { + "email": "5837d278eabede28e37b5766399ed0d1a4cdc36acee8d35710a255032f45beda" + }, + "eids": [ { - "source": "audigent.com", - "uids": - [ + "source": "id5-sync.com", + "uids": [ { - "id": "f84456cd3c72296d7898f62e1c46dd964206ff4d47e64b690c3c5a1d6b1bd286", - "atype": 1 + "id": "ID5*a7fF5d6f1GZgAUunYu5C3Edu5H4_nlp9aN34PiHotjzCtKWRh-vXq84ijeusCYHD", + "atype": 1, + "ext": { + "linkType": 2, + "pba": "EGg0hDGKtMSoVfdiLRmUPZc+x7Hm8I37s1sAPcxYmWA=" + } } ] }, { - "source": "adnxs.com", - "uids": - [ + "source": "pubmatic.com", + "uids": [ { - "id": "d4fd63f0f4f7ce0d128348cb145c7e0f" + "id": "A672B525-C053-45D6-B68E-B1DF0B4D6F3B", + "atype": 3, + "ext": { + "provider": "liveintent.com" + } } ] } - ] + ], + "consent": "", + "ConsentedProvidersSettings": { + "consented_providers": "2~70.1516.89.320.2778.2322.510.1516.2343~dv." + } + } + }, + "regs": { + "gpp": "DBABzw~1YNY~BVQqAAAAAgA", + "gpp_sid": [ + 6, + 5, + 7 + ], + "ext": { + "us_privacy": "1YNY" } }, "ext": { "prebid": { "targeting": { - "includebidderkeys": true + "includewinners": true, + "includebidderkeys": false }, - "analytics": - { + "analytics": { "options": { "enableclientdetails": true } - } + }, + "eidPermissions": [ + { + "source": "bidswitch.net", + "bidders": [ + "conversant", + "grid", + "improvedigital", + "pubmatic", + "undertone", + "unruly", + "yieldmo", + "adform", + "colossus", + "openx", + "triplelift" + ] + } + ], + "bidderconfig": [ + { + "bidders": [ + "openx" + ], + "config": { + "ortb2": { + "site": { + "ext": { + "data": { + "refresh": "0", + "vp": "0", + "hvp": "70", + "site_code": [ + "AFOI_2020", + "APIO_2022", + "BIPOC_2024", + "FMLO_2021", + "FMOOI_2022", + "MINO_2021", + "MOMS_2020", + "MRLOI_22", + "NWPR_2021", + "RMOI_24", + "STKCONT_24" + ] + } + } + } + } + } + }, + { + "bidders": [ + "pubmatic" + ], + "config": { + } + } + ] } - } + }, + "tmax": 1000, + "id": "id", + "test": 1 } 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 index 91afba88a31..ef36467d2b5 100644 --- 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 @@ -1,15 +1,25 @@ package org.prebid.server.hooks.modules.optable.targeting.config; import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; import org.prebid.server.auction.privacy.enforcement.mask.UserFpdActivityMask; +import org.prebid.server.bidder.BidderCatalog; import org.prebid.server.cache.PbcStorageService; +import org.prebid.server.execution.timeout.TimeoutFactory; +import org.prebid.server.hooks.execution.model.ExecutionPlan; import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties; +import org.prebid.server.hooks.modules.optable.targeting.v1.OptableBidderRequestHook; +import org.prebid.server.hooks.modules.optable.targeting.v1.OptableRawAuctionRequestHook; 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.AliasesResolver; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.BidderEnrichmentSampler; import org.prebid.server.hooks.modules.optable.targeting.v1.core.Cache; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.CompositeHookExecutionPlan; 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.TargetingRequestExecutor; 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; @@ -38,7 +48,7 @@ OptableTargetingProperties optableTargetingProperties() { @Bean IdsMapper queryParametersExtractor(@Value("${logging.sampling-rate:0.01}") double logSamplingRate) { - return new IdsMapper(ObjectMapperProvider.mapper(), logSamplingRate); + return new IdsMapper(logSamplingRate); } @Bean @@ -46,12 +56,12 @@ APIClientImpl apiClient(HttpClient httpClient, @Value("${logging.sampling-rate:0.01}") double logSamplingRate, OptableTargetingProperties optableTargetingProperties, - JacksonMapper jacksonMapperr) { + JacksonMapper jacksonMapper) { return new APIClientImpl( optableTargetingProperties.getApiEndpoint(), httpClient, - jacksonMapperr, + jacksonMapper, logSamplingRate); } @@ -86,19 +96,43 @@ ConfigResolver configResolver(JsonMerger jsonMerger, OptableTargetingProperties return new ConfigResolver(ObjectMapperProvider.mapper(), jsonMerger, globalProperties); } + @Bean + TargetingRequestExecutor targetingRequestExecutor(OptableTargeting optableTargeting, + UserFpdActivityMask userFpdActivityMask, + TimeoutFactory timeoutFactory, + @Value("${logging.sampling-rate:0.01}") double logSamplingRate) { + + return new TargetingRequestExecutor(optableTargeting, userFpdActivityMask, timeoutFactory, logSamplingRate); + } + @Bean OptableTargetingModule optableTargetingModule(ConfigResolver configResolver, - OptableTargeting optableTargeting, - UserFpdActivityMask userFpdActivityMask, + TargetingRequestExecutor targetingRequestExecutor, JsonMerger jsonMerger, + BidderCatalog bidderCatalog, + JacksonMapper mapper, + @Value("${hooks.host-execution-plan:}") + String executionPlan, @Value("${logging.sampling-rate:0.01}") double logSamplingRate) { + final CompositeHookExecutionPlan hooksExecutionPlan = CompositeHookExecutionPlan.of( + StringUtils.isNoneEmpty(executionPlan) + ? mapper.decodeValue(executionPlan, ExecutionPlan.class) + : null); + return new OptableTargetingModule(List.of( + new OptableRawAuctionRequestHook( + configResolver, + targetingRequestExecutor, + BidderEnrichmentSampler.of(AliasesResolver.of(bidderCatalog)), + hooksExecutionPlan, + logSamplingRate), new OptableTargetingProcessedAuctionRequestHook( configResolver, - optableTargeting, - userFpdActivityMask, + targetingRequestExecutor, + hooksExecutionPlan, logSamplingRate), + new OptableBidderRequestHook(), new OptableTargetingAuctionResponseHook( configResolver, ObjectMapperProvider.mapper(), diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/App.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/App.java new file mode 100644 index 00000000000..1b1cdb51c63 --- /dev/null +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/App.java @@ -0,0 +1,11 @@ +package org.prebid.server.hooks.modules.optable.targeting.model; + +import lombok.Value; + +@Value(staticConstructor = "of") +public class App { + + String bundle; + + String ver; +} 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 index ed0264f0249..ac7e30eec8d 100644 --- 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 @@ -1,10 +1,14 @@ package org.prebid.server.hooks.modules.optable.targeting.model; +import io.vertx.core.Future; import lombok.Data; +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.TargetingResult; import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; import java.util.List; +import java.util.Set; @Data public class ModuleContext { @@ -19,8 +23,31 @@ public class ModuleContext { private long optableTargetingExecutionTime; + private boolean isEarlyNetworkCallEnabled = false; + + private Future optableTargetingCall; + + private long callTargetingAPITimestamp; + + private Set biddersToEnrich; + + private OptableTargetingProperties optableTargetingProperties; + + private boolean shouldSkipEnrichment; + + private String id5Signature; + public static ModuleContext of(AuctionInvocationContext invocationContext) { final ModuleContext moduleContext = (ModuleContext) invocationContext.moduleContext(); return moduleContext != null ? moduleContext : new ModuleContext(); } + + public void failWithExecutionTime(long executionTime) { + setOptableTargetingExecutionTime(executionTime); + setEnrichRequestStatus(EnrichmentStatus.failure()); + } + + public boolean hasOptableTargetingProperties() { + return optableTargetingProperties != null; + } } 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 index 9dacd6de322..d53d1963795 100644 --- 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 @@ -23,4 +23,8 @@ public class OptableAttributes { String userAgent; Long timeout; + + App app; + + String id5Signature; } 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 index 20862050f39..9b24ed2ed9d 100644 --- 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 @@ -7,9 +7,13 @@ public class Query { String ids; + String hid; + String attributes; + String hidAttributes; + public String toQueryString() { - return ids + attributes; + return ids + hid + attributes + hidAttributes; } } 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 index 7f0598da83e..314e2639cef 100644 --- 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 @@ -32,6 +32,9 @@ public final class OptableTargetingProperties { @JsonProperty("id-prefix-order") String idPrefixOrder; + @JsonProperty("hid-prefixes") + String hidPrefixes; + @JsonProperty("optable-inserter-eids-merge") Set optableInserterEidsMerge = Set.of(); @@ -42,4 +45,16 @@ public final class OptableTargetingProperties { Set optableInserterEidsIgnore = Set.of(); CacheProperties cache = new CacheProperties(); + + @JsonProperty("enrichment-percentage") + Integer enrichmentPercentage = 100; + + @JsonProperty("bidder-enrichment-percentages") + Map bidderEnrichmentPercentages = Map.of(); + + @JsonProperty("enrich-web") + Boolean enrichWeb = true; + + @JsonProperty("enrich-app") + Boolean enrichApp = true; } 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 index 787b3adbf1a..a0c8f974b69 100644 --- 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 @@ -17,4 +17,6 @@ public class ExtUserOptable extends FlexibleExtension { String zip; String vid; + + String id5Signature; } 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 index 826ce2698ed..5bc11a92bd3 100644 --- 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 @@ -1,5 +1,6 @@ package org.prebid.server.hooks.modules.optable.targeting.model.openrtb; +import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.Value; import java.util.List; @@ -10,4 +11,6 @@ public class TargetingResult { List audience; Ortb2 ortb2; + + ObjectNode refs; } diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java new file mode 100644 index 00000000000..317cb621fe4 --- /dev/null +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java @@ -0,0 +1,130 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1; + +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.config.OptableTargetingProperties; +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.v1.core.AnalyticTagsResolver; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.BidderRequestEnricher; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.Id5Resolver; +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.analytics.Tags; +import org.prebid.server.hooks.v1.bidder.BidderInvocationContext; +import org.prebid.server.hooks.v1.bidder.BidderRequestHook; +import org.prebid.server.hooks.v1.bidder.BidderRequestPayload; + +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeoutException; + +public class OptableBidderRequestHook implements BidderRequestHook { + + public static final String CODE = "optable-targeting-bidder-request-hook"; + + @Override + public Future> call(BidderRequestPayload bidderRequestPayload, + BidderInvocationContext invocationContext) { + + final ModuleContext moduleContext = ModuleContext.of(invocationContext); + final OptableTargetingProperties properties = moduleContext.getOptableTargetingProperties(); + + final Set biddersToEnrich = moduleContext.getBiddersToEnrich(); + if (CollectionUtils.isEmpty(biddersToEnrich) + || !biddersToEnrich.contains(invocationContext.bidder())) { + return noAction(moduleContext, null); + } + + final String bidder = invocationContext.bidder(); + + return moduleContext.getOptableTargetingCall() + .compose(targetingResult -> + enrichedPayload(targetingResult, moduleContext, properties, bidder)) + .recover(throwable -> failedAction(moduleContext, throwable, bidder)); + } + + private Future> enrichedPayload(TargetingResult targetingResult, + ModuleContext moduleContext, + OptableTargetingProperties properties, + String bidder) { + + final long executionTime = calcExecutionTime(moduleContext); + final boolean hasData = hasEnrichmentData(targetingResult); + + if (hasData) { + moduleContext.setTargeting(targetingResult.getAudience()); + moduleContext.setId5Signature(Id5Resolver.resolveId5Signature(targetingResult)); + moduleContext.setEnrichRequestStatus(EnrichmentStatus.success()); + } + + final String outcome = hasData ? "enriched" : "no-data"; + final Tags analyticsTags = AnalyticTagsResolver.toBidderEnrichRequestAnalyticTags( + bidder, outcome, executionTime); + + return hasData + ? update(BidderRequestEnricher.of(targetingResult, properties), moduleContext, analyticsTags) + : noAction(moduleContext, analyticsTags); + } + + private Future> failedAction(ModuleContext moduleContext, + Throwable throwable, + String bidder) { + + final long executionTime = calcExecutionTime(moduleContext); + final String outcome = throwable instanceof TimeoutException ? "timeout" : "error"; + final Tags analyticsTags = AnalyticTagsResolver.toBidderEnrichRequestAnalyticTags( + bidder, outcome, executionTime); + + return noAction(moduleContext, analyticsTags); + } + + private static boolean hasEnrichmentData(TargetingResult targetingResult) { + return Optional.ofNullable(targetingResult) + .map(TargetingResult::getOrtb2) + .map(Ortb2::getUser) + .isPresent(); + } + + private static long calcExecutionTime(ModuleContext moduleContext) { + final long startTime = moduleContext.getCallTargetingAPITimestamp(); + return startTime > 0 ? System.currentTimeMillis() - startTime : 0; + } + + private Future> noAction(ModuleContext moduleContext, + Tags analyticsTags) { + + return Future.succeededFuture( + InvocationResultImpl.builder() + .status(InvocationStatus.success) + .action(InvocationAction.no_action) + .analyticsTags(analyticsTags) + .moduleContext(moduleContext) + .build()); + } + + private static Future> update( + PayloadUpdate payloadUpdate, + ModuleContext moduleContext, + Tags analyticsTags) { + + return Future.succeededFuture( + InvocationResultImpl.builder() + .status(InvocationStatus.success) + .action(InvocationAction.update) + .analyticsTags(analyticsTags) + .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/OptableRawAuctionRequestHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableRawAuctionRequestHook.java new file mode 100644 index 00000000000..e5b24f3c53c --- /dev/null +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableRawAuctionRequestHook.java @@ -0,0 +1,120 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1; + +import com.iab.openrtb.request.BidRequest; +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.ModuleContext; +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.BidRequestCleaner; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.BidderEnrichmentSampler; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.CompositeHookExecutionPlan; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.ConfigResolver; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.TargetingRequestExecutor; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.PropertiesValidator; +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.RawAuctionRequestHook; +import org.prebid.server.log.ConditionalLogger; +import org.prebid.server.log.LoggerFactory; +import org.prebid.server.settings.model.Account; + +import java.util.Objects; +import java.util.Set; + +public class OptableRawAuctionRequestHook implements RawAuctionRequestHook { + + private static final ConditionalLogger conditionalLogger = new ConditionalLogger( + LoggerFactory.getLogger(OptableRawAuctionRequestHook.class)); + + public static final String CODE = "optable-targeting-raw-auction-request-hook"; + + private final ConfigResolver configResolver; + private final TargetingRequestExecutor targetingRequestExecutor; + private final BidderEnrichmentSampler bidderEnrichmentSampler; + private final CompositeHookExecutionPlan hooksExecutionPlan; + private final double logSamplingRate; + + public OptableRawAuctionRequestHook(ConfigResolver configResolver, + TargetingRequestExecutor targetingRequestExecutor, + BidderEnrichmentSampler bidderEnrichmentSampler, + CompositeHookExecutionPlan hooksExecutionPlan, + double logSamplingRate) { + + this.configResolver = Objects.requireNonNull(configResolver); + this.targetingRequestExecutor = Objects.requireNonNull(targetingRequestExecutor); + this.bidderEnrichmentSampler = Objects.requireNonNull(bidderEnrichmentSampler); + this.hooksExecutionPlan = hooksExecutionPlan; + this.logSamplingRate = logSamplingRate; + } + + @Override + public Future> call(AuctionRequestPayload payload, + AuctionInvocationContext invocationContext) { + + final OptableTargetingProperties properties = configResolver.resolve(invocationContext.accountConfig()); + final ModuleContext moduleContext = new ModuleContext(); + moduleContext.setEarlyNetworkCallEnabled(true); + moduleContext.setCallTargetingAPITimestamp(System.currentTimeMillis()); + moduleContext.setOptableTargetingProperties(properties); + + if (!PropertiesValidator.isValid(properties)) { + conditionalLogger.error( + "Account not properly configured: tenant and/or origin is missing.", logSamplingRate); + + moduleContext.failWithExecutionTime( + System.currentTimeMillis() - moduleContext.getCallTargetingAPITimestamp()); + + return update(BidRequestCleaner.instance(), moduleContext); + } + + final BidRequest bidRequest = invocationContext.auctionContext().getBidRequest(); + if (!PropertiesValidator.isTrafficSourceValid(bidRequest, properties)) { + moduleContext.setShouldSkipEnrichment(true); + return update(BidRequestCleaner.instance(), moduleContext); + } + + final Set biddersToEnrich = bidderEnrichmentSampler.sample(bidRequest, properties); + if (CollectionUtils.isEmpty(biddersToEnrich)) { + return update(BidRequestCleaner.instance(), moduleContext); + } + + moduleContext.setBiddersToEnrich(biddersToEnrich); + final Account account = invocationContext.auctionContext().getAccount(); + final long crossHookFutureTimeout = + hooksExecutionPlan.getOptableTargetingBidderRequestTimeout(account); + + final Future optableTargetingCall = targetingRequestExecutor.makeRequest( + payload, + invocationContext, + properties, + crossHookFutureTimeout); + + moduleContext.setOptableTargetingCall(optableTargetingCall); + + return update(BidRequestCleaner.instance(), moduleContext); + } + + public static Future> update( + PayloadUpdate payloadUpdate, + ModuleContext moduleContext) { + + return Future.succeededFuture( + InvocationResultImpl.builder() + .status(InvocationStatus.success) + .action(InvocationAction.update) + .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/OptableTargetingAuctionResponseHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java index 5a20f79a347..5b915618b0c 100644 --- 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 @@ -2,7 +2,6 @@ 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; @@ -13,6 +12,7 @@ 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.modules.optable.targeting.v1.core.Id5SignatureBidResponseEnricher; import org.prebid.server.hooks.v1.InvocationAction; import org.prebid.server.hooks.v1.InvocationResult; import org.prebid.server.hooks.v1.InvocationStatus; @@ -52,27 +52,39 @@ public Future> call(AuctionResponsePayl final ModuleContext moduleContext = ModuleContext.of(invocationContext); moduleContext.setAdserverTargetingEnabled(adserverTargeting); - if (!adserverTargeting) { - return success(moduleContext); + if (moduleContext.isShouldSkipEnrichment() || !adserverTargeting) { + return id5SignatureOnlyPayload(moduleContext); } + final List targeting = moduleContext.getTargeting(); + final EnrichmentStatus validationStatus = AuctionResponseValidator.checkEnrichmentPossibility( - auctionResponsePayload.bidResponse(), moduleContext.getTargeting()); + auctionResponsePayload.bidResponse(), targeting); moduleContext.setEnrichResponseStatus(validationStatus); return validationStatus.getStatus() == Status.SUCCESS - ? enrichedPayload(moduleContext) - : success(moduleContext); + ? update(fullEnrichmentChain(targeting, moduleContext.getId5Signature()), moduleContext) + : id5SignatureOnlyPayload(moduleContext); } - private Future> enrichedPayload(ModuleContext moduleContext) { - final List targeting = moduleContext.getTargeting(); + private PayloadUpdate fullEnrichmentChain(final List targeting, + final String id5Signature) { - return CollectionUtils.isNotEmpty(targeting) - ? update(BidResponseEnricher.of(targeting, objectMapper, jsonMerger), moduleContext) + return BidResponseEnricher.of(targeting, objectMapper, jsonMerger) + .andThen(Id5SignatureBidResponseEnricher.of(id5Signature, objectMapper, jsonMerger))::apply; + } + + private Future> id5SignatureOnlyPayload(ModuleContext moduleContext) { + final String id5Signature = moduleContext.getId5Signature(); + return id5Signature != null + ? update(id5SignatureEnrichmentChain(id5Signature), moduleContext) : success(moduleContext); } + private PayloadUpdate id5SignatureEnrichmentChain(String id5Signature) { + return Id5SignatureBidResponseEnricher.of(id5Signature, objectMapper, jsonMerger); + } + private Future> update( PayloadUpdate payloadUpdate, ModuleContext moduleContext) { 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 index a5ad2559d40..fc22fdc354c 100644 --- 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 @@ -1,31 +1,19 @@ 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.apache.commons.lang3.StringUtils; -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.CompositeHookExecutionPlan; 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.modules.optable.targeting.v1.core.Id5Resolver; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.PropertiesValidator; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.TargetingRequestExecutor; import org.prebid.server.hooks.v1.InvocationAction; import org.prebid.server.hooks.v1.InvocationResult; import org.prebid.server.hooks.v1.InvocationStatus; @@ -35,9 +23,15 @@ import org.prebid.server.hooks.v1.auction.ProcessedAuctionRequestHook; import org.prebid.server.log.ConditionalLogger; import org.prebid.server.log.LoggerFactory; +import org.prebid.server.settings.model.Account; import java.util.Objects; +/** + * @deprecated This hook is deprecated and will be removed in a future release. + * Use {@link OptableRawAuctionRequestHook} and {@link OptableBidderRequestHook} instead. + */ +@Deprecated public class OptableTargetingProcessedAuctionRequestHook implements ProcessedAuctionRequestHook { private static final ConditionalLogger conditionalLogger = new ConditionalLogger( @@ -45,19 +39,23 @@ public class OptableTargetingProcessedAuctionRequestHook implements ProcessedAuc public static final String CODE = "optable-targeting-processed-auction-request-hook"; + private static final String AUCTION_NOT_PROPERLY_CONFIGURED = + "Account not properly configured: tenant and/or origin is missing."; + private final ConfigResolver configResolver; - private final OptableTargeting optableTargeting; - private final UserFpdActivityMask userFpdActivityMask; + private final TargetingRequestExecutor targetingRequestExecutor; private final double logSamplingRate; + private final CompositeHookExecutionPlan hooksExecutionPlan; + public OptableTargetingProcessedAuctionRequestHook(ConfigResolver configResolver, - OptableTargeting optableTargeting, - UserFpdActivityMask userFpdActivityMask, + TargetingRequestExecutor targetingRequestExecutor, + CompositeHookExecutionPlan hooksExecutionPlan, double logSamplingRate) { this.configResolver = Objects.requireNonNull(configResolver); - this.optableTargeting = Objects.requireNonNull(optableTargeting); - this.userFpdActivityMask = Objects.requireNonNull(userFpdActivityMask); + this.targetingRequestExecutor = Objects.requireNonNull(targetingRequestExecutor); + this.hooksExecutionPlan = hooksExecutionPlan; this.logSamplingRate = logSamplingRate; } @@ -65,94 +63,97 @@ public OptableTargetingProcessedAuctionRequestHook(ConfigResolver configResolver public Future> call(AuctionRequestPayload auctionRequestPayload, AuctionInvocationContext invocationContext) { - final OptableTargetingProperties properties = configResolver.resolve(invocationContext.accountConfig()); - final ModuleContext moduleContext = new ModuleContext(); - final long callTargetingAPITimestamp = System.currentTimeMillis(); + final ModuleContext moduleContext = ModuleContext.of(invocationContext); + if (moduleContext.isShouldSkipEnrichment()) { + moduleContext.setOptableTargetingExecutionTime(calcAPICallExecutionTime(moduleContext)); + return update(BidRequestCleaner.instance(), moduleContext); + } - if (!isTargetingPropertiesValid(properties)) { - conditionalLogger.error( - "Account not properly configured: tenant and/or origin is missing.", logSamplingRate); + final Account account = invocationContext.auctionContext().getAccount(); + final boolean hasRawAuctionRequestHook = hooksExecutionPlan.hasRawAuctionRequestHook(account); + final boolean hasBidderRequestHook = hooksExecutionPlan.hasBidderRequestHook(account); - moduleContext.setOptableTargetingExecutionTime(System.currentTimeMillis() - callTargetingAPITimestamp); - moduleContext.setEnrichRequestStatus(EnrichmentStatus.failure()); + if (hasRawAuctionRequestHook && hasBidderRequestHook) { return update(BidRequestCleaner.instance(), moduleContext); } - final BidRequest bidRequest = applyActivityRestrictions(auctionRequestPayload.bidRequest(), invocationContext); + final OptableTargetingProperties properties = + resolveOptableTargetingProperties(moduleContext, invocationContext); - final Timeout timeout = getHookTimeout(invocationContext); - final OptableAttributes attributes = OptableAttributesResolver.resolveAttributes( - invocationContext.auctionContext(), - properties.getTimeout()); + final Future optableTargetingCall = hasRawAuctionRequestHook + ? resolveEarlyNetworkCall(moduleContext) + : resolvePreEarlyNetworkCall(auctionRequestPayload, invocationContext, moduleContext, properties); - return optableTargeting.getTargeting(properties, bidRequest, attributes, timeout) + if (optableTargetingCall == null) { + moduleContext.failWithExecutionTime(calcAPICallExecutionTime(moduleContext)); + return update(BidRequestCleaner.instance(), moduleContext); + } + + return optableTargetingCall .compose(targetingResult -> { - moduleContext.setOptableTargetingExecutionTime( - System.currentTimeMillis() - callTargetingAPITimestamp); - return enrichedPayload(targetingResult, moduleContext, properties); + moduleContext.setOptableTargetingExecutionTime(calcAPICallExecutionTime(moduleContext)); + return enrichPayload(targetingResult, moduleContext, properties); }) .recover(throwable -> { - moduleContext.setOptableTargetingExecutionTime( - System.currentTimeMillis() - callTargetingAPITimestamp); - moduleContext.setEnrichRequestStatus(EnrichmentStatus.failure()); + moduleContext.failWithExecutionTime(calcAPICallExecutionTime(moduleContext)); return update(BidRequestCleaner.instance(), moduleContext); }); } - private boolean isTargetingPropertiesValid(OptableTargetingProperties properties) { - return !StringUtils.isEmpty(properties.getOrigin()) && !StringUtils.isEmpty(properties.getTenant()); - } + private Future> enrichPayload( + TargetingResult targetingResult, + ModuleContext moduleContext, + OptableTargetingProperties properties) { + + moduleContext.setTargeting(targetingResult.getAudience()); + moduleContext.setId5Signature(Id5Resolver.resolveId5Signature(targetingResult)); + moduleContext.setEnrichRequestStatus(EnrichmentStatus.success()); - private BidRequest applyActivityRestrictions(BidRequest bidRequest, - AuctionInvocationContext auctionInvocationContext) { + final PayloadUpdate payloadUpdate = + BidRequestCleaner.instance().andThen(BidRequestEnricher.of(targetingResult, properties))::apply; - final AuctionContext auctionContext = auctionInvocationContext.auctionContext(); - final ActivityInvocationPayload activityInvocationPayload = BidRequestActivityInvocationPayload.of( - ActivityInvocationPayloadImpl.of(ComponentType.GENERAL_MODULE, OptableTargetingModule.CODE), - bidRequest); - final ActivityInfrastructure activityInfrastructure = auctionContext.getActivityInfrastructure(); + return update(payloadUpdate, moduleContext); + } - 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); + private Future resolveEarlyNetworkCall(ModuleContext moduleContext) { + return moduleContext.getOptableTargetingCall(); + } - return maskUserPersonalInfo(bidRequest, disallowTransmitUfpd, disallowTransmitEids, disallowTransmitGeo); + private static long calcAPICallExecutionTime(ModuleContext moduleContext) { + return System.currentTimeMillis() - moduleContext.getCallTargetingAPITimestamp(); } - private BidRequest maskUserPersonalInfo(BidRequest bidRequest, - boolean disallowTransmitUfpd, - boolean disallowTransmitEids, - boolean disallowTransmitGeo) { + private OptableTargetingProperties resolveOptableTargetingProperties(ModuleContext moduleContext, + AuctionInvocationContext invocationContext) { - final User maskedUser = userFpdActivityMask.maskUser( - bidRequest.getUser(), disallowTransmitUfpd, disallowTransmitEids); - final Device maskedDevice = userFpdActivityMask.maskDevice( - bidRequest.getDevice(), disallowTransmitUfpd, disallowTransmitGeo); + final OptableTargetingProperties properties = moduleContext.hasOptableTargetingProperties() + ? moduleContext.getOptableTargetingProperties() + : configResolver.resolve(invocationContext.accountConfig()); + moduleContext.setOptableTargetingProperties(properties); - return bidRequest.toBuilder() - .user(maskedUser) - .device(maskedDevice) - .build(); + return properties; } - private Timeout getHookTimeout(AuctionInvocationContext invocationContext) { - return invocationContext.timeout(); - } + private Future resolvePreEarlyNetworkCall( + AuctionRequestPayload payload, + AuctionInvocationContext invocationContext, + ModuleContext moduleContext, + OptableTargetingProperties properties) { - private Future> enrichedPayload(TargetingResult targetingResult, - ModuleContext moduleContext, - OptableTargetingProperties properties) { + moduleContext.setCallTargetingAPITimestamp(System.currentTimeMillis()); + if (!PropertiesValidator.isValid(properties)) { + conditionalLogger.error(AUCTION_NOT_PROPERLY_CONFIGURED, logSamplingRate); - moduleContext.setTargeting(targetingResult.getAudience()); - moduleContext.setEnrichRequestStatus(EnrichmentStatus.success()); - return update( - BidRequestCleaner.instance() - .andThen(BidRequestEnricher.of(targetingResult, properties)) - ::apply, - moduleContext); + moduleContext.failWithExecutionTime( + System.currentTimeMillis() - moduleContext.getCallTargetingAPITimestamp()); + return Future.failedFuture(AUCTION_NOT_PROPERLY_CONFIGURED); + } + + return targetingRequestExecutor.makeRequest( + payload, + invocationContext, + properties, + null); } private static Future> update( diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/AliasesResolver.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/AliasesResolver.java new file mode 100644 index 00000000000..f86724de969 --- /dev/null +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/AliasesResolver.java @@ -0,0 +1,27 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +import com.iab.openrtb.request.BidRequest; +import lombok.AllArgsConstructor; +import org.prebid.server.auction.aliases.BidderAliases; +import org.prebid.server.bidder.BidderCatalog; +import org.prebid.server.util.PbsUtil; + +import java.util.Map; +import java.util.Optional; + +@AllArgsConstructor(staticName = "of") +public class AliasesResolver { + + private final BidderCatalog bidderCatalog; + + public BidderAliases resolve(BidRequest bidRequest) { + return Optional.ofNullable(bidRequest) + .map(PbsUtil::extRequestPrebid) + .map(extRequestPrebid -> { + final Map aliases = extRequestPrebid.getAliases(); + final Map aliasesGvlIds = extRequestPrebid.getAliasgvlids(); + return BidderAliases.of(aliases, aliasesGvlIds, bidderCatalog); + }) + .orElseGet(() -> BidderAliases.of(Map.of(), Map.of(), bidderCatalog)); + } +} 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 index 80905ce088c..5504ddfea26 100644 --- 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 @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import org.prebid.server.hooks.execution.v1.analytics.ActivityImpl; +import org.prebid.server.hooks.execution.v1.analytics.AppliedToImpl; 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; @@ -21,8 +22,10 @@ public class AnalyticTagsResolver { private static final String ACTIVITY_ENRICH_REQUEST = "optable-enrich-request"; + private static final String ACTIVITY_ENRICH_BIDDER_REQUEST = "optable-enrich-bidder-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_OUTCOME = "outcome"; private static final String STATUS_REASON = "reason"; private AnalyticTagsResolver() { @@ -35,6 +38,26 @@ public static Tags toEnrichRequestAnalyticTags(ModuleContext moduleContext) { toResults(STATUS_EXECUTION_TIME, String.valueOf(moduleContext.getOptableTargetingExecutionTime()))))); } + public static Tags toBidderEnrichRequestAnalyticTags(String bidder, + String outcome, + long executionTime) { + + final ObjectNode values = ObjectMapperProvider.mapper().createObjectNode() + .put(STATUS_OUTCOME, outcome) + .put(STATUS_EXECUTION_TIME, String.valueOf(executionTime)); + + return TagsImpl.of(Collections.singletonList(ActivityImpl.of( + ACTIVITY_ENRICH_BIDDER_REQUEST, + "enriched".equals(outcome) ? Status.SUCCESS.getValue() : Status.FAIL.getValue(), + Collections.singletonList(ResultImpl.of( + null, + values, + AppliedToImpl.builder() + .bidders(Collections.singletonList(bidder)) + .request(true) + .build()))))); + } + public static Tags toEnrichResponseAnalyticTags(ModuleContext moduleContext) { final List activities = new ArrayList<>(); if (moduleContext.isAdserverTargetingEnabled()) { 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 index 30652383942..d467e6df750 100644 --- 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 @@ -14,7 +14,7 @@ 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"); + private static final List FIELDS_TO_REMOVE = List.of("email", "phone", "zip", "vid", "id5_signature"); public static BidRequestCleaner instance() { return new BidRequestCleaner(); 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 index 2a60389802c..81c82b55580 100644 --- 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 @@ -1,39 +1,15 @@ 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 com.iab.openrtb.request.Uid; -import org.apache.commons.collections4.CollectionUtils; -import org.apache.commons.lang3.StringUtils; import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl; import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties; -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 static final String OPTABLE_CO_INSERTER = "optable.co"; - - private final TargetingResult targetingResult; - private final OptableTargetingProperties targetingProperties; +public class BidRequestEnricher extends RequestEnricher implements PayloadUpdate { private BidRequestEnricher(TargetingResult targetingResult, OptableTargetingProperties targetingProperties) { - this.targetingResult = targetingResult; - this.targetingProperties = targetingProperties; + super(targetingResult, targetingProperties); } public static BidRequestEnricher of(TargetingResult targetingResult, OptableTargetingProperties properties) { @@ -44,160 +20,4 @@ public static BidRequestEnricher of(TargetingResult targetingResult, OptableTarg 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 com.iab.openrtb.request.User mergeUserData(com.iab.openrtb.request.User user, User optableUser) { - return user.toBuilder() - .eids(filterOptableEids(mergeEids(user.getEids(), optableUser.getEids()))) - .data(mergeData(user.getData(), optableUser.getData())) - .build(); - } - - private List mergeEids(List destination, List source) { - if (CollectionUtils.isEmpty(destination)) { - return source; - } - - if (CollectionUtils.isEmpty(source)) { - return destination; - } - - final Map idToSourceEid = source.stream().collect(Collectors.toMap( - BidRequestEnricher::eidIdExtractor, - Function.identity(), - (a, b) -> b, - HashMap::new)); - - final Set sourceToReplace = targetingProperties.getOptableInserterEidsReplace(); - final Set sourceToMerge = targetingProperties.getOptableInserterEidsMerge() - .stream() - .filter(it -> !sourceToReplace.contains(it)).collect(Collectors.toSet()); - - final List mergedEid = destination.stream() - .map(destinationEid -> idToSourceEid.containsKey(eidIdExtractor(destinationEid)) - && OPTABLE_CO_INSERTER.equals(destinationEid.getInserter()) - ? resolveEidConflict( - destinationEid, - idToSourceEid.get(eidIdExtractor(destinationEid)), - sourceToMerge, - sourceToReplace) - : destinationEid) - .toList(); - - return merge(mergedEid, source, BidRequestEnricher::eidIdExtractor); - } - - private List filterOptableEids(List eids) { - if (CollectionUtils.isEmpty(eids)) { - return eids; - } - - final Set optableIdsToIgnore = targetingProperties.getOptableInserterEidsIgnore(); - if (CollectionUtils.isEmpty(optableIdsToIgnore)) { - return eids; - } - - return eids.stream() - .filter(eid -> !OPTABLE_CO_INSERTER.equals(eid.getInserter()) - || !optableIdsToIgnore.contains(eid.getSource())) - .toList(); - } - - private static Eid resolveEidConflict(Eid destinationEid, - Eid sourceEid, - Set sourceToMerge, - Set sourceToReplace) { - - final String eidSource = sourceEid.getSource(); - - if (sourceToReplace.contains(eidSource)) { - return sourceEid; - } - if (sourceToMerge.contains(eidSource)) { - return mergeEid(destinationEid, sourceEid); - } - - return destinationEid; - } - - private static Eid mergeEid(Eid destinationEid, Eid sourceEid) { - return destinationEid.toBuilder() - .uids(merge(destinationEid.getUids(), sourceEid.getUids(), Uid::getId)) - .build(); - } - - private static String eidIdExtractor(Eid eid) { - return "%s_%s".formatted(StringUtils.defaultString(eid.getInserter()), 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/BidderEnrichmentSampler.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidderEnrichmentSampler.java new file mode 100644 index 00000000000..9e714e74a54 --- /dev/null +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidderEnrichmentSampler.java @@ -0,0 +1,65 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Imp; +import lombok.AllArgsConstructor; +import org.prebid.server.auction.aliases.BidderAliases; +import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties; +import org.prebid.server.util.StreamUtil; + +import java.util.Collection; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.IntSupplier; +import java.util.stream.Collectors; + +@AllArgsConstructor(staticName = "of") +public class BidderEnrichmentSampler { + + private final AliasesResolver aliasesResolver; + private final IntSupplier randomSupplier; + + public static BidderEnrichmentSampler of(AliasesResolver aliasesResolver) { + return of(aliasesResolver, () -> ThreadLocalRandom.current().nextInt(100)); + } + + public Set sample(BidRequest bidRequest, OptableTargetingProperties optableTargetingProperties) { + final Integer defaultEnrichmentPercentage = optableTargetingProperties.getEnrichmentPercentage(); + final Map bidderEnrichmentPercentage = + optableTargetingProperties.getBidderEnrichmentPercentages(); + + final BidderAliases aliases = aliasesResolver.resolve(bidRequest); + return extractUniqueBidders(bidRequest) + .stream() + .filter(bidder -> { + final int percentage = + resolvePercentage(aliases, bidder, defaultEnrichmentPercentage, bidderEnrichmentPercentage); + return percentage > 0 && randomSupplier.getAsInt() < percentage; + }) + .collect(Collectors.toSet()); + } + + private static int resolvePercentage(BidderAliases aliases, String bidder, + Integer defaultEnrichmentPercentage, + Map bidderEnrichmentPercentage) { + + return Optional.ofNullable(bidderEnrichmentPercentage.get(bidder)) + .or(() -> Optional.ofNullable(bidderEnrichmentPercentage.get(aliases.resolveBidder(bidder)))) + .orElse(defaultEnrichmentPercentage); + } + + private static Set extractUniqueBidders(BidRequest bidRequest) { + return Optional.ofNullable(bidRequest.getImp()) + .stream() + .flatMap(Collection::stream) + .map(Imp::getExt) + .filter(Objects::nonNull) + .map(ext -> ext.at("/prebid/bidder")) + .filter(Objects::nonNull) + .flatMap(bidder -> StreamUtil.asStream(bidder.fieldNames())) + .collect(Collectors.toSet()); + } +} diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidderRequestEnricher.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidderRequestEnricher.java new file mode 100644 index 00000000000..24b59cd42a1 --- /dev/null +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidderRequestEnricher.java @@ -0,0 +1,23 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +import org.prebid.server.hooks.execution.v1.bidder.BidderRequestPayloadImpl; +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.v1.PayloadUpdate; +import org.prebid.server.hooks.v1.bidder.BidderRequestPayload; + +public class BidderRequestEnricher extends RequestEnricher implements PayloadUpdate { + + private BidderRequestEnricher(TargetingResult targetingResult, OptableTargetingProperties targetingProperties) { + super(targetingResult, targetingProperties); + } + + public static BidderRequestEnricher of(TargetingResult targetingResult, OptableTargetingProperties properties) { + return new BidderRequestEnricher(targetingResult, properties); + } + + @Override + public BidderRequestPayload apply(BidderRequestPayload payload) { + return BidderRequestPayloadImpl.of(enrichBidRequest(payload.bidRequest())); + } +} diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/CompositeHookExecutionPlan.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/CompositeHookExecutionPlan.java new file mode 100644 index 00000000000..0d5bda38be9 --- /dev/null +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/CompositeHookExecutionPlan.java @@ -0,0 +1,120 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.hooks.execution.model.EndpointExecutionPlan; +import org.prebid.server.hooks.execution.model.ExecutionGroup; +import org.prebid.server.hooks.execution.model.ExecutionPlan; +import org.prebid.server.hooks.execution.model.HookHttpEndpoint; +import org.prebid.server.hooks.execution.model.Stage; +import org.prebid.server.hooks.execution.model.StageExecutionPlan; +import org.prebid.server.hooks.modules.optable.targeting.v1.OptableBidderRequestHook; +import org.prebid.server.hooks.modules.optable.targeting.v1.OptableRawAuctionRequestHook; +import org.prebid.server.settings.model.Account; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +public class CompositeHookExecutionPlan { + + private static final HookHttpEndpoint ENDPOINT_AUCTION = HookHttpEndpoint.POST_AUCTION; + private static final String STAGE_RAW_AUCTION_REQUEST = "raw_auction_request"; + private static final String STAGE_BIDDER_REQUEST = "bidder_request"; + private static final String HOOK_CODE_OPTABLE_RAW_AUCTION = OptableRawAuctionRequestHook.CODE; + private static final String HOOK_CODE_OPTABLE_BIDDER_REQUEST = OptableBidderRequestHook.CODE; + + private final boolean hasGlobalRawAuctionRequestHook; + + private final boolean hasGlobalBidderRequestHook; + + private final long globalBidderRequestHookTimeout; + + private final ConcurrentHashMap rawAuctionRequestHookCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap bidderRequestHookCache = new ConcurrentHashMap<>(); + + private final ConcurrentHashMap bidderRequestHookTimeoutCache = new ConcurrentHashMap<>(); + + private CompositeHookExecutionPlan(boolean hasGlobalRawAuctionRequestHook, + boolean hasGlobalBidderRequestHook, + long globalBidderRequestHookTimeout) { + + this.hasGlobalRawAuctionRequestHook = hasGlobalRawAuctionRequestHook; + this.hasGlobalBidderRequestHook = hasGlobalBidderRequestHook; + this.globalBidderRequestHookTimeout = globalBidderRequestHookTimeout; + } + + public static CompositeHookExecutionPlan of(ExecutionPlan globalExecutionPlan) { + return globalExecutionPlan == null + ? new CompositeHookExecutionPlan(false, false, 0) + : new CompositeHookExecutionPlan( + hasHook(globalExecutionPlan, STAGE_RAW_AUCTION_REQUEST, HOOK_CODE_OPTABLE_RAW_AUCTION), + hasHook(globalExecutionPlan, STAGE_BIDDER_REQUEST, HOOK_CODE_OPTABLE_BIDDER_REQUEST), + getHookTimeout(globalExecutionPlan, + STAGE_BIDDER_REQUEST, HOOK_CODE_OPTABLE_BIDDER_REQUEST)); + } + + private T computeFromAccount(Account account, + ConcurrentHashMap cache, + T defaultValue, + Function compute) { + final String accountId = account != null ? account.getId() : null; + return StringUtils.isNotEmpty(accountId) + ? cache.computeIfAbsent(accountId, id -> compute.apply(resolveExecutionPlan(account))) + : defaultValue; + } + + public boolean hasRawAuctionRequestHook(Account account) { + return computeFromAccount(account, rawAuctionRequestHookCache, false, + plan -> hasHook(plan, STAGE_RAW_AUCTION_REQUEST, HOOK_CODE_OPTABLE_RAW_AUCTION) + || hasGlobalRawAuctionRequestHook); + } + + public boolean hasBidderRequestHook(Account account) { + return computeFromAccount(account, bidderRequestHookCache, false, + plan -> hasHook(plan, STAGE_BIDDER_REQUEST, HOOK_CODE_OPTABLE_BIDDER_REQUEST) + || hasGlobalBidderRequestHook); + } + + public long getOptableTargetingBidderRequestTimeout(Account account) { + return computeFromAccount(account, bidderRequestHookTimeoutCache, globalBidderRequestHookTimeout, + plan -> { + final long timeout = getHookTimeout(plan, STAGE_BIDDER_REQUEST, HOOK_CODE_OPTABLE_BIDDER_REQUEST); + return timeout != 0 ? timeout : globalBidderRequestHookTimeout; + }); + } + + private ExecutionPlan resolveExecutionPlan(Account account) { + return Optional.ofNullable(account) + .map(org.prebid.server.settings.model.Account::getHooks) + .map(org.prebid.server.settings.model.AccountHooksConfiguration::getExecutionPlan) + .orElse(null); + } + + private static boolean hasHook(ExecutionPlan executionPlan, String stage, String hookCode) { + return Optional.ofNullable(executionPlan) + .map(ExecutionPlan::getEndpoints) + .map(endpoints -> endpoints.get(ENDPOINT_AUCTION)) + .map(EndpointExecutionPlan::getStages) + .map(stages -> stages.get(Stage.valueOf(stage))) + .map(StageExecutionPlan::getGroups) + .orElseGet(List::of) + .stream() + .map(ExecutionGroup::getHookSequence) + .flatMap(java.util.Collection::stream) + .anyMatch(hook -> hookCode.equals(hook.getHookImplCode())); + } + + private static long getHookTimeout(ExecutionPlan executionPlan, String stage, String hookCode) { + return Optional.ofNullable(executionPlan) + .map(ExecutionPlan::getEndpoints) + .map(endpoints -> endpoints.get(ENDPOINT_AUCTION)) + .map(EndpointExecutionPlan::getStages) + .map(stages -> stages.get(Stage.valueOf(stage))) + .map(StageExecutionPlan::getGroups) + .orElseGet(List::of) + .stream().findFirst() + .map(ExecutionGroup::getTimeout) + .orElse(0L); + } +} diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/ExtUserOptableResolver.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/ExtUserOptableResolver.java new file mode 100644 index 00000000000..1d7e081148e --- /dev/null +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/ExtUserOptableResolver.java @@ -0,0 +1,26 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.ExtUserOptable; +import org.prebid.server.json.ObjectMapperProvider; +import org.prebid.server.log.ConditionalLogger; +import org.prebid.server.log.LoggerFactory; + +public class ExtUserOptableResolver { + + private static final ConditionalLogger conditionalLogger = + new ConditionalLogger(LoggerFactory.getLogger(ExtUserOptableResolver.class)); + + private ExtUserOptableResolver() { + } + + public static ExtUserOptable resolveExtUserOptable(JsonNode node, double logSamplingRate) { + try { + return ObjectMapperProvider.mapper().treeToValue(node, ExtUserOptable.class); + } catch (JsonProcessingException e) { + conditionalLogger.warn("Can't parse $.ext.user.Optable tag", logSamplingRate); + return null; + } + } +} diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java new file mode 100644 index 00000000000..4349ad21240 --- /dev/null +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java @@ -0,0 +1,74 @@ +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.Eid; +import com.iab.openrtb.request.Uid; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +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 java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public class Id5Resolver { + + public static final String OPTABLE_INSERTER = "optable.co"; + public static final String ID5_SOURCE = "id5-sync.com"; + public static final String OPTABLE = "optable"; + public static final String REF = "ref"; + public static final String SIGNATURE = "signature"; + + private Id5Resolver() { + } + + public static String resolveId5Signature(TargetingResult targetingResult) { + if (targetingResult == null) { + return null; + } + + final List refs = Optional.of(targetingResult) + .map(TargetingResult::getOrtb2) + .map(Ortb2::getUser) + .map(User::getEids) + .orElseGet(List::of) + .stream() + .filter(it -> OPTABLE_INSERTER.equals(it.getInserter()) && ID5_SOURCE.equals(it.getSource())) + .map(Eid::getUids) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .map(Uid::getExt) + .filter(Objects::nonNull) + .map(it -> it.get(OPTABLE)) + .filter(Objects::nonNull) + .map(it -> it.get(REF)) + .filter(Objects::nonNull) + .map(JsonNode::asText) + .toList(); + + if (CollectionUtils.isEmpty(refs)) { + return null; + } + + final ObjectNode references = targetingResult.getRefs(); + if (references == null) { + return null; + } + + return refs.stream() + .map(references::get) + .filter(Objects::nonNull) + .map(it -> it.get(SIGNATURE)) + .filter(Objects::nonNull) + .filter(JsonNode::isValueNode) + .filter(it -> !it.isNull()) + .map(JsonNode::asText) + .filter(StringUtils::isNotBlank) + .findFirst() + .orElse(null); + } +} diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5SignatureBidResponseEnricher.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5SignatureBidResponseEnricher.java new file mode 100644 index 00000000000..e94af71928d --- /dev/null +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5SignatureBidResponseEnricher.java @@ -0,0 +1,83 @@ +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.BidResponse; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.hooks.execution.v1.auction.AuctionResponsePayloadImpl; +import org.prebid.server.hooks.v1.PayloadUpdate; +import org.prebid.server.hooks.v1.auction.AuctionResponsePayload; +import org.prebid.server.json.JsonMerger; +import org.prebid.server.proto.openrtb.ext.response.ExtBidResponse; +import org.prebid.server.proto.openrtb.ext.response.ExtBidResponsePrebid; + +import java.util.Objects; + +public class Id5SignatureBidResponseEnricher implements PayloadUpdate { + + private final String id5Signature; + private final ObjectMapper mapper; + private final JsonMerger jsonMerger; + + private Id5SignatureBidResponseEnricher(String id5Signature, ObjectMapper mapper, JsonMerger jsonMerger) { + this.id5Signature = id5Signature; + this.mapper = Objects.requireNonNull(mapper); + this.jsonMerger = Objects.requireNonNull(jsonMerger); + } + + public static Id5SignatureBidResponseEnricher of(String id5Signature, ObjectMapper mapper, JsonMerger jsonMerger) { + return new Id5SignatureBidResponseEnricher(id5Signature, mapper, jsonMerger); + } + + @Override + public AuctionResponsePayload apply(AuctionResponsePayload payload) { + return AuctionResponsePayloadImpl.of(enrichBidResponse(payload.bidResponse(), id5Signature)); + } + + private BidResponse enrichBidResponse(BidResponse bidResponse, String id5Signature) { + if (StringUtils.isEmpty(id5Signature)) { + return bidResponse; + } + final ObjectNode passthroughNode = id5SignatureToObjectNode(id5Signature); + + final ExtBidResponse existingExt = bidResponse.getExt(); + final ExtBidResponsePrebid existingPrebid = existingExt != null ? existingExt.getPrebid() : null; + final JsonNode existingPassthrough = existingPrebid != null ? existingPrebid.getPassthrough() : null; + + final JsonNode mergedPassthrough = existingPassthrough != null + ? jsonMerger.merge(passthroughNode, existingPassthrough) + : passthroughNode; + + final ExtBidResponsePrebid modifiedPrebid = existingPrebid != null + ? existingPrebid.toBuilder() + .passthrough(mergedPassthrough) + .build() + : ExtBidResponsePrebid.builder() + .passthrough(mergedPassthrough) + .build(); + + final ExtBidResponse modifiedExt = existingExt != null + ? existingExt.toBuilder() + .prebid(modifiedPrebid) + .build() + : ExtBidResponse.builder() + .prebid(modifiedPrebid) + .build(); + + return bidResponse.toBuilder() + .ext(modifiedExt) + .build(); + } + + private ObjectNode id5SignatureToObjectNode(String id5Signature) { + final ObjectNode node = mapper.createObjectNode(); + node.set("id5_signature", TextNode.valueOf(id5Signature)); + + final ObjectNode optableNode = mapper.createObjectNode(); + optableNode.set("optable", node); + + return optableNode; + } +} 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 index 7214f6ce6a3..2ea248334b3 100644 --- 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 @@ -1,8 +1,5 @@ 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; @@ -14,8 +11,6 @@ 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; @@ -26,19 +21,14 @@ 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); + public IdsMapper(double logSamplingRate) { this.logSamplingRate = logSamplingRate; } @@ -60,7 +50,7 @@ private void addOptableIds(Map ids, User user) { final Optional extUserOptable = Optional.ofNullable(user) .map(User::getExt) .map(ext -> ext.getProperty("optable")) - .map(this::parseExtUserOptable); + .map(it -> ExtUserOptableResolver.resolveExtUserOptable(it, logSamplingRate)); extUserOptable.map(ExtUserOptable::getEmail).ifPresent(it -> ids.put(Id.EMAIL, it)); extUserOptable.map(ExtUserOptable::getPhone).ifPresent(it -> ids.put(Id.PHONE, it)); @@ -68,19 +58,15 @@ private void addOptableIds(Map ids, User user) { 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); + final String ip6 = device != null ? device.getIpv6() : null; + + if (ip6 != null) { + ids.put(Id.DEVICE_IP_V_6, ip6); + } if (ifa == null || StringUtils.isEmpty(os) || lmt == 1) { return; 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 index 7f2aad0657d..0828adea34d 100644 --- 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 @@ -1,11 +1,18 @@ package org.prebid.server.hooks.modules.optable.targeting.v1.core; +import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Regs; +import com.iab.openrtb.request.User; import org.apache.commons.collections4.SetUtils; +import org.apache.commons.lang3.StringUtils; import org.prebid.server.auction.gpp.model.GppContext; import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.hooks.modules.optable.targeting.model.App; import org.prebid.server.hooks.modules.optable.targeting.model.OptableAttributes; -import org.prebid.server.privacy.gdpr.model.TcfContext; +import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.ExtUserOptable; +import org.prebid.server.proto.openrtb.ext.request.ExtRegs; +import org.prebid.server.proto.openrtb.ext.request.ExtUser; import java.util.ArrayList; import java.util.List; @@ -16,30 +23,62 @@ public class OptableAttributesResolver { private OptableAttributesResolver() { } - public static OptableAttributes resolveAttributes(AuctionContext auctionContext, Long timeout) { - final TcfContext tcfContext = auctionContext.getPrivacyContext().getTcfContext(); + public static OptableAttributes resolveAttributes(AuctionContext auctionContext, + Long timeout, + double logSamplingRate) { + final GppContext.Scope gppScope = auctionContext.getGppContext().scope(); + final BidRequest bidRequest = auctionContext.getBidRequest(); + final Optional regs = Optional.ofNullable(bidRequest.getRegs()); + final Integer gdpr = regs + .map(Regs::getGdpr) + .orElseGet(() -> regs.map(Regs::getExt) + .map(ExtRegs::getGdpr) + .orElse(null)); + final OptableAttributes.OptableAttributesBuilder builder = OptableAttributes.builder() .ips(resolveIp(auctionContext)) .userAgent(resolveUserAgent(auctionContext)) + .app(resolveApp(auctionContext)) .timeout(timeout); - if (tcfContext.isConsentValid()) { - builder - .gdprApplies(tcfContext.isInGdprScope()) - .gdprConsent(tcfContext.getConsentString()); + if (gdpr != null && gdpr > 0) { + final Optional user = Optional.ofNullable(bidRequest.getUser()); + final String consent = user.map(User::getConsent) + .orElseGet(() -> user.map(User::getExt) + .map(ExtUser::getConsent) + .orElse(null)); + + if (StringUtils.isNotEmpty(consent)) { + builder + .gdprApplies(true) + .gdprConsent(consent); + } } - if (gppScope.getGppModel() != null) { + if (gppScope != null && gppScope.getGppModel() != null) { builder .gpp(gppScope.getGppModel().encode()) .gppSid(SetUtils.emptyIfNull(gppScope.getSectionsIds())); } + Optional.ofNullable(bidRequest.getUser()) + .map(User::getExt) + .map(ext -> ext.getProperty("optable")) + .map(it -> ExtUserOptableResolver.resolveExtUserOptable(it, logSamplingRate)) + .map(ExtUserOptable::getId5Signature) + .filter(StringUtils::isNotBlank) + .ifPresent(builder::id5Signature); + return builder.build(); } + private static App resolveApp(AuctionContext auctionContext) { + final com.iab.openrtb.request.App app = auctionContext.getBidRequest().getApp(); + return app != null ? App.of(app.getBundle(), app.getVer()) : null; + } + public static String resolveUserAgent(AuctionContext auctionContext) { final Device device = auctionContext.getBidRequest().getDevice(); return device != null ? device.getUa() : null; 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 index 0cb16d9c456..b79ca2790f2 100644 --- 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 @@ -29,7 +29,7 @@ public Future getTargeting(OptableTargetingProperties propertie Timeout timeout) { final List ids = idsMapper.toIds(bidRequest, properties.getPpidMapping()); - final Query query = QueryBuilder.build(ids, attributes, properties.getIdPrefixOrder()); + final Query query = QueryBuilder.build(ids, attributes, properties); if (query == null) { return Future.failedFuture("Can't get targeting"); } diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/PropertiesValidator.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/PropertiesValidator.java new file mode 100644 index 00000000000..391590057ff --- /dev/null +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/PropertiesValidator.java @@ -0,0 +1,20 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +import com.iab.openrtb.request.BidRequest; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties; + +public class PropertiesValidator { + + private PropertiesValidator() { + } + + public static boolean isValid(OptableTargetingProperties properties) { + return StringUtils.isNotEmpty(properties.getOrigin()) && StringUtils.isNotEmpty(properties.getTenant()); + } + + public static boolean isTrafficSourceValid(BidRequest bidRequest, OptableTargetingProperties properties) { + return (Boolean.TRUE.equals(properties.getEnrichWeb()) && bidRequest.getSite() != null) + || (Boolean.TRUE.equals(properties.getEnrichApp()) && bidRequest.getApp() != null); + } +} 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 index 613286f4b92..78d362d9fec 100644 --- 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 @@ -5,14 +5,17 @@ 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 java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.function.Function; import java.util.function.Predicate; @@ -26,12 +29,42 @@ public class QueryBuilder { private QueryBuilder() { } - public static Query build(List ids, OptableAttributes optableAttributes, String idPrefixOrder) { + public static Query build(List ids, OptableAttributes optableAttributes, + OptableTargetingProperties properties) { + if (CollectionUtils.isEmpty(ids) && CollectionUtils.isEmpty(optableAttributes.getIps())) { return null; } - return Query.of(buildIdsString(ids, idPrefixOrder), buildAttributesString(optableAttributes)); + final String idPrefixOrder = properties.getIdPrefixOrder(); + final String hidPrefixes = properties.getHidPrefixes(); + return Query.of( + buildIdsString(ids, idPrefixOrder), + buildHidString(ids, hidPrefixes), + buildAttributesString(optableAttributes), + buildHidAttributesString(optableAttributes)); + } + + private static String buildHidString(List ids, String hidPrefixesString) { + if (CollectionUtils.isEmpty(ids) || StringUtils.isEmpty(hidPrefixesString)) { + return StringUtils.EMPTY; + } + final Map prefixToIdValue = ids.stream() + .collect(Collectors.toMap(Id::getName, it -> it, (a, b) -> b)); + + final String hidParameters = Arrays.stream(hidPrefixesString.split(",")) + .map(String::trim) + .filter(StringUtils::isNotEmpty) + .map(prefixToIdValue::get) + .filter(Objects::nonNull) + .map(it -> String.format( + "hid=%s:%s", it.getName(), URLEncoder.encode(it.getValue(), StandardCharsets.UTF_8))) + .collect(Collectors.joining("&")); + if (StringUtils.isEmpty(hidParameters)) { + return StringUtils.EMPTY; + } + + return "&" + hidParameters; } private static String buildIdsString(List ids, String idPrefixOrder) { @@ -39,14 +72,15 @@ private static String buildIdsString(List ids, String idPrefixOrder) { return StringUtils.EMPTY; } - final List reorderedIds = reorderIds(ids, idPrefixOrder); + final List reorderedIds = reorderIds(ids, idPrefixOrder) + .stream() + .filter(id -> !Id.DEVICE_IP_V_6.equals(id.getName())) + .toList(); 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)); + sb.append(URLEncoder.encode(String.format("%s:%s", id.getName(), id.getValue()), StandardCharsets.UTF_8)); } return sb.toString(); @@ -78,7 +112,8 @@ private static String buildAttributesString(OptableAttributes optableAttributes) .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())); + .ifPresent(gppSids -> sb.append("&gpp_sid=").append( + gppSids.stream().limit(2).map(String::valueOf).collect(Collectors.joining(",")))); Optional.ofNullable(optableAttributes.getTimeout()) .ifPresent(timeout -> sb.append("&timeout=").append(timeout).append("ms")); @@ -87,4 +122,31 @@ private static String buildAttributesString(OptableAttributes optableAttributes) return sb.toString(); } + + /** + * Kept apart from buildAttributesString because these are the only attributes that + * discriminate one user from another, so they have to take part in the cache key. + */ + private static String buildHidAttributesString(OptableAttributes optableAttributes) { + final StringBuilder sb = new StringBuilder(); + + Optional.ofNullable(optableAttributes.getApp()) + .ifPresent(app -> { + final String bundle = app.getBundle(); + if (StringUtils.isNotEmpty(bundle)) { + sb.append("&bundle=").append(URLEncoder.encode(bundle, StandardCharsets.UTF_8)); + + final String ver = app.getVer(); + if (StringUtils.isNotEmpty(ver)) { + sb.append("&ver=").append(URLEncoder.encode(ver, StandardCharsets.UTF_8)); + } + } + }); + + Optional.ofNullable(optableAttributes.getId5Signature()) + .ifPresent(id5Signature -> sb.append("&id5_signature=") + .append(URLEncoder.encode(id5Signature, StandardCharsets.UTF_8))); + + return sb.toString(); + } } diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/RequestEnricher.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/RequestEnricher.java new file mode 100644 index 00000000000..f2b9a169a49 --- /dev/null +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/RequestEnricher.java @@ -0,0 +1,194 @@ +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 com.iab.openrtb.request.Uid; +import com.iab.openrtb.request.User; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties; +import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Ortb2; +import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult; + +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 abstract class RequestEnricher { + + private static final String OPTABLE_CO_INSERTER = "optable.co"; + + private final TargetingResult targetingResult; + private final OptableTargetingProperties targetingProperties; + + protected RequestEnricher(TargetingResult targetingResult, OptableTargetingProperties targetingProperties) { + this.targetingResult = targetingResult; + this.targetingProperties = targetingProperties; + } + + protected BidRequest enrichBidRequest(BidRequest bidRequest) { + if (bidRequest == null || targetingResult == null) { + return bidRequest; + } + + final var optableUser = Optional.of(targetingResult) + .map(TargetingResult::getOrtb2) + .map(Ortb2::getUser) + .orElse(null); + + if (optableUser == null) { + return bidRequest; + } + + final User bidRequestUser = Optional.ofNullable(bidRequest.getUser()) + .orElseGet(() -> User.builder().build()); + + return bidRequest.toBuilder() + .user(mergeUserData(bidRequestUser, optableUser)) + .build(); + } + + private User mergeUserData( + User user, + org.prebid.server.hooks.modules.optable.targeting.model.openrtb.User optableUser) { + + return user.toBuilder() + .eids(filterOptableEids(mergeEids(user.getEids(), optableUser.getEids()))) + .data(mergeData(user.getData(), optableUser.getData())) + .build(); + } + + private List mergeEids(List destination, List source) { + if (CollectionUtils.isEmpty(destination)) { + return source; + } + + if (CollectionUtils.isEmpty(source)) { + return destination; + } + + final Map idToSourceEid = source.stream().collect(Collectors.toMap( + RequestEnricher::eidIdExtractor, + Function.identity(), + (a, b) -> b, + HashMap::new)); + + final Set sourceToReplace = targetingProperties.getOptableInserterEidsReplace(); + final Set sourceToMerge = targetingProperties.getOptableInserterEidsMerge() + .stream() + .filter(it -> !sourceToReplace.contains(it)).collect(Collectors.toSet()); + + final List mergedEid = destination.stream() + .map(destinationEid -> idToSourceEid.containsKey(eidIdExtractor(destinationEid)) + && OPTABLE_CO_INSERTER.equals(destinationEid.getInserter()) + ? resolveEidConflict( + destinationEid, + idToSourceEid.get(eidIdExtractor(destinationEid)), + sourceToMerge, + sourceToReplace) + : destinationEid) + .toList(); + + return merge(mergedEid, source, RequestEnricher::eidIdExtractor); + } + + private List filterOptableEids(List eids) { + if (CollectionUtils.isEmpty(eids)) { + return eids; + } + + final Set optableIdsToIgnore = targetingProperties.getOptableInserterEidsIgnore(); + if (CollectionUtils.isEmpty(optableIdsToIgnore)) { + return eids; + } + + return eids.stream() + .filter(eid -> !OPTABLE_CO_INSERTER.equals(eid.getInserter()) + || !optableIdsToIgnore.contains(eid.getSource())) + .toList(); + } + + private static Eid resolveEidConflict(Eid destinationEid, + Eid sourceEid, + Set sourceToMerge, + Set sourceToReplace) { + + final String eidSource = sourceEid.getSource(); + + if (sourceToReplace.contains(eidSource)) { + return sourceEid; + } + if (sourceToMerge.contains(eidSource)) { + return mergeEid(destinationEid, sourceEid); + } + + return destinationEid; + } + + private static Eid mergeEid(Eid destinationEid, Eid sourceEid) { + return destinationEid.toBuilder() + .uids(merge(destinationEid.getUids(), sourceEid.getUids(), Uid::getId)) + .build(); + } + + private static String eidIdExtractor(Eid eid) { + return "%s_%s".formatted(StringUtils.defaultString(eid.getInserter()), 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/TargetingRequestExecutor.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/TargetingRequestExecutor.java new file mode 100644 index 00000000000..8a93df9fbdf --- /dev/null +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/TargetingRequestExecutor.java @@ -0,0 +1,101 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +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.apache.commons.lang3.ObjectUtils; +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.execution.timeout.TimeoutFactory; +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.OptableTargetingModule; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; + +import java.util.Objects; + +public class TargetingRequestExecutor { + + private final OptableTargeting optableTargeting; + private final UserFpdActivityMask userFpdActivityMask; + private final TimeoutFactory timeoutFactory; + private final double logSamplingRate; + + public TargetingRequestExecutor(OptableTargeting optableTargeting, + UserFpdActivityMask userFpdActivityMask, + TimeoutFactory timeoutFactory, + double logSamplingRate) { + + this.optableTargeting = Objects.requireNonNull(optableTargeting); + this.userFpdActivityMask = Objects.requireNonNull(userFpdActivityMask); + this.timeoutFactory = ObjectUtils.requireNonEmpty(timeoutFactory); + this.logSamplingRate = logSamplingRate; + } + + public Future makeRequest(AuctionRequestPayload payload, + AuctionInvocationContext invocationContext, + OptableTargetingProperties properties, + Long apiTimeout) { + + final BidRequest bidRequest = applyActivityRestrictions(payload.bidRequest(), invocationContext); + + final Timeout timeout = apiTimeout == null + ? getHookTimeout(invocationContext) + : timeoutFactory.create(getHookTimeout(invocationContext).remaining() + apiTimeout); + final OptableAttributes attributes = OptableAttributesResolver.resolveAttributes( + invocationContext.auctionContext(), + properties.getTimeout(), + logSamplingRate); + + return optableTargeting.getTargeting(properties, bidRequest, attributes, timeout); + } + + private static Timeout getHookTimeout(AuctionInvocationContext invocationContext) { + return invocationContext.timeout(); + } + + 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(); + } +} 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 index e7e8bc3e452..ada03ab1528 100644 --- 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 @@ -42,7 +42,7 @@ public Future getTargeting(OptableTargetingProperties propertie return cache.get(createCachingKey(tenant, origin, ips, query, true)) .recover(ignore -> apiClient.getTargeting(properties, query, ips, userAgent, timeout) .recover(throwable -> isCircuitBreakerEnabled - ? Future.succeededFuture(new TargetingResult(null, null)) + ? Future.succeededFuture(new TargetingResult(null, null, null)) : Future.failedFuture(throwable)) .compose(result -> cache.put( createCachingKey(tenant, origin, ips, query, false), @@ -53,12 +53,19 @@ public Future getTargeting(OptableTargetingProperties propertie } private String createCachingKey(String tenant, String origin, List ips, Query query, boolean encodeQuery) { - return "%s:%s:%s:%s".formatted( + return "%s:%s:%s:%s:%s:%s".formatted( tenant, origin, ips.getFirst(), encodeQuery ? URLEncoder.encode(query.getIds(), StandardCharsets.UTF_8) - : query.getIds()); + : query.getIds(), + encodeQuery && query.getHid() != null + ? URLEncoder.encode(query.getHid(), StandardCharsets.UTF_8) + : query.getHid(), + encodeQuery && query.getHidAttributes() != null + ? URLEncoder.encode(query.getHidAttributes(), StandardCharsets.UTF_8) + : query.getHidAttributes() + ); } } 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 index a3ab28d098f..77ccb5be730 100644 --- 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 @@ -10,12 +10,14 @@ import com.iab.openrtb.request.Eid; import com.iab.openrtb.request.Geo; import com.iab.openrtb.request.Segment; +import com.iab.openrtb.request.Site; 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.netty.handler.codec.http.HttpResponseStatus; +import io.vertx.core.Future; import io.vertx.core.MultiMap; import io.vertx.core.http.HttpHeaders; import org.apache.commons.io.IOUtils; @@ -40,6 +42,7 @@ 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.settings.model.Account; import org.prebid.server.vertx.httpclient.model.HttpClientResponse; import java.io.IOException; @@ -71,7 +74,31 @@ protected ModuleContext givenModuleContext(List audiences) { return moduleContext; } - protected AuctionContext givenAuctionContext(ActivityInfrastructure activityInfrastructure, Timeout timeout) { + protected ModuleContext givenModuleContext(List audiences, Future optableTargetingCall) { + final ModuleContext moduleContext = givenModuleContext(audiences); + moduleContext.setOptableTargetingCall(optableTargetingCall); + return moduleContext; + } + + protected Eid givenId5Eid(String refValue) { + final ObjectNode uidExt = mapper.createObjectNode(); + uidExt.set("optable", mapper.createObjectNode().set("ref", TextNode.valueOf(refValue))); + return Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) + .build(); + } + + protected ObjectNode givenRefsObject(String refValue, String signature) { + final ObjectNode refs = mapper.createObjectNode(); + refs.set(refValue, mapper.createObjectNode().set("signature", TextNode.valueOf(signature))); + return refs; + } + + protected AuctionContext givenAuctionContext(ActivityInfrastructure activityInfrastructure, + Timeout timeout, + Account account) { final GppModel gppModel = new GppModel(); final TcfContext tcfContext = TcfContext.builder().build(); final GppContext gppContext = new GppContext( @@ -80,6 +107,7 @@ protected AuctionContext givenAuctionContext(ActivityInfrastructure activityInfr return AuctionContext.builder() .bidRequest(givenBidRequest()) + .account(account) .activityInfrastructure(activityInfrastructure) .privacyContext(PrivacyContext.of(Privacy.builder().build(), tcfContext, "8.8.8.8")) .gppContext(gppContext) @@ -87,18 +115,32 @@ protected AuctionContext givenAuctionContext(ActivityInfrastructure activityInfr .build(); } + protected AuctionContext givenAuctionContext(ActivityInfrastructure activityInfrastructure, Timeout timeout) { + return givenAuctionContext(activityInfrastructure, timeout, null); + } + protected BidRequest givenBidRequest() { return givenBidRequestWithUserEids(null); } protected static BidRequest givenBidRequest(UnaryOperator bidRequestCustomizer) { - return bidRequestCustomizer.apply(BidRequest.builder().id("requestId")).build(); + return bidRequestCustomizer.apply(BidRequest.builder().id("requestId").site(Site.builder().build())).build(); } protected BidRequest givenBidRequestWithUserEids(List eids) { return BidRequest.builder() .user(givenUser(eids)) .device(givenDevice()) + .site(Site.builder().build()) + .cur(List.of("USD")) + .build(); + } + + protected BidRequest givenBidRequestWithUser(User user) { + return BidRequest.builder() + .user(user) + .device(givenDevice()) + .site(Site.builder().build()) .cur(List.of("USD")) .build(); } @@ -107,6 +149,7 @@ protected BidRequest givenBidRequestWithUserData(List data) { return BidRequest.builder() .user(givenUserWithData(data)) .device(givenDevice()) + .site(Site.builder().build()) .cur(List.of("USD")) .build(); } @@ -148,17 +191,22 @@ protected TargetingResult givenTargetingResult() { } protected TargetingResult givenTargetingResult(List eids, List data) { + return givenTargetingResult(eids, data, null); + } + + protected TargetingResult givenTargetingResult(List eids, List data, ObjectNode refs) { 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))); + new Ortb2(new org.prebid.server.hooks.modules.optable.targeting.model.openrtb.User(eids, data)), + refs); } protected TargetingResult givenEmptyTargetingResult() { - return new TargetingResult(Collections.emptyList(), new Ortb2(null)); + return new TargetingResult(Collections.emptyList(), new Ortb2(null), null); } protected User givenUser() { @@ -245,6 +293,7 @@ protected OptableTargetingProperties givenOptableTargetingProperties(String key, optableTargetingProperties.setApiKey(key); optableTargetingProperties.setPpidMapping(Map.of("c", "id")); optableTargetingProperties.setAdserverTargeting(true); + optableTargetingProperties.setEnrichWeb(true); optableTargetingProperties.setTimeout(100L); optableTargetingProperties.setCache(cacheProperties); @@ -252,6 +301,10 @@ protected OptableTargetingProperties givenOptableTargetingProperties(String key, } protected Query givenQuery() { - return Query.of("?que", "ry"); + return Query.of("?que", "r", "y", ""); + } + + protected ObjectNode givenAccountConfig(String key, String tenant, String origin, boolean cacheEnabled) { + return mapper.valueToTree(givenOptableTargetingProperties(key, tenant, origin, cacheEnabled)); } } diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java new file mode 100644 index 00000000000..5778ac41289 --- /dev/null +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java @@ -0,0 +1,398 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Eid; +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.hooks.execution.v1.bidder.BidderRequestPayloadImpl; +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.openrtb.TargetingResult; +import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties; +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.analytics.Tags; +import org.prebid.server.hooks.v1.bidder.BidderInvocationContext; +import org.prebid.server.hooks.v1.bidder.BidderRequestPayload; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeoutException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class OptableBidderRequestHookTest extends BaseOptableTest { + + @Mock + private BidderInvocationContext invocationContext; + + @Mock + private BidderRequestPayload bidderRequestPayload; + + private OptableBidderRequestHook target; + + @BeforeEach + public void setUp() { + target = new OptableBidderRequestHook(); + when(bidderRequestPayload.bidRequest()).thenReturn(givenBidRequest()); + when(invocationContext.bidder()).thenReturn("bidder1"); + } + + @Test + public void shouldHaveRightCode() { + // given and when and then + assertThat(target.code()).isEqualTo("optable-targeting-bidder-request-hook"); + } + + @Test + public void shouldReturnNoActionWhenPerBidderEnrichmentIsDisabled() { + // given + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenOptableTargetingProperties(false)); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + + // when + final Future> future = + target.call(bidderRequestPayload, invocationContext); + + // then + assertThat(future.succeeded()).isTrue(); + final InvocationResult result = future.result(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + assertThat(result.moduleContext()).isSameAs(moduleContext); + } + + @Test + public void shouldReturnNoActionWhenPerBidderEnrichmentIsDisabledAndTargetingCallFailed() { + // given + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenOptableTargetingProperties(false)); + moduleContext.setOptableTargetingCall(Future.failedFuture(new RuntimeException("timeout"))); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + // when + final Future> future = + target.call(bidderRequestPayload, invocationContext); + // then + assertThat(future.succeeded()).isTrue(); + final InvocationResult result = future.result(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + assertThat(moduleContext.getId5Signature()).isNull(); + } + + @Test + public void shouldNotSetId5SignatureOnModuleContextWhenPerBidderEnrichmentIsDisabledAndTargetingCallIsPresent() { + // given + final String refValue = "refValue"; + final String signature = "id5Signature"; + final Eid id5Eid = givenId5Eid(refValue); + final ObjectNode refs = givenRefsObject(refValue, signature); + final TargetingResult targetingResult = givenTargetingResult(List.of(id5Eid), null, refs); + + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenOptableTargetingProperties(false)); + moduleContext.setOptableTargetingCall(Future.succeededFuture(targetingResult)); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + + // when + final Future> future = + target.call(bidderRequestPayload, invocationContext); + + // then + assertThat(future.succeeded()).isTrue(); + final InvocationResult result = future.result(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + assertThat(moduleContext.getId5Signature()).isNull(); + } + + @Test + public void shouldNotSetId5SignatureOnModuleContextWhenBidderNotInEnrichmentSetAndTargetingCallIsPresent() { + // given + final String refValue = "refValue"; + final String signature = "id5Signature"; + final Eid id5Eid = givenId5Eid(refValue); + final ObjectNode refs = givenRefsObject(refValue, signature); + final TargetingResult targetingResult = givenTargetingResult(List.of(id5Eid), null, refs); + + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenPropertiesWithPerBidderEnrichmentEnabled()); + moduleContext.setBiddersToEnrich(Set.of("otherBidder")); + moduleContext.setOptableTargetingCall(Future.succeededFuture(targetingResult)); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + + // when + final Future> future = + target.call(bidderRequestPayload, invocationContext); + + // then + assertThat(future.succeeded()).isTrue(); + final InvocationResult result = future.result(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + assertThat(moduleContext.getId5Signature()).isNull(); + } + + @Test + public void shouldReturnNoActionWhenBiddersToEnrichIsEmpty() { + // given + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenPropertiesWithPerBidderEnrichmentEnabled()); + moduleContext.setBiddersToEnrich(Collections.emptySet()); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + + // when + final Future> future = + target.call(bidderRequestPayload, invocationContext); + + // then + assertThat(future.succeeded()).isTrue(); + final InvocationResult result = future.result(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + } + + @Test + public void shouldReturnNoActionWhenBiddersToEnrichIsNull() { + // given + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenPropertiesWithPerBidderEnrichmentEnabled()); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + + // when + final Future> future = + target.call(bidderRequestPayload, invocationContext); + + // then + assertThat(future.succeeded()).isTrue(); + final InvocationResult result = future.result(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + } + + @Test + public void shouldReturnUpdateActionWhenTargetingResultIsAvailable() { + // given + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenPropertiesWithPerBidderEnrichmentEnabled()); + moduleContext.setBiddersToEnrich(Set.of("bidder1")); + moduleContext.setCallTargetingAPITimestamp(System.currentTimeMillis() - 100); + moduleContext.setOptableTargetingCall(Future.succeededFuture(givenTargetingResult())); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + when(invocationContext.bidder()).thenReturn("bidder1"); + + // when + final Future> future = + target.call(bidderRequestPayload, invocationContext); + + // then + assertThat(future.succeeded()).isTrue(); + final InvocationResult result = future.result(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action) + .extracting(InvocationResult::errors).isNull(); + + final BidRequest enrichedRequest = result + .payloadUpdate() + .apply(BidderRequestPayloadImpl.of(givenBidRequest())) + .bidRequest(); + assertThat(enrichedRequest.getUser().getEids().getFirst().getUids().getFirst().getId()) + .isEqualTo("id"); + assertThat(enrichedRequest.getUser().getData().getFirst().getSegment().getFirst().getId()) + .isEqualTo("id"); + + assertAnalyticsTags(result.analyticsTags(), "bidder1", "enriched", "success"); + } + + @Test + public void shouldUpdateModuleContextWithTargetingOnSuccess() { + // given + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenPropertiesWithPerBidderEnrichmentEnabled()); + moduleContext.setBiddersToEnrich(Set.of("bidder1")); + moduleContext.setCallTargetingAPITimestamp(System.currentTimeMillis() - 100); + moduleContext.setOptableTargetingCall(Future.succeededFuture(givenTargetingResult())); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + when(invocationContext.bidder()).thenReturn("bidder1"); + + // when + target.call(bidderRequestPayload, invocationContext); + + // then + assertThat(moduleContext.getTargeting()).isNotNull().isNotEmpty(); + assertThat(moduleContext.getEnrichRequestStatus()).isNotNull() + .extracting(EnrichmentStatus::getStatus) + .extracting(Status::getValue) + .isEqualTo("success"); + } + + @Test + public void shouldSetId5SignatureOnModuleContextWhenTargetingResultHasId5Signature() { + // given + final String refValue = "refValue"; + final String signature = "id5Signature"; + final Eid id5Eid = givenId5Eid(refValue); + final ObjectNode refs = givenRefsObject(refValue, signature); + final TargetingResult targetingResult = givenTargetingResult(List.of(id5Eid), null, refs); + + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenPropertiesWithPerBidderEnrichmentEnabled()); + moduleContext.setBiddersToEnrich(Set.of("bidder1")); + moduleContext.setCallTargetingAPITimestamp(System.currentTimeMillis() - 100); + moduleContext.setOptableTargetingCall(Future.succeededFuture(targetingResult)); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + when(invocationContext.bidder()).thenReturn("bidder1"); + + // when + target.call(bidderRequestPayload, invocationContext); + + // then + assertThat(moduleContext.getId5Signature()).isEqualTo(signature); + } + + @Test + public void shouldNotSetId5SignatureOnModuleContextWhenTargetingResultHasNoId5Signature() { + // given + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenPropertiesWithPerBidderEnrichmentEnabled()); + moduleContext.setBiddersToEnrich(Set.of("bidder1")); + moduleContext.setCallTargetingAPITimestamp(System.currentTimeMillis() - 100); + moduleContext.setOptableTargetingCall(Future.succeededFuture(givenTargetingResult())); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + when(invocationContext.bidder()).thenReturn("bidder1"); + + // when + target.call(bidderRequestPayload, invocationContext); + + // then + assertThat(moduleContext.getId5Signature()).isNull(); + } + + @Test + public void shouldReturnNoActionWithNoDataOutcomeWhenTargetingResultHasNoUser() { + // given + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenPropertiesWithPerBidderEnrichmentEnabled()); + moduleContext.setBiddersToEnrich(Set.of("bidder1")); + moduleContext.setCallTargetingAPITimestamp(System.currentTimeMillis() - 50); + moduleContext.setOptableTargetingCall(Future.succeededFuture(givenEmptyTargetingResult())); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + when(invocationContext.bidder()).thenReturn("bidder1"); + + // when + final Future> future = + target.call(bidderRequestPayload, invocationContext); + + // then + assertThat(future.succeeded()).isTrue(); + final InvocationResult result = future.result(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + + assertThat(moduleContext.getTargeting()).isNull(); + assertAnalyticsTags(result.analyticsTags(), "bidder1", "no-data", "fail"); + } + + @Test + public void shouldReturnNoActionWithErrorOutcomeWhenTargetingCallFails() { + // given + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenPropertiesWithPerBidderEnrichmentEnabled()); + moduleContext.setBiddersToEnrich(Set.of("bidder1")); + moduleContext.setCallTargetingAPITimestamp(System.currentTimeMillis() - 50); + moduleContext.setOptableTargetingCall( + Future.failedFuture(new RuntimeException("targeting service error"))); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + when(invocationContext.bidder()).thenReturn("bidder1"); + + // when + final Future> future = + target.call(bidderRequestPayload, invocationContext); + + // then + assertThat(future.succeeded()).isTrue(); + final InvocationResult result = future.result(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + + assertAnalyticsTags(result.analyticsTags(), "bidder1", "error", "fail"); + } + + @Test + public void shouldReturnNoActionWithTimeoutOutcomeWhenTargetingCallTimesOut() { + // given + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenPropertiesWithPerBidderEnrichmentEnabled()); + moduleContext.setBiddersToEnrich(Set.of("bidder1")); + moduleContext.setCallTargetingAPITimestamp(System.currentTimeMillis() - 500); + moduleContext.setOptableTargetingCall( + Future.failedFuture(new TimeoutException("Timeout has been exceeded"))); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + when(invocationContext.bidder()).thenReturn("bidder1"); + + // when + final Future> future = + target.call(bidderRequestPayload, invocationContext); + + // then + assertThat(future.succeeded()).isTrue(); + final InvocationResult result = future.result(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + + assertAnalyticsTags(result.analyticsTags(), "bidder1", "timeout", "fail"); + } + + private static ModuleContext givenModuleContextWithProperties(OptableTargetingProperties properties) { + final ModuleContext moduleContext = new ModuleContext(); + moduleContext.setOptableTargetingProperties(properties); + return moduleContext; + } + + private OptableTargetingProperties givenPropertiesWithPerBidderEnrichmentEnabled() { + final OptableTargetingProperties properties = givenOptableTargetingProperties(false); + properties.setEnrichmentPercentage(50); + return properties; + } + + private void assertAnalyticsTags(Tags tags, String expectedBidder, String expectedOutcome, String expectedStatus) { + assertThat(tags).isNotNull(); + assertThat(tags.activities()).hasSize(1); + assertThat(tags.activities().getFirst().name()).isEqualTo("optable-enrich-bidder-request"); + assertThat(tags.activities().getFirst().status()).isEqualTo(expectedStatus); + + assertThat(tags.activities().getFirst().results()).hasSize(1); + final JsonNode values = tags.activities().getFirst().results().getFirst().values(); + assertThat(values.get("outcome").asText()).isEqualTo(expectedOutcome); + assertThat(values.has("execution-time")).isTrue(); + + assertThat(tags.activities().getFirst().results().getFirst().appliedTo()).isNotNull(); + assertThat(tags.activities().getFirst().results().getFirst().appliedTo().bidders()) + .containsExactly(expectedBidder); + } +} diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableRawAuctionRequestHookTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableRawAuctionRequestHookTest.java new file mode 100644 index 00000000000..4e1a5b3e5f1 --- /dev/null +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableRawAuctionRequestHookTest.java @@ -0,0 +1,181 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1; + +import com.iab.openrtb.request.BidRequest; +import io.vertx.core.Future; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import lombok.SneakyThrows; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.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.execution.timeout.TimeoutFactory; +import org.prebid.server.hooks.execution.model.ExecutionPlan; +import org.prebid.server.hooks.modules.optable.targeting.model.ModuleContext; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.BidderEnrichmentSampler; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.CompositeHookExecutionPlan; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.ConfigResolver; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.TargetingRequestExecutor; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.OptableTargeting; +import org.prebid.server.hooks.v1.InvocationResult; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; + +import java.util.Set; + +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; + +@MockitoSettings(strictness = Strictness.LENIENT) +@ExtendWith(VertxExtension.class) +public class OptableRawAuctionRequestHookTest extends BaseOptableTest { + + @Mock + private OptableTargeting optableTargeting; + @Mock + private UserFpdActivityMask userFpdActivityMask; + @Mock + private AuctionRequestPayload auctionRequestPayload; + @Mock + private ActivityInfrastructure activityInfrastructure; + @Mock + private AuctionInvocationContext invocationContext; + @Mock + private Timeout timeout; + @Mock + private TimeoutFactory timeoutFactory; + @Mock + private BidderEnrichmentSampler bidderEnrichmentSampler; + + private ConfigResolver configResolver; + private TargetingRequestExecutor targetingRequestExecutor; + private OptableRawAuctionRequestHook target; + + @BeforeEach + public void setUp() { + when(userFpdActivityMask.maskDevice(any(), anyBoolean(), anyBoolean())) + .thenAnswer(answer -> answer.getArgument(0)); + configResolver = new ConfigResolver(mapper, jsonMerger, givenOptableTargetingProperties(false)); + targetingRequestExecutor = new TargetingRequestExecutor( + optableTargeting, userFpdActivityMask, timeoutFactory, 0.01); + target = new OptableRawAuctionRequestHook( + configResolver, targetingRequestExecutor, bidderEnrichmentSampler, + CompositeHookExecutionPlan.of(ExecutionPlan.empty()), 0.01); + 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-raw-auction-request-hook"); + } + + @SneakyThrows + @Test + public void shouldInjectEarlyNetworkCallToModuleContext(VertxTestContext vertxTestContext) { + // given + when(invocationContext.accountConfig()) + .thenReturn(givenAccountConfig("key", "tenant", "origin", true)); + when(auctionRequestPayload.bidRequest()).thenReturn(givenBidRequest()); + when(optableTargeting.getTargeting(any(), any(), any(), any())) + .thenReturn(Future.succeededFuture(givenTargetingResult())); + when(bidderEnrichmentSampler.sample(any(), any())).thenReturn(Set.of("bidder")); + + // when + final Future> result = + target.call(auctionRequestPayload, invocationContext); + + // then + assertThat(result).isNotNull(); + result.map(res -> (ModuleContext) res.moduleContext()) + .compose(ModuleContext::getOptableTargetingCall) + .onComplete(call -> { + vertxTestContext.verify(() -> { + assertThat(call.result()).isNotNull(); + }); + vertxTestContext.completeNow(); + }); + } + + @SneakyThrows + @Test + public void shouldNotInjectEarlyNetworkCallToModuleContextWhenOriginIsAbsentInAccountConfiguration( + VertxTestContext vertxTestContext) { + + // given + when(invocationContext.accountConfig()) + .thenReturn(givenAccountConfig("key", "tenant", null, true)); + when(auctionRequestPayload.bidRequest()).thenReturn(givenBidRequest()); + when(optableTargeting.getTargeting(any(), any(), any(), any())) + .thenReturn(Future.succeededFuture(givenTargetingResult())); + + configResolver = new ConfigResolver( + mapper, jsonMerger, givenOptableTargetingProperties("key", "tenant", null, true)); + target = new OptableRawAuctionRequestHook( + configResolver, targetingRequestExecutor, bidderEnrichmentSampler, + CompositeHookExecutionPlan.of(ExecutionPlan.empty()), 0.01); + + // when + final Future> result = + target.call(auctionRequestPayload, invocationContext); + + // then + assertThat(result).isNotNull(); + result.map(res -> (ModuleContext) res.moduleContext()) + .onComplete(cxt -> { + vertxTestContext.verify(() -> { + final ModuleContext moduleContext = cxt.result(); + assertThat(moduleContext.getOptableTargetingCall()).isNull(); + assertThat(moduleContext.isEarlyNetworkCallEnabled()).isTrue(); + }); + vertxTestContext.completeNow(); + }); + } + + @SneakyThrows + @Test + public void shouldNotInjectEarlyNetworkCallWhenTrafficSourceIsInvalid(VertxTestContext vertxTestContext) { + // given + when(invocationContext.accountConfig()) + .thenReturn(givenAccountConfig("key", "tenant", "origin", true)); + final BidRequest bidRequestWithoutTrafficSource = givenBidRequest(bidRequestCustomizer -> + bidRequestCustomizer.site(null).app(null)); + when(auctionRequestPayload.bidRequest()).thenReturn(bidRequestWithoutTrafficSource); + when(invocationContext.auctionContext()).thenReturn( + givenAuctionContext(activityInfrastructure, timeout) + .toBuilder() + .bidRequest(bidRequestWithoutTrafficSource) + .build()); + + configResolver = new ConfigResolver(mapper, jsonMerger, givenOptableTargetingProperties(false)); + target = new OptableRawAuctionRequestHook( + configResolver, targetingRequestExecutor, bidderEnrichmentSampler, + CompositeHookExecutionPlan.of(ExecutionPlan.empty()), 0.01); + + // when + final Future> result = + target.call(auctionRequestPayload, invocationContext); + + // then + assertThat(result).isNotNull(); + result.map(res -> (ModuleContext) res.moduleContext()) + .onComplete(cxt -> { + vertxTestContext.verify(() -> { + final ModuleContext moduleContext = cxt.result(); + assertThat(moduleContext.isShouldSkipEnrichment()).isTrue(); + assertThat(moduleContext.getOptableTargetingCall()).isNull(); + }); + vertxTestContext.completeNow(); + }); + } +} 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 index af4a809df78..08c1d4d3997 100644 --- 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 @@ -1,5 +1,6 @@ package org.prebid.server.hooks.modules.optable.targeting.v1; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.iab.openrtb.response.BidResponse; import io.vertx.core.Future; @@ -9,12 +10,17 @@ 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.ModuleContext; +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.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.analytics.Activity; +import org.prebid.server.hooks.v1.analytics.Result; +import org.prebid.server.hooks.v1.analytics.Tags; import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; import org.prebid.server.hooks.v1.auction.AuctionResponseHook; import org.prebid.server.hooks.v1.auction.AuctionResponsePayload; @@ -54,9 +60,10 @@ public void shouldHaveCode() { } @Test - public void shouldReturnResultWithNoActionAndWithPBSAnalyticsTags() { + public void shouldReturnResultWithNoActionAndWithPBSAnalyticsTagsWhenTargetingIsEmptyAndNoId5Signature() { // given - when(invocationContext.moduleContext()).thenReturn(givenModuleContext()); + when(invocationContext.moduleContext()).thenReturn( + givenModuleContext(null, Future.failedFuture(new RuntimeException("error")))); // when final Future> future = @@ -67,14 +74,51 @@ public void shouldReturnResultWithNoActionAndWithPBSAnalyticsTags() { 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).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + assertThat(result.payloadUpdate()).isNull(); + assertThat(result.analyticsTags()) + .extracting(Tags::activities) + .extracting(List::getFirst) + .extracting(Activity::results) + .extracting(List::getFirst) + .extracting(Result::values) + .extracting(it -> it.get("reason")) + .isNotNull(); assertThat(result.errors()).isNull(); } + @Test + public void shouldReturnResultWithUpdateActionAndId5SignatureWhenTargetingIsEmptyButId5SignatureIsPresent() { + // given + final String signature = "id5Signature"; + final ModuleContext moduleContext = + givenModuleContext(null, Future.failedFuture(new RuntimeException("error"))); + moduleContext.setId5Signature(signature); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + 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 JsonNode passthrough = bidResponse.getExt().getPrebid().getPassthrough(); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action); + assertThat(passthrough).isNotNull(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo(signature); + } + @Test public void shouldReturnResultWithUpdateActionWhenAdvertiserTargetingOptionIsOn() { // given @@ -83,7 +127,8 @@ public void shouldReturnResultWithUpdateActionWhenAdvertiserTargetingOptionIsOn( "provider", List.of(new AudienceId("audienceId")), "keyspace", - 1)))); + 1)), + Future.succeededFuture(givenEmptyTargetingResult()))); when(auctionResponsePayload.bidResponse()).thenReturn(givenBidResponse()); // when @@ -117,14 +162,241 @@ public void shouldReturnResultWithUpdateActionWhenAdvertiserTargetingOptionIsOn( } @Test - public void shouldReturnResultWithNoActionWhenAdvertiserTargetingOptionIsOff() { + public void shouldEnrichBidResponseWithBothTargetingKeywordsAndId5Signature() { + // given + final String signature = "id5Signature"; + final ModuleContext moduleContext = givenModuleContext(List.of( + new Audience( + "provider", + List.of(new AudienceId("audienceId")), + "keyspace", + 1))); + moduleContext.setId5Signature(signature); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + 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"); + final JsonNode passthrough = bidResponse.getExt().getPrebid().getPassthrough(); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action); + + assertThat(targeting.get("keyspace").asText()).isEqualTo("audienceId"); + assertThat(passthrough).isNotNull(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo(signature); + } + + @Test + public void shouldEnrichBidResponseWithId5SignatureOnlyWhenNoTargeting() { + // given + final String signature = "id5Signature"; + final ModuleContext moduleContext = + givenModuleContext(null, Future.succeededFuture(givenEmptyTargetingResult())); + moduleContext.setId5Signature(signature); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + 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 JsonNode passthrough = bidResponse.getExt().getPrebid().getPassthrough(); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action); + assertThat(passthrough).isNotNull(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo(signature); + } + + @Test + public void shouldReturnUpdateActionWhenTargetingIsPresentEvenIfOptableTargetingCallFails() { + // given + when(invocationContext.moduleContext()).thenReturn(givenModuleContext( + List.of(new Audience( + "provider", + List.of(new AudienceId("audienceId")), + "keyspace", + 1)), + Future.failedFuture(new RuntimeException("error")))); + 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.get("keyspace").asText()).isEqualTo("audienceId"); + } + + @Test + public void shouldReturnNoActionWhenOptableTargetingCallFailsAndTargetingIsEmptyAndNoId5Signature() { + // given + when(invocationContext.moduleContext()).thenReturn( + givenModuleContext(null, Future.failedFuture(new RuntimeException("error")))); + + // 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); + assertThat(result.payloadUpdate()).isNull(); + } + + @Test + public void shouldReturnUpdateActionWithId5SignatureWhenOptableTargetingCallFailsAndTargetingIsEmpty() { + // given + final String signature = "id5Signature"; + final ModuleContext moduleContext = + givenModuleContext(null, Future.failedFuture(new RuntimeException("error"))); + moduleContext.setId5Signature(signature); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + 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 JsonNode passthrough = bidResponse.getExt().getPrebid().getPassthrough(); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action); + assertThat(passthrough).isNotNull(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo(signature); + } + + @Test + public void shouldReturnNoActionWhenTargetingCallFailsAndNoBidsAndNoId5Signature() { + // given + when(invocationContext.moduleContext()).thenReturn(givenModuleContext(List.of( + new Audience( + "provider", + List.of(new AudienceId("audienceId")), + "keyspace", + 1)), + Future.failedFuture(new RuntimeException("error")))); + + // 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); + assertThat(result.payloadUpdate()).isNull(); + } + + @Test + public void shouldReturnUpdateActionWithId5SignatureWhenTargetingCallFailsAndNoBids() { + // given + final String signature = "id5Signature"; + final ModuleContext moduleContext = givenModuleContext(List.of( + new Audience( + "provider", + List.of(new AudienceId("audienceId")), + "keyspace", + 1)), + Future.failedFuture(new RuntimeException("error"))); + moduleContext.setId5Signature(signature); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + final BidResponse bidlessResponse = BidResponse.builder().build(); + when(auctionResponsePayload.bidResponse()).thenReturn(bidlessResponse); + + // when + final Future> future = + target.call(auctionResponsePayload, invocationContext); + final InvocationResult result = future.result(); + final BidResponse bidResponse = result + .payloadUpdate() + .apply(AuctionResponsePayloadImpl.of(bidlessResponse)) + .bidResponse(); + final JsonNode passthrough = bidResponse.getExt().getPrebid().getPassthrough(); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action); + assertThat(passthrough).isNotNull(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo(signature); + } + + @Test + public void shouldReturnNoActionWhenAdserverTargetingIsDisabledAndNoId5Signature() { // given + final OptableTargetingProperties properties = givenOptableTargetingProperties(false); + properties.setAdserverTargeting(false); + configResolver = new ConfigResolver(mapper, jsonMerger, properties); + target = new OptableTargetingAuctionResponseHook(configResolver, mapper, jsonMerger); + when(invocationContext.accountConfig()).thenReturn(mapper.valueToTree(properties)); when(invocationContext.moduleContext()).thenReturn(givenModuleContext(List.of( new Audience( "provider", List.of(new AudienceId("audienceId")), "keyspace", - 1)))); + 1)), + Future.failedFuture(new RuntimeException("error")))); // when final Future> future = @@ -137,6 +409,96 @@ public void shouldReturnResultWithNoActionWhenAdvertiserTargetingOptionIsOff() { assertThat(result).isNotNull() .returns(InvocationStatus.success, InvocationResult::status) .returns(InvocationAction.no_action, InvocationResult::action); + assertThat(result.payloadUpdate()).isNull(); + } + + @Test + public void shouldReturnUpdateActionWithId5SignatureWhenAdserverTargetingIsDisabled() { + // given + final String signature = "id5Signature"; + final OptableTargetingProperties properties = givenOptableTargetingProperties(false); + properties.setAdserverTargeting(false); + configResolver = new ConfigResolver(mapper, jsonMerger, properties); + target = new OptableTargetingAuctionResponseHook(configResolver, mapper, jsonMerger); + when(invocationContext.accountConfig()).thenReturn(mapper.valueToTree(properties)); + final ModuleContext moduleContext = givenModuleContext(List.of( + new Audience( + "provider", + List.of(new AudienceId("audienceId")), + "keyspace", + 1)), + Future.failedFuture(new RuntimeException("error"))); + moduleContext.setId5Signature(signature); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + + // when + final Future> future = + target.call(auctionResponsePayload, invocationContext); + final InvocationResult result = future.result(); + final BidResponse bidResponse = result + .payloadUpdate() + .apply(AuctionResponsePayloadImpl.of(givenBidResponse())) + .bidResponse(); + final JsonNode passthrough = bidResponse.getExt().getPrebid().getPassthrough(); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action); + assertThat(passthrough).isNotNull(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo(signature); + } + + @Test + public void shouldReturnNoActionWhenSkipEnrichmentIsTrueAndNoId5Signature() { + // given + final ModuleContext moduleContext = givenModuleContext(); + moduleContext.setShouldSkipEnrichment(true); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + + // 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); + assertThat(result.payloadUpdate()).isNull(); + } + + @Test + public void shouldReturnUpdateActionWithId5SignatureWhenSkipEnrichmentIsTrue() { + // given + final String signature = "id5Signature"; + final ModuleContext moduleContext = givenModuleContext(); + moduleContext.setShouldSkipEnrichment(true); + moduleContext.setId5Signature(signature); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + + // when + final Future> future = + target.call(auctionResponsePayload, invocationContext); + final InvocationResult result = future.result(); + final BidResponse bidResponse = result + .payloadUpdate() + .apply(AuctionResponsePayloadImpl.of(givenBidResponse())) + .bidResponse(); + final JsonNode passthrough = bidResponse.getExt().getPrebid().getPassthrough(); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action); + assertThat(passthrough).isNotNull(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo(signature); } private ObjectNode givenAccountConfig(boolean 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 index 008262b8a3e..c804be0b508 100644 --- 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 @@ -2,27 +2,43 @@ 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 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.execution.timeout.TimeoutFactory; +import org.prebid.server.hooks.execution.model.EndpointExecutionPlan; +import org.prebid.server.hooks.execution.model.ExecutionGroup; +import org.prebid.server.hooks.execution.model.ExecutionPlan; +import org.prebid.server.hooks.execution.model.HookHttpEndpoint; +import org.prebid.server.hooks.execution.model.HookId; +import org.prebid.server.hooks.execution.model.Stage; +import org.prebid.server.hooks.execution.model.StageExecutionPlan; import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl; 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.v1.core.CompositeHookExecutionPlan; import org.prebid.server.hooks.modules.optable.targeting.v1.core.ConfigResolver; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.TargetingRequestExecutor; 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 org.prebid.server.settings.model.Account; + +import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -30,57 +46,61 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -@MockitoSettings(strictness = Strictness.LENIENT) -public class OptableTargetingProcessedAuctionRequestHookTest extends BaseOptableTest { +class OptableTargetingProcessedAuctionRequestHookTest extends BaseOptableTest { private ConfigResolver configResolver; @Mock private OptableTargeting optableTargeting; - @Mock + @Mock(strictness = Mock.Strictness.LENIENT) private UserFpdActivityMask userFpdActivityMask; - private OptableTargetingProcessedAuctionRequestHook target; - @Mock private AuctionRequestPayload auctionRequestPayload; - @Mock + @Mock(strictness = Mock.Strictness.LENIENT) private AuctionInvocationContext invocationContext; - @Mock + @Mock(strictness = Mock.Strictness.LENIENT) private ActivityInfrastructure activityInfrastructure; - @Mock + @Mock(strictness = Mock.Strictness.LENIENT) private Timeout timeout; + @Mock(strictness = Mock.Strictness.LENIENT) + private TimeoutFactory timeoutFactory; + + private TargetingRequestExecutor targetingRequestExecutor; + + private OptableTargetingProcessedAuctionRequestHook target; + @BeforeEach - public void setUp() { + void setUp() { when(userFpdActivityMask.maskDevice(any(), anyBoolean(), anyBoolean())) .thenAnswer(answer -> answer.getArgument(0)); configResolver = new ConfigResolver(mapper, jsonMerger, givenOptableTargetingProperties(false)); + targetingRequestExecutor = new TargetingRequestExecutor( + optableTargeting, userFpdActivityMask, timeoutFactory, 0.01); target = new OptableTargetingProcessedAuctionRequestHook( - configResolver, - optableTargeting, - userFpdActivityMask, - 0.01); + configResolver, targetingRequestExecutor, CompositeHookExecutionPlan.of(ExecutionPlan.empty()), 0.01); when(invocationContext.accountConfig()).thenReturn(givenAccountConfig(true)); - when(invocationContext.auctionContext()).thenReturn(givenAuctionContext(activityInfrastructure, timeout)); + when(invocationContext.auctionContext()).thenReturn( + givenAuctionContext(activityInfrastructure, timeout, Account.builder().id("accountId").build())); when(invocationContext.timeout()).thenReturn(timeout); when(activityInfrastructure.isAllowed(any(), any())).thenReturn(true); when(timeout.remaining()).thenReturn(1000L); } @Test - public void shouldHaveRightCode() { + void codeShouldReturnRightCode() { // when and then assertThat(target.code()).isEqualTo("optable-targeting-processed-auction-request-hook"); } @Test - public void shouldReturnResultWithPBSAnalyticsTags() { + void callShouldReturnResultWithPBSAnalyticsTags() { // given when(auctionRequestPayload.bidRequest()).thenReturn(givenBidRequest()); when(optableTargeting.getTargeting(any(), any(), any(), any())) @@ -95,16 +115,16 @@ public void shouldReturnResultWithPBSAnalyticsTags() { 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).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action) + .extracting(InvocationResult::errors).isNull(); assertThat(result.analyticsTags().activities().getFirst() .results().getFirst().values().get("execution-time")).isNotNull(); } @Test - public void shouldReturnResultWithUpdateActionWhenOptableTargetingReturnTargeting() { + void callShouldReturnResultWithUpdateActionWhenOptableTargetingReturnsTargeting() { // given when(auctionRequestPayload.bidRequest()).thenReturn(givenBidRequest()); when(optableTargeting.getTargeting(any(), any(), any(), any())) @@ -119,30 +139,233 @@ public void shouldReturnResultWithUpdateActionWhenOptableTargetingReturnTargetin 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).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action) + .extracting(InvocationResult::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"); + assertThat(bidRequest.getUser().getEids()) + .flatExtracting(Eid::getUids) + .extracting(Uid::getId) + .containsExactly("id"); + assertThat(bidRequest.getUser().getData()) + .flatExtracting(Data::getSegment) + .extracting(Segment::getId) + .containsExactly("id"); + } + + @Test + void callShouldSetId5SignatureOnModuleContextWhenTargetingResultContainsId5Signature() { + // given + final String refValue = "refValue"; + final String signature = "id5Signature"; + final Eid id5Eid = givenId5Eid(refValue); + final ObjectNode refs = givenRefsObject(refValue, signature); + when(auctionRequestPayload.bidRequest()).thenReturn(givenBidRequest()); + when(optableTargeting.getTargeting(any(), any(), any(), any())) + .thenReturn(Future.succeededFuture(givenTargetingResult(List.of(id5Eid), null, refs))); + + // when + final Future> future = target.call(auctionRequestPayload, + invocationContext); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat((ModuleContext) future.result().moduleContext()) + .isNotNull() + .extracting(ModuleContext::getId5Signature) + .isEqualTo(signature); } @Test - public void shouldReturnFailWhenOriginIsAbsentInAccountConfiguration() { + void callShouldLeaveId5SignatureNullWhenTargetingResultHasNoId5Signature() { + // 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(); + assertThat((ModuleContext) future.result().moduleContext()) + .isNotNull() + .extracting(ModuleContext::getId5Signature) + .isNull(); + } + + @Test + void callShouldReturnResultWithUpdateActionWhenEarlyOptableCallIsEnabled() { + // given + final ModuleContext moduleContext = new ModuleContext(); + target = new OptableTargetingProcessedAuctionRequestHook( + configResolver, + targetingRequestExecutor, + CompositeHookExecutionPlan.of(givenExecutionPlan(true, false)), + 0.01); + when(optableTargeting.getTargeting(any(), any(), any(), any())) + .thenReturn(Future.succeededFuture(givenTargetingResult())); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + when(auctionRequestPayload.bidRequest()).thenReturn(givenBidRequest()); + moduleContext.setOptableTargetingCall( + targetingRequestExecutor.makeRequest( + auctionRequestPayload, + invocationContext, + givenOptableTargetingProperties("key", "tenant", "origin", false), + 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() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action) + .extracting(InvocationResult::errors).isNull(); + final BidRequest bidRequest = result + .payloadUpdate() + .apply(AuctionRequestPayloadImpl.of(givenBidRequest())) + .bidRequest(); + assertThat(bidRequest.getUser().getEids()) + .flatExtracting(Eid::getUids) + .extracting(Uid::getId) + .containsExactly("id"); + assertThat(bidRequest.getUser().getData()) + .flatExtracting(Data::getSegment) + .extracting(Segment::getId) + .containsExactly("id"); + } + + @Test + void callShouldReturnResultWithEnrichedBidRequestWhenBothHooksAreAbsent() { + // given + target = new OptableTargetingProcessedAuctionRequestHook( + configResolver, + targetingRequestExecutor, + CompositeHookExecutionPlan.of(givenExecutionPlan(false, false)), + 0.01); + 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() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action) + .extracting(InvocationResult::errors).isNull(); + final BidRequest bidRequest = result + .payloadUpdate() + .apply(AuctionRequestPayloadImpl.of(givenBidRequest())) + .bidRequest(); + assertThat(bidRequest.getUser().getEids()) + .flatExtracting(Eid::getUids) + .extracting(Uid::getId) + .containsExactly("id"); + assertThat(bidRequest.getUser().getData()) + .flatExtracting(Data::getSegment) + .extracting(Segment::getId) + .containsExactly("id"); + } + + @Test + void callShouldReturnResultWithEnrichedBidRequestWhenOnlyBidderRequestHookIsPresent() { + // given + target = new OptableTargetingProcessedAuctionRequestHook( + configResolver, + targetingRequestExecutor, + CompositeHookExecutionPlan.of(givenExecutionPlan(false, true)), + 0.01); + 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() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action) + .extracting(InvocationResult::errors).isNull(); + final BidRequest bidRequest = result + .payloadUpdate() + .apply(AuctionRequestPayloadImpl.of(givenBidRequest())) + .bidRequest(); + assertThat(bidRequest.getUser().getEids()) + .flatExtracting(Eid::getUids) + .extracting(Uid::getId) + .containsExactly("id"); + assertThat(bidRequest.getUser().getData()) + .flatExtracting(Data::getSegment) + .extracting(Segment::getId) + .containsExactly("id"); + } + + @Test + void callShouldReturnResultWithoutEnrichedBidRequestWhenBothHooksArePresent() { + // given + target = new OptableTargetingProcessedAuctionRequestHook( + configResolver, + targetingRequestExecutor, + CompositeHookExecutionPlan.of(givenExecutionPlan(true, true)), + 0.01); + when(invocationContext.moduleContext()).thenReturn(new ModuleContext()); + + // when + final Future> future = target.call(auctionRequestPayload, + invocationContext); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + + final InvocationResult result = future.result(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action) + .extracting(InvocationResult::errors).isNull(); + final BidRequest bidRequest = result + .payloadUpdate() + .apply(AuctionRequestPayloadImpl.of(givenBidRequest())) + .bidRequest(); + assertThat(bidRequest.getUser().getEids()).isNull(); + assertThat(bidRequest.getUser().getData()).isNull(); + } + + @Test + void callShouldReturnFailWhenOriginIsAbsentInAccountConfiguration() { // given configResolver = new ConfigResolver( mapper, jsonMerger, givenOptableTargetingProperties("key", "tenant", null, false)); target = new OptableTargetingProcessedAuctionRequestHook( - configResolver, - optableTargeting, - userFpdActivityMask, - 0.01); + configResolver, targetingRequestExecutor, CompositeHookExecutionPlan.of(ExecutionPlan.empty()), 0.01); when(invocationContext.accountConfig()) .thenReturn(givenAccountConfig("key", "tenant", null, true)); @@ -155,26 +378,23 @@ public void shouldReturnFailWhenOriginIsAbsentInAccountConfiguration() { 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).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action); assertThat((ModuleContext) result.moduleContext()) .extracting(it -> it.getEnrichRequestStatus().getStatus()) .isEqualTo(Status.FAIL); } @Test - public void shouldReturnFailWhenTenantIsAbsentInAccountConfiguration() { + void callShouldReturnFailWhenTenantIsAbsentInAccountConfiguration() { // given configResolver = new ConfigResolver( mapper, jsonMerger, givenOptableTargetingProperties("key", null, "origin", false)); target = new OptableTargetingProcessedAuctionRequestHook( - configResolver, - optableTargeting, - userFpdActivityMask, - 0.01); + configResolver, targetingRequestExecutor, CompositeHookExecutionPlan.of(ExecutionPlan.empty()), 0.01); when(invocationContext.accountConfig()) .thenReturn(givenAccountConfig("key", null, null, true)); @@ -187,18 +407,17 @@ public void shouldReturnFailWhenTenantIsAbsentInAccountConfiguration() { 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).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action); assertThat((ModuleContext) result.moduleContext()) .extracting(it -> it.getEnrichRequestStatus().getStatus()) .isEqualTo(Status.FAIL); } @Test - public void shouldReturnResultWithCleanedUpUserExtOptableTag() { + void callShouldReturnResultWithCleanedUpUserExtOptableTag() { // given - when(invocationContext.timeout()).thenReturn(timeout); when(auctionRequestPayload.bidRequest()).thenReturn(givenBidRequest()); when(optableTargeting.getTargeting(any(), any(), any(), any())) .thenReturn(Future.succeededFuture(givenTargetingResult())); @@ -212,10 +431,10 @@ public void shouldReturnResultWithCleanedUpUserExtOptableTag() { 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).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action) + .extracting(InvocationResult::errors).isNull(); final ObjectNode optable = (ObjectNode) result .payloadUpdate() .apply(AuctionRequestPayloadImpl.of(givenBidRequest())) @@ -226,7 +445,7 @@ public void shouldReturnResultWithCleanedUpUserExtOptableTag() { } @Test - public void shouldReturnResultWithUpdateWhenOptableTargetingDoesntReturnResult() { + void callShouldReturnResultWithUpdateWhenOptableTargetingDoesNotReturnResult() { // given when(auctionRequestPayload.bidRequest()).thenReturn(givenBidRequest()); when(optableTargeting.getTargeting(any(), any(), any(), any())).thenReturn(Future.succeededFuture(null)); @@ -240,17 +459,54 @@ public void shouldReturnResultWithUpdateWhenOptableTargetingDoesntReturnResult() 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).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action) + .extracting(InvocationResult::errors).isNull(); + } + + @Test + void callShouldReturnUpdateWhenTrafficSourceIsInvalid() { + // given + final ModuleContext moduleContext = new ModuleContext(); + moduleContext.setShouldSkipEnrichment(true); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + + // when + final Future> future = target.call(auctionRequestPayload, + invocationContext); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + + final InvocationResult result = future.result(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action) + .extracting(InvocationResult::errors).isNull(); } private ObjectNode givenAccountConfig(boolean cacheEnabled) { return givenAccountConfig("key", "tenant", "origin", cacheEnabled); } - private ObjectNode givenAccountConfig(String key, String tenant, String origin, boolean cacheEnabled) { - return mapper.valueToTree(givenOptableTargetingProperties(key, tenant, origin, cacheEnabled)); + private ExecutionPlan givenExecutionPlan(boolean hasRawAuctionRequestHook, boolean hasBidderRequestHook) { + final HookId rawAuctionHook = HookId.of("optable-targeting", "optable-targeting-raw-auction-request-hook"); + final HookId bidderRequestHook = HookId.of("optable-targeting", "optable-targeting-bidder-request-hook"); + + final StageExecutionPlan rawAuctionStage = StageExecutionPlan.of(List.of( + ExecutionGroup.of(null, hasRawAuctionRequestHook ? List.of(rawAuctionHook) : List.of()) + )); + final StageExecutionPlan bidderRequestStage = StageExecutionPlan.of(List.of( + ExecutionGroup.of(null, hasBidderRequestHook ? List.of(bidderRequestHook) : List.of()) + )); + + final EndpointExecutionPlan endpointExecutionPlan = EndpointExecutionPlan.of(Map.of( + Stage.raw_auction_request, rawAuctionStage, + Stage.bidder_request, bidderRequestStage + )); + + return ExecutionPlan.of(null, Map.of(HookHttpEndpoint.POST_AUCTION, endpointExecutionPlan)); } } diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/AliasesResolverTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/AliasesResolverTest.java new file mode 100644 index 00000000000..b8b7c986636 --- /dev/null +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/AliasesResolverTest.java @@ -0,0 +1,88 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +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.aliases.BidderAliases; +import org.prebid.server.bidder.BidderCatalog; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +public class AliasesResolverTest { + + @Mock + private BidderCatalog bidderCatalog; + + private AliasesResolver target; + + @BeforeEach + public void setUp() { + target = AliasesResolver.of(bidderCatalog); + } + + @Test + public void resolveShouldReturnEmptyBidderAliasesWhenBidRequestIsNull() { + // when + final BidderAliases result = target.resolve(null); + + // then + assertThat(result.isAliasDefined("anyAlias")).isFalse(); + } + + @Test + public void resolveShouldReturnEmptyBidderAliasesWhenBidRequestHasNoExt() { + // given + final BidRequest bidRequest = BidRequest.builder().build(); + + // when + final BidderAliases result = target.resolve(bidRequest); + + // then + assertThat(result.isAliasDefined("anyAlias")).isFalse(); + } + + @Test + public void resolveShouldReturnEmptyBidderAliasesWhenBidRequestHasNoExtPrebid() { + // given + final BidRequest bidRequest = BidRequest.builder() + .ext(ExtRequest.empty()) + .build(); + + // when + final BidderAliases result = target.resolve(bidRequest); + + // then + assertThat(result.isAliasDefined("anyAlias")).isFalse(); + } + + @Test + public void resolveShouldReturnBidderAliasesWithValuesWhenBidRequestHasAliases() { + // given + final BidRequest bidRequest = BidRequest.builder() + .ext(ExtRequest.of(ExtRequestPrebid.builder() + .aliases(Map.of("alias", "bidder")) + .aliasgvlids(Map.of("alias", 123)) + .build())) + .build(); + + given(bidderCatalog.isValidName(anyString())).willReturn(false); + + // when + final BidderAliases result = target.resolve(bidRequest); + + // then + assertThat(result.isAliasDefined("alias")).isTrue(); + assertThat(result.resolveBidder("alias")).isEqualTo("bidder"); + assertThat(result.resolveAliasVendorId("alias")).isEqualTo(123); + } +} 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 index b6d72748fbd..606c89ee102 100644 --- 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 @@ -27,4 +27,31 @@ public void shouldRemoveUserExtOptableTag() { .extracting(it -> it.getProperty("optable")) .isEqualTo(null); } + + @Test + public void shouldKeepOtherUserExtOptableTags() { + // given + final User user = givenUser(); + ((com.fasterxml.jackson.databind.node.ObjectNode) user.getExt().getProperty("optable")) + .put("other", "value") + .put("id5_signature", "signature"); + + final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(givenBidRequest(bidRequest -> + bidRequest.user(user))); + + // when + final AuctionRequestPayload result = BidRequestCleaner.instance().apply(auctionRequestPayload); + + // then + assertThat(result).extracting(AuctionRequestPayload::bidRequest) + .extracting(BidRequest::getUser) + .extracting(User::getExt) + .extracting(it -> (com.fasterxml.jackson.databind.node.ObjectNode) it.getProperty("optable")) + .isNotNull() + .satisfies(optable -> { + assertThat(optable.has("other")).isTrue(); + assertThat(optable.has("email")).isFalse(); + assertThat(optable.has("id5_signature")).isFalse(); + }); + } } diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidderEnrichmentSamplerTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidderEnrichmentSamplerTest.java new file mode 100644 index 00000000000..914079178e7 --- /dev/null +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidderEnrichmentSamplerTest.java @@ -0,0 +1,266 @@ +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.Imp; +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.aliases.BidderAliases; +import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties; +import org.prebid.server.hooks.modules.optable.targeting.v1.BaseOptableTest; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.IntSupplier; +import java.util.function.UnaryOperator; + +import static java.util.function.UnaryOperator.identity; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +public class BidderEnrichmentSamplerTest extends BaseOptableTest { + + @Mock + private AliasesResolver aliasesResolver; + + @Mock + private BidderAliases bidderAliases; + + @Mock + private IntSupplier randomSupplier; + + private BidderEnrichmentSampler target; + + @BeforeEach + public void setUp() { + target = BidderEnrichmentSampler.of(aliasesResolver, randomSupplier); + given(aliasesResolver.resolve(any())).willReturn(bidderAliases); + } + + @Test + public void sampleShouldReturnEmptySetWhenRequestHasNoImpressions() { + // given + final BidRequest bidRequest = givenBidRequest(identity()); + + // when + final Set result = target.sample(bidRequest, givenSampleProperties(100, Collections.emptyMap())); + + // then + assertThat(result).isEmpty(); + } + + @Test + public void sampleShouldReturnEmptySetWhenImpHasNoExt() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.imp(List.of(givenImp(identity())))); + + // when + final Set result = target.sample(bidRequest, givenSampleProperties(100, Collections.emptyMap())); + + // then + assertThat(result).isEmpty(); + } + + @Test + public void sampleShouldReturnEmptySetWhenImpExtHasNoPrebidBidderNode() { + // given + final ObjectNode ext = mapper.createObjectNode(); + final BidRequest bidRequest = givenBidRequest( + request -> request.imp(List.of(givenImp(imp -> imp.ext(ext))))); + + // when + final Set result = target.sample(bidRequest, givenSampleProperties(100, Collections.emptyMap())); + + // then + assertThat(result).isEmpty(); + } + + @Test + public void sampleShouldReturnEmptySetWhenBidderNodeIsEmpty() { + // given + final BidRequest bidRequest = givenBidRequest( + request -> request.imp(List.of(givenImp(imp -> imp.ext(givenBidderExt()))))); + + // when + final Set result = target.sample(bidRequest, givenSampleProperties(100, Collections.emptyMap())); + + // then + assertThat(result).isEmpty(); + } + + @Test + public void sampleShouldIncludeAllBiddersWhenDefaultPercentageIs100() { + // given + given(bidderAliases.resolveBidder(any())).willAnswer(inv -> inv.getArgument(0)); + given(randomSupplier.getAsInt()).willReturn(99); + + final BidRequest bidRequest = givenBidRequest( + request -> request.imp(List.of(givenImp(imp -> imp.ext(givenBidderExt("bidderA", "bidderB")))))); + + // when + final Set result = target.sample(bidRequest, givenSampleProperties(100, Collections.emptyMap())); + + // then + assertThat(result).containsExactlyInAnyOrder("bidderA", "bidderB"); + } + + @Test + public void sampleShouldExcludeAllBiddersWhenDefaultPercentageIsNegative() { + // given + given(bidderAliases.resolveBidder(any())).willAnswer(inv -> inv.getArgument(0)); + + final BidRequest bidRequest = givenBidRequest( + request -> request.imp(List.of(givenImp(imp -> imp.ext(givenBidderExt("bidderA", "bidderB")))))); + + // when + final Set result = target.sample(bidRequest, givenSampleProperties(-1, Collections.emptyMap())); + + // then + assertThat(result).isEmpty(); + } + + @Test + public void sampleShouldExcludeBidderWhenRandomValueEqualsPercentage() { + // given + given(bidderAliases.resolveBidder(any())).willAnswer(inv -> inv.getArgument(0)); + given(randomSupplier.getAsInt()).willReturn(50); + + final BidRequest bidRequest = givenBidRequest( + request -> request.imp(List.of(givenImp(imp -> imp.ext(givenBidderExt("bidderA")))))); + + // when + final Set result = target.sample(bidRequest, givenSampleProperties(50, Collections.emptyMap())); + + // then + assertThat(result).isEmpty(); + } + + @Test + public void sampleShouldIncludeBidderWhenRandomValueIsBelowPercentage() { + // given + given(bidderAliases.resolveBidder(any())).willAnswer(inv -> inv.getArgument(0)); + given(randomSupplier.getAsInt()).willReturn(49); + + final BidRequest bidRequest = givenBidRequest( + request -> request.imp(List.of(givenImp(imp -> imp.ext(givenBidderExt("bidderA")))))); + + // when + final Set result = target.sample(bidRequest, givenSampleProperties(50, Collections.emptyMap())); + + // then + assertThat(result).containsExactly("bidderA"); + } + + @Test + public void sampleShouldExcludeBidderWhenRandomValueExceedsPercentage() { + // given + given(bidderAliases.resolveBidder(any())).willAnswer(inv -> inv.getArgument(0)); + given(randomSupplier.getAsInt()).willReturn(51); + + final BidRequest bidRequest = givenBidRequest( + request -> request.imp(List.of(givenImp(imp -> imp.ext(givenBidderExt("bidderA")))))); + + // when + final Set result = target.sample(bidRequest, givenSampleProperties(50, Collections.emptyMap())); + + // then + assertThat(result).isEmpty(); + } + + @Test + public void sampleShouldExcludeBidderWhenPercentageIsZeroAndRandomIsZero() { + // given + given(bidderAliases.resolveBidder(any())).willAnswer(inv -> inv.getArgument(0)); + + final BidRequest bidRequest = givenBidRequest( + request -> request.imp(List.of(givenImp(imp -> imp.ext(givenBidderExt("bidderA")))))); + + // when + final Set result = target.sample(bidRequest, givenSampleProperties(0, Collections.emptyMap())); + + // then + assertThat(result).isEmpty(); + } + + @Test + public void sampleShouldUseBidderSpecificPercentageWhenAvailable() { + // given + given(bidderAliases.resolveBidder(any())).willAnswer(inv -> inv.getArgument(0)); + given(randomSupplier.getAsInt()).willReturn(99); + + final BidRequest bidRequest = givenBidRequest( + request -> request.imp(List.of(givenImp(imp -> imp.ext(givenBidderExt("bidderA", "bidderB")))))); + + // when + final Set result = target.sample(bidRequest, givenSampleProperties(-1, Map.of("bidderA", 100))); + + // then + assertThat(result).containsExactly("bidderA"); + } + + @Test + public void sampleShouldUseAliasSpecificPercentageWhenBidderResolvesToAlias() { + // given + given(bidderAliases.resolveBidder("bidderA")).willReturn("bidderA"); + given(bidderAliases.resolveBidder("bidderB")).willReturn("aliasB"); + given(randomSupplier.getAsInt()).willReturn(99); + + final BidRequest bidRequest = givenBidRequest( + request -> request.imp(List.of(givenImp(imp -> imp.ext(givenBidderExt("bidderA", "bidderB")))))); + + // when + final Set result = target.sample(bidRequest, givenSampleProperties(-1, Map.of("aliasB", 100))); + + // then + assertThat(result).containsExactly("bidderB"); + } + + @Test + public void sampleShouldDeduplicateBiddersAppearingInMultipleImps() { + // given + given(bidderAliases.resolveBidder(any())).willAnswer(inv -> inv.getArgument(0)); + given(randomSupplier.getAsInt()).willReturn(0); + + final ObjectNode ext = givenBidderExt("bidderA"); + final BidRequest bidRequest = givenBidRequest(request -> request.imp(List.of( + givenImp(imp -> imp.ext(ext)), + givenImp(imp -> imp.ext(ext))))); + + // when + final Set result = target.sample(bidRequest, givenSampleProperties(100, Collections.emptyMap())); + + // then + assertThat(result).containsExactly("bidderA"); + } + + private OptableTargetingProperties givenSampleProperties(int defaultPct, Map bidderPcts) { + final OptableTargetingProperties props = new OptableTargetingProperties(); + props.setEnrichmentPercentage(defaultPct); + props.setBidderEnrichmentPercentages(bidderPcts); + return props; + } + + private ObjectNode givenBidderExt(String... bidders) { + final ObjectNode bidderNode = mapper.createObjectNode(); + for (String bidder : bidders) { + bidderNode.put(bidder, "value"); + } + final ObjectNode prebidNode = mapper.createObjectNode(); + prebidNode.set("bidder", bidderNode); + final ObjectNode ext = mapper.createObjectNode(); + ext.set("prebid", prebidNode); + return ext; + } + + private static Imp givenImp(UnaryOperator impCustomizer) { + return impCustomizer.apply(Imp.builder()).build(); + } +} 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 index 1917fd6ca27..e0a723bbfa3 100644 --- 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 @@ -109,6 +109,7 @@ private TargetingResult givenTargetingResult() { List.of(new AudienceId("1")), "keyspace", 0)), - new Ortb2(new User(null, null))); + new Ortb2(new User(null, null)), + null); } } diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/CompositeHookExecutionPlanTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/CompositeHookExecutionPlanTest.java new file mode 100644 index 00000000000..c836de388a9 --- /dev/null +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/CompositeHookExecutionPlanTest.java @@ -0,0 +1,360 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +import org.junit.jupiter.api.Test; +import org.prebid.server.hooks.execution.model.EndpointExecutionPlan; +import org.prebid.server.hooks.execution.model.ExecutionGroup; +import org.prebid.server.hooks.execution.model.ExecutionPlan; +import org.prebid.server.hooks.execution.model.HookHttpEndpoint; +import org.prebid.server.hooks.execution.model.HookId; +import org.prebid.server.hooks.execution.model.Stage; +import org.prebid.server.hooks.execution.model.StageExecutionPlan; +import org.prebid.server.settings.model.Account; +import org.prebid.server.settings.model.AccountHooksConfiguration; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CompositeHookExecutionPlanTest { + + @Test + public void hasRawAuctionRequestHookShouldReturnTrueWhenGlobalPlanHasHook() { + // given + final ExecutionPlan globalPlan = givenExecutionPlan( + "raw_auction_request", "optable-targeting-raw-auction-request-hook"); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(globalPlan); + final Account account = Account.builder().id("accountId").build(); + + // when and then + assertThat(target.hasRawAuctionRequestHook(account)).isTrue(); + } + + @Test + public void hasRawAuctionRequestHookShouldReturnTrueWhenAccountPlanHasHook() { + // given + final ExecutionPlan accountPlan = givenExecutionPlan( + "raw_auction_request", "optable-targeting-raw-auction-request-hook"); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(null); + final Account account = givenAccount("accountId", accountPlan); + + // when and then + assertThat(target.hasRawAuctionRequestHook(account)).isTrue(); + } + + @Test + public void hasRawAuctionRequestHookShouldReturnTrueWhenBothPlansHaveHook() { + // given + final ExecutionPlan globalPlan = givenExecutionPlan( + "raw_auction_request", "optable-targeting-raw-auction-request-hook"); + final ExecutionPlan accountPlan = givenExecutionPlan( + "raw_auction_request", "optable-targeting-raw-auction-request-hook"); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(globalPlan); + final Account account = givenAccount("accountId", accountPlan); + + // when and then + assertThat(target.hasRawAuctionRequestHook(account)).isTrue(); + } + + @Test + public void hasRawAuctionRequestHookShouldReturnFalseWhenNeitherPlanHasHook() { + // given + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(null); + final Account account = Account.builder().id("accountId").build(); + + // when and then + assertThat(target.hasRawAuctionRequestHook(account)).isFalse(); + } + + @Test + public void hasRawAuctionRequestHookShouldReturnFalseWhenAccountIsNull() { + // given + final ExecutionPlan globalPlan = givenExecutionPlan( + "raw_auction_request", "optable-targeting-raw-auction-request-hook"); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(globalPlan); + + // when and then + assertThat(target.hasRawAuctionRequestHook(null)).isFalse(); + } + + @Test + public void hasRawAuctionRequestHookShouldReturnFalseWhenAccountIdIsEmpty() { + // given + final ExecutionPlan globalPlan = givenExecutionPlan( + "raw_auction_request", "optable-targeting-raw-auction-request-hook"); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(globalPlan); + final Account account = Account.builder().id("").build(); + + // when and then + assertThat(target.hasRawAuctionRequestHook(account)).isFalse(); + } + + @Test + public void hasRawAuctionRequestHookShouldReturnGlobalFlagWhenAccountHasNoHooksConfig() { + // given + final ExecutionPlan globalPlan = givenExecutionPlan( + "raw_auction_request", "optable-targeting-raw-auction-request-hook"); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(globalPlan); + final Account account = Account.builder().id("accountId").build(); + + // when and then + assertThat(target.hasRawAuctionRequestHook(account)).isTrue(); + } + + @Test + public void hasRawAuctionRequestHookShouldReturnSameResultOnRepeatedCallsForSameAccount() { + // given + final ExecutionPlan accountPlan = givenExecutionPlan( + "raw_auction_request", "optable-targeting-raw-auction-request-hook"); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(null); + final Account account = givenAccount("accountId", accountPlan); + + // when and then + assertThat(target.hasRawAuctionRequestHook(account)).isTrue(); + assertThat(target.hasRawAuctionRequestHook(account)).isTrue(); + } + + @Test + public void hasBidderRequestHookShouldReturnTrueWhenGlobalPlanHasHook() { + // given + final ExecutionPlan globalPlan = givenExecutionPlan( + "bidder_request", "optable-targeting-bidder-request-hook"); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(globalPlan); + final Account account = Account.builder().id("accountId").build(); + + // when and then + assertThat(target.hasBidderRequestHook(account)).isTrue(); + } + + @Test + public void hasBidderRequestHookShouldReturnTrueWhenAccountPlanHasHook() { + // given + final ExecutionPlan accountPlan = givenExecutionPlan( + "bidder_request", "optable-targeting-bidder-request-hook"); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(null); + final Account account = givenAccount("accountId", accountPlan); + + // when and then + assertThat(target.hasBidderRequestHook(account)).isTrue(); + } + + @Test + public void hasBidderRequestHookShouldReturnTrueWhenBothPlansHaveHook() { + // given + final ExecutionPlan globalPlan = givenExecutionPlan( + "bidder_request", "optable-targeting-bidder-request-hook"); + final ExecutionPlan accountPlan = givenExecutionPlan( + "bidder_request", "optable-targeting-bidder-request-hook"); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(globalPlan); + final Account account = givenAccount("accountId", accountPlan); + + // when and then + assertThat(target.hasBidderRequestHook(account)).isTrue(); + } + + @Test + public void hasBidderRequestHookShouldReturnFalseWhenNeitherPlanHasHook() { + // given + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(null); + final Account account = Account.builder().id("accountId").build(); + + // when and then + assertThat(target.hasBidderRequestHook(account)).isFalse(); + } + + @Test + public void hasBidderRequestHookShouldReturnFalseWhenAccountIsNull() { + // given + final ExecutionPlan globalPlan = givenExecutionPlan( + "bidder_request", "optable-targeting-bidder-request-hook"); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(globalPlan); + + // when and then + assertThat(target.hasBidderRequestHook(null)).isFalse(); + } + + @Test + public void hasBidderRequestHookShouldReturnFalseWhenAccountIdIsEmpty() { + // given + final ExecutionPlan globalPlan = givenExecutionPlan( + "bidder_request", "optable-targeting-bidder-request-hook"); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(globalPlan); + final Account account = Account.builder().id("").build(); + + // when and then + assertThat(target.hasBidderRequestHook(account)).isFalse(); + } + + @Test + public void hasBidderRequestHookShouldReturnGlobalFlagWhenAccountHasNoHooksConfig() { + // given + final ExecutionPlan globalPlan = givenExecutionPlan( + "bidder_request", "optable-targeting-bidder-request-hook"); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(globalPlan); + final Account account = Account.builder().id("accountId").build(); + + // when and then + assertThat(target.hasBidderRequestHook(account)).isTrue(); + } + + @Test + public void hasBidderRequestHookShouldReturnSameResultOnRepeatedCallsForSameAccount() { + // given + final ExecutionPlan accountPlan = givenExecutionPlan( + "bidder_request", "optable-targeting-bidder-request-hook"); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(null); + final Account account = givenAccount("accountId", accountPlan); + + // when and then + assertThat(target.hasBidderRequestHook(account)).isTrue(); + assertThat(target.hasBidderRequestHook(account)).isTrue(); + } + + @Test + public void hasRawAuctionRequestHookShouldReturnFalseWhenOnlyBidderRequestHookIsInGlobalPlan() { + // given + final ExecutionPlan globalPlan = givenExecutionPlan( + "bidder_request", "optable-targeting-bidder-request-hook"); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(globalPlan); + final Account account = Account.builder().id("accountId").build(); + + // when and then + assertThat(target.hasRawAuctionRequestHook(account)).isFalse(); + } + + @Test + public void hasBidderRequestHookShouldReturnFalseWhenOnlyRawAuctionRequestHookIsInGlobalPlan() { + // given + final ExecutionPlan globalPlan = givenExecutionPlan( + "raw_auction_request", "optable-targeting-raw-auction-request-hook"); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(globalPlan); + final Account account = Account.builder().id("accountId").build(); + + // when and then + assertThat(target.hasBidderRequestHook(account)).isFalse(); + } + + @Test + public void getBidderRequestTimeoutShouldReturnGlobalTimeoutWhenConfigured() { + // given + final ExecutionPlan globalPlan = givenExecutionPlanWithTimeout( + "bidder_request", + "optable-targeting-bidder-request-hook", + 500L); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(globalPlan); + final Account account = Account.builder().id("accountId").build(); + + // when and then + assertThat(target.getOptableTargetingBidderRequestTimeout(account)).isEqualTo(500L); + } + + @Test + public void getBidderRequestTimeoutShouldReturnAccountTimeoutWhenAccountPlanOverrides() { + // given + final ExecutionPlan globalPlan = givenExecutionPlanWithTimeout( + "bidder_request", + "optable-targeting-bidder-request-hook", + 500L); + final ExecutionPlan accountPlan = givenExecutionPlanWithTimeout( + "bidder_request", + "optable-targeting-bidder-request-hook", + 200L); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(globalPlan); + final Account account = givenAccount("accountId", accountPlan); + + // when and then + assertThat(target.getOptableTargetingBidderRequestTimeout(account)).isEqualTo(200L); + } + + @Test + public void getBidderRequestTimeoutShouldFallbackToGlobalWhenAccountPlanHasNoTimeout() { + // given + final ExecutionPlan globalPlan = givenExecutionPlanWithTimeout( + "bidder_request", + "optable-targeting-bidder-request-hook", + 300L); + final ExecutionPlan accountPlan = givenExecutionPlan( + "raw_auction_request", "optable-targeting-raw-auction-request-hook"); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(globalPlan); + final Account account = givenAccount("accountId", accountPlan); + + // when and then + assertThat(target.getOptableTargetingBidderRequestTimeout(account)).isEqualTo(300L); + } + + @Test + public void getBidderRequestTimeoutShouldReturnZeroWhenNoPlanIsConfigured() { + // given + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(null); + final Account account = Account.builder().id("accountId").build(); + + // when and then + assertThat(target.getOptableTargetingBidderRequestTimeout(account)).isEqualTo(0L); + } + + @Test + public void getBidderRequestTimeoutShouldReturnGlobalTimeoutWhenAccountIsNull() { + // given + final ExecutionPlan globalPlan = givenExecutionPlanWithTimeout( + "bidder_request", + "optable-targeting-bidder-request-hook", + 400L); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(globalPlan); + + // when and then + assertThat(target.getOptableTargetingBidderRequestTimeout(null)).isEqualTo(400L); + } + + @Test + public void getBidderRequestTimeoutShouldReturnGlobalTimeoutWhenAccountIdIsEmpty() { + // given + final ExecutionPlan globalPlan = givenExecutionPlanWithTimeout( + "bidder_request", + "optable-targeting-bidder-request-hook", + 150L); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(globalPlan); + final Account account = Account.builder().id("").build(); + + // when and then + assertThat(target.getOptableTargetingBidderRequestTimeout(account)).isEqualTo(150L); + } + + @Test + public void getBidderRequestTimeoutShouldReturnSameResultOnRepeatedCallsForSameAccount() { + // given + final ExecutionPlan accountPlan = givenExecutionPlanWithTimeout( + "bidder_request", + "optable-targeting-bidder-request-hook", + 250L); + final CompositeHookExecutionPlan target = CompositeHookExecutionPlan.of(null); + final Account account = givenAccount("accountId", accountPlan); + + // when and then + assertThat(target.getOptableTargetingBidderRequestTimeout(account)).isEqualTo(250L); + assertThat(target.getOptableTargetingBidderRequestTimeout(account)).isEqualTo(250L); + } + + private ExecutionPlan givenExecutionPlan(String stage, String hookCode) { + final HookId hookId = HookId.of("optable-targeting", hookCode); + final ExecutionGroup group = ExecutionGroup.of(null, List.of(hookId)); + final StageExecutionPlan stagePlan = StageExecutionPlan.of(List.of(group)); + final EndpointExecutionPlan endpointPlan = EndpointExecutionPlan.of(Map.of(Stage.valueOf(stage), stagePlan)); + return ExecutionPlan.of(null, Map.of(HookHttpEndpoint.POST_AUCTION, endpointPlan)); + } + + private ExecutionPlan givenExecutionPlanWithTimeout(String stage, String hookCode, long timeout) { + final HookId hookId = HookId.of("optable-targeting", hookCode); + final ExecutionGroup group = ExecutionGroup.of(timeout, List.of(hookId)); + final StageExecutionPlan stagePlan = StageExecutionPlan.of(List.of(group)); + final EndpointExecutionPlan endpointPlan = EndpointExecutionPlan.of(Map.of(Stage.valueOf(stage), stagePlan)); + return ExecutionPlan.of(null, Map.of(HookHttpEndpoint.POST_AUCTION, endpointPlan)); + } + + private Account givenAccount(String accountId, ExecutionPlan executionPlan) { + return Account.builder() + .id(accountId) + .hooks(AccountHooksConfiguration.of(executionPlan, null, null)) + .build(); + } +} + diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/ExtUserOptableResolverTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/ExtUserOptableResolverTest.java new file mode 100644 index 00000000000..b3142c173f2 --- /dev/null +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/ExtUserOptableResolverTest.java @@ -0,0 +1,64 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import org.junit.jupiter.api.Test; +import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.ExtUserOptable; +import org.prebid.server.json.ObjectMapperProvider; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ExtUserOptableResolverTest { + + @Test + public void shouldResolveExtUserOptableWhenNodeIsValid() { + // given + final ObjectNode node = ObjectMapperProvider.mapper().createObjectNode(); + node.set("email", TextNode.valueOf("user@example.com")); + node.set("phone", TextNode.valueOf("123")); + node.set("zip", TextNode.valueOf("321")); + node.set("vid", TextNode.valueOf("vid")); + node.set("id5_signature", TextNode.valueOf("signature")); + + // when + final ExtUserOptable result = ExtUserOptableResolver.resolveExtUserOptable(node, 1.0); + + // then + assertThat(result).isNotNull() + .returns("user@example.com", ExtUserOptable::getEmail) + .returns("123", ExtUserOptable::getPhone) + .returns("321", ExtUserOptable::getZip) + .returns("vid", ExtUserOptable::getVid) + .returns("signature", ExtUserOptable::getId5Signature); + } + + @Test + public void shouldReturnNullWhenNodeIsNotParseable() { + // given + final ObjectNode node = ObjectMapperProvider.mapper().createObjectNode(); + node.set("email", ObjectMapperProvider.mapper().createArrayNode().add(1).add(2)); + + // when + final ExtUserOptable result = ExtUserOptableResolver.resolveExtUserOptable(node, 1.0); + + // then + assertThat(result).isNull(); + } + + @Test + public void shouldResolveExtUserOptableWhenNodeIsEmpty() { + // given + final ObjectNode node = ObjectMapperProvider.mapper().createObjectNode(); + + // when + final ExtUserOptable result = ExtUserOptableResolver.resolveExtUserOptable(node, 1.0); + + // then + assertThat(result).isNotNull() + .returns(null, ExtUserOptable::getEmail) + .returns(null, ExtUserOptable::getPhone) + .returns(null, ExtUserOptable::getZip) + .returns(null, ExtUserOptable::getVid) + .returns(null, ExtUserOptable::getId5Signature); + } +} diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java new file mode 100644 index 00000000000..da0e5bb4161 --- /dev/null +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java @@ -0,0 +1,339 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.Uid; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.ObjectMapperProvider; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class Id5ResolverTest { + + private ObjectMapper mapper; + + @BeforeEach + public void setUp() { + mapper = ObjectMapperProvider.mapper(); + } + + @Test + public void shouldReturnNullWhenSignatureIsJsonNull() { + // given + final ObjectNode uidExt = mapper.createObjectNode(); + uidExt.set("optable", mapper.createObjectNode() + .set("ref", TextNode.valueOf("refValue"))); + final Eid eid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) + .build(); + final ObjectNode refs = mapper.createObjectNode(); + refs.set("refValue", mapper.createObjectNode().putNull("signature")); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(eid), null)), + refs); + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + // then + assertThat(result).isNull(); + } + + @Test + public void shouldReturnNullWhenTargetingResultIsNull() { + // when + final String result = Id5Resolver.resolveId5Signature(null); + + // then + assertThat(result).isNull(); + } + + @Test + public void shouldReturnNullWhenOrtb2IsNull() { + // given + final TargetingResult targetingResult = new TargetingResult(List.of(), null, null); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isNull(); + } + + @Test + public void shouldReturnNullWhenUserHasNoEids() { + // given + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(null, null)), + null); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isNull(); + } + + @Test + public void shouldReturnNullWhenEidsDoNotMatchOptableAndId5Source() { + // given + final Eid eid = Eid.builder() + .source("other-source") + .inserter("other-inserter") + .uids(List.of(Uid.builder().id("id").build())) + .build(); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(eid), null)), + null); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isNull(); + } + + @Test + public void shouldReturnNullWhenMatchingEidButRefsAreAbsent() { + // given + final ObjectNode uidExt = mapper.createObjectNode(); + uidExt.set("optable", mapper.createObjectNode() + .set("ref", TextNode.valueOf("refValue"))); + + final Eid eid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) + .build(); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(eid), null)), + null); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isNull(); + } + + @Test + public void shouldReturnNullWhenRefsDoNotContainResolvedRef() { + // given + final ObjectNode uidExt = mapper.createObjectNode(); + uidExt.set("optable", mapper.createObjectNode() + .set("ref", TextNode.valueOf("refValue"))); + + final Eid eid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) + .build(); + final ObjectNode refs = mapper.createObjectNode(); + refs.set("otherRef", mapper.createObjectNode() + .set("signature", TextNode.valueOf("signatureValue"))); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(eid), null)), + refs); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isNull(); + } + + @Test + public void shouldReturnSignatureWhenAllConditionsAreMet() { + // given + final ObjectNode uidExt = mapper.createObjectNode(); + uidExt.set("optable", mapper.createObjectNode() + .set("ref", TextNode.valueOf("refValue"))); + + final Eid eid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) + .build(); + final ObjectNode refs = mapper.createObjectNode(); + refs.set("refValue", mapper.createObjectNode() + .set("signature", TextNode.valueOf("signatureValue"))); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(eid), null)), + refs); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isEqualTo("signatureValue"); + } + + @Test + public void shouldReturnSignatureWhenMultipleMatchingEidsExist() { + // given + final ObjectNode firstUidExt = mapper.createObjectNode(); + firstUidExt.set("optable", mapper.createObjectNode() + .set("ref", TextNode.valueOf("firstRef"))); + + final ObjectNode secondUidExt = mapper.createObjectNode(); + secondUidExt.set("optable", mapper.createObjectNode() + .set("ref", TextNode.valueOf("secondRef"))); + + final Eid firstEid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id1").ext(firstUidExt).build())) + .build(); + final Eid secondEid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id2").ext(secondUidExt).build())) + .build(); + final ObjectNode refs = mapper.createObjectNode(); + refs.set("firstRef", mapper.createObjectNode() + .set("signature", TextNode.valueOf("firstSignature"))); + refs.set("secondRef", mapper.createObjectNode() + .set("signature", TextNode.valueOf("secondSignature"))); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(firstEid, secondEid), null)), + refs); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isEqualTo("firstSignature"); + } + + @Test + public void shouldReturnSignatureFromSecondMatchingEidWhenFirstRefNotInRefs() { + // given + final ObjectNode firstUidExt = mapper.createObjectNode(); + firstUidExt.set("optable", mapper.createObjectNode() + .set("ref", TextNode.valueOf("firstRef"))); + + final ObjectNode secondUidExt = mapper.createObjectNode(); + secondUidExt.set("optable", mapper.createObjectNode() + .set("ref", TextNode.valueOf("secondRef"))); + + final Eid firstEid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id1").ext(firstUidExt).build())) + .build(); + final Eid secondEid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id2").ext(secondUidExt).build())) + .build(); + final ObjectNode refs = mapper.createObjectNode(); + refs.set("secondRef", mapper.createObjectNode() + .set("signature", TextNode.valueOf("secondSignature"))); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(firstEid, secondEid), null)), + refs); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isEqualTo("secondSignature"); + } + + @Test + public void shouldReturnNullWhenRefEntrySignatureIsBlank() { + // given + final ObjectNode uidExt = mapper.createObjectNode(); + uidExt.set("optable", mapper.createObjectNode() + .set("ref", TextNode.valueOf("refValue"))); + + final Eid eid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) + .build(); + final ObjectNode refs = mapper.createObjectNode(); + refs.set("refValue", mapper.createObjectNode() + .set("signature", TextNode.valueOf(" "))); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(eid), null)), + refs); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isNull(); + } + + @Test + public void shouldReturnNullWhenRefEntrySignatureIsContainerNode() { + // given + final ObjectNode uidExt = mapper.createObjectNode(); + uidExt.set("optable", mapper.createObjectNode() + .set("ref", TextNode.valueOf("refValue"))); + + final Eid eid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) + .build(); + final ObjectNode refs = mapper.createObjectNode(); + refs.set("refValue", mapper.createObjectNode() + .set("signature", mapper.createObjectNode())); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(eid), null)), + refs); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isNull(); + } + + @Test + public void shouldReturnNullWhenRefEntryHasNoSignature() { + // given + final ObjectNode uidExt = mapper.createObjectNode(); + uidExt.set("optable", mapper.createObjectNode() + .set("ref", TextNode.valueOf("refValue"))); + + final Eid eid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) + .build(); + final ObjectNode refs = mapper.createObjectNode(); + refs.set("refValue", mapper.createObjectNode() + .set("other", TextNode.valueOf("value"))); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(eid), null)), + refs); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isNull(); + } +} diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5SignatureBidResponseEnricherTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5SignatureBidResponseEnricherTest.java new file mode 100644 index 00000000000..d9eb8bd1798 --- /dev/null +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5SignatureBidResponseEnricherTest.java @@ -0,0 +1,122 @@ +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.fasterxml.jackson.databind.node.TextNode; +import com.iab.openrtb.response.BidResponse; +import org.junit.jupiter.api.Test; +import org.prebid.server.hooks.execution.v1.auction.AuctionResponsePayloadImpl; +import org.prebid.server.hooks.v1.auction.AuctionResponsePayload; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.json.JsonMerger; +import org.prebid.server.json.ObjectMapperProvider; +import org.prebid.server.proto.openrtb.ext.response.ExtBidResponse; +import org.prebid.server.proto.openrtb.ext.response.ExtBidResponsePrebid; + +import static org.assertj.core.api.Assertions.assertThat; + +public class Id5SignatureBidResponseEnricherTest { + + private final JacksonMapper jacksonMapper = new JacksonMapper(ObjectMapperProvider.mapper()); + private final JsonMerger jsonMerger = new JsonMerger(jacksonMapper); + + @Test + public void shouldReturnOriginBidResponseWhenId5SignatureIsNull() { + // given + final BidResponse bidResponse = BidResponse.builder().build(); + final AuctionResponsePayload payload = AuctionResponsePayloadImpl.of(bidResponse); + final Id5SignatureBidResponseEnricher enricher = + Id5SignatureBidResponseEnricher.of(null, ObjectMapperProvider.mapper(), jsonMerger); + + // when + final AuctionResponsePayload result = enricher.apply(payload); + + // then + assertThat(result.bidResponse()).isSameAs(bidResponse); + } + + @Test + public void shouldReturnOriginBidResponseWhenId5SignatureIsEmpty() { + // given + final BidResponse bidResponse = BidResponse.builder().build(); + final AuctionResponsePayload payload = AuctionResponsePayloadImpl.of(bidResponse); + final Id5SignatureBidResponseEnricher enricher = + Id5SignatureBidResponseEnricher.of("", ObjectMapperProvider.mapper(), jsonMerger); + + // when + final AuctionResponsePayload result = enricher.apply(payload); + + // then + assertThat(result.bidResponse()).isSameAs(bidResponse); + } + + @Test + public void shouldAddId5SignatureToPassthroughWhenExtIsAbsent() { + // given + final BidResponse bidResponse = BidResponse.builder().build(); + final AuctionResponsePayload payload = AuctionResponsePayloadImpl.of(bidResponse); + final Id5SignatureBidResponseEnricher enricher = + Id5SignatureBidResponseEnricher.of("signature", ObjectMapperProvider.mapper(), jsonMerger); + + // when + final AuctionResponsePayload result = enricher.apply(payload); + + // then + final BidResponse enriched = result.bidResponse(); + assertThat(enriched.getExt()).isNotNull(); + assertThat(enriched.getExt().getPrebid()).isNotNull(); + final JsonNode passthrough = enriched.getExt().getPrebid().getPassthrough(); + assertThat(passthrough).isNotNull(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo("signature"); + } + + @Test + public void shouldMergeId5SignatureWithExistingPassthrough() { + // given + final ObjectNode existingPassthrough = ObjectMapperProvider.mapper().createObjectNode(); + existingPassthrough.set("other", ObjectMapperProvider.mapper().createObjectNode() + .set("value", TextNode.valueOf("otherValue"))); + existingPassthrough.set("optable", ObjectMapperProvider.mapper().createObjectNode() + .set("existing", TextNode.valueOf("preserved"))); + + final ExtBidResponse ext = ExtBidResponse.builder() + .prebid(ExtBidResponsePrebid.builder() + .passthrough(existingPassthrough) + .build()) + .build(); + final BidResponse bidResponse = BidResponse.builder().ext(ext).build(); + final AuctionResponsePayload payload = AuctionResponsePayloadImpl.of(bidResponse); + final Id5SignatureBidResponseEnricher enricher = + Id5SignatureBidResponseEnricher.of("signature", ObjectMapperProvider.mapper(), jsonMerger); + + // when + final AuctionResponsePayload result = enricher.apply(payload); + + // then + final JsonNode passthrough = result.bidResponse().getExt().getPrebid().getPassthrough(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo("signature"); + assertThat(passthrough.get("optable").get("existing").asText()).isEqualTo("preserved"); + assertThat(passthrough.get("other").get("value").asText()).isEqualTo("otherValue"); + } + + @Test + public void shouldPreserveExistingPrebidFieldsWhenAddingPassthrough() { + // given + final ExtBidResponse ext = ExtBidResponse.builder() + .prebid(ExtBidResponsePrebid.builder().build()) + .build(); + final BidResponse bidResponse = BidResponse.builder().ext(ext).build(); + final AuctionResponsePayload payload = AuctionResponsePayloadImpl.of(bidResponse); + final Id5SignatureBidResponseEnricher enricher = + Id5SignatureBidResponseEnricher.of("signature", ObjectMapperProvider.mapper(), jsonMerger); + + // when + final AuctionResponsePayload result = enricher.apply(payload); + + // then + final ExtBidResponsePrebid prebid = result.bidResponse().getExt().getPrebid(); + assertThat(prebid).isNotNull(); + assertThat(prebid.getPassthrough().get("optable").get("id5_signature").asText()) + .isEqualTo("signature"); + } +} 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 index 39693661629..ee273761f04 100644 --- 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 @@ -32,7 +32,7 @@ public class IdsMapperTest { @BeforeEach public void setUp() { - target = new IdsMapper(objectMapper, 0.01); + target = new IdsMapper(0.01); } @Test @@ -56,7 +56,8 @@ public void shouldMapBidRequestToAllPossibleIds() { .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")); + .contains(Id.of("c", "test_id")) + .contains(Id.of(Id.DEVICE_IP_V_6, "0:0:0:0:0:0:0:1")); } @Test @@ -71,6 +72,17 @@ public void shouldMapNothing() { assertThat(ids).isNotNull(); } + @Test + public void shouldMapIpv6WhenPresent() { + final BidRequest bidRequest = givenBidRequestWithEids(Map.of()); + + final List ids = target.toIds(bidRequest, Map.of()); + + assertThat(ids).isNotNull() + .contains(Id.of(Id.EMAIL, "email")) + .contains(Id.of(Id.DEVICE_IP_V_6, "0:0:0:0:0:0:0:1")); + } + private BidRequest givenBidRequestWithEids(Map eids) { final JsonNode extUserOptable = objectMapper.convertValue(givenOptable(), JsonNode.class); final ExtUser extUser = ExtUser.builder().build(); 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 index de2c01948fb..51adfda9952 100644 --- 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 @@ -1,7 +1,12 @@ package org.prebid.server.hooks.modules.optable.targeting.v1.core; +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.Regs; +import com.iab.openrtb.request.User; +import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -9,12 +14,15 @@ 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.App; 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 org.prebid.server.proto.openrtb.ext.request.ExtRegs; +import org.prebid.server.proto.openrtb.ext.request.ExtUser; import java.util.List; import java.util.Set; @@ -42,19 +50,36 @@ public void setUp() { } @Test - public void shouldResolveTcfAttributesWhenConsentIsValid() { + public void shouldResolveGdprAttributesForORTB26WhenConsentIsValid() { // 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); + final AuctionContext auctionContext = + givenAuctionContext(givenBidRequestWithGdprORTB26(true, "consent"), tcfContext, gppContext); // when final OptableAttributes result = OptableAttributesResolver.resolveAttributes( - auctionContext, properties.getTimeout()); + auctionContext, properties.getTimeout(), 0.01); + + // then + assertThat(result).isNotNull() + .returns(true, OptableAttributes::isGdprApplies) + .returns("consent", OptableAttributes::getGdprConsent); + } + + @Test + public void shouldResolveGdprAttributesForORTB25WhenConsentIsValid() { + // given + final GppModel gppModel = mock(); + when(gppModel.encode()).thenReturn("consent"); + when(gppContext.scope()).thenReturn(GppContext.Scope.of(gppModel, Set.of(1))); + final AuctionContext auctionContext = + givenAuctionContext(givenBidRequestWithGdprORTB25(true, "consent"), tcfContext, gppContext); + + // when + final OptableAttributes result = OptableAttributesResolver.resolveAttributes( + auctionContext, properties.getTimeout(), 0.01); // then assertThat(result).isNotNull() @@ -75,7 +100,7 @@ public void shouldNotResolveTcfAttributesWhenConsentIsNotValid() { // when final OptableAttributes result = OptableAttributesResolver.resolveAttributes( - auctionContext, properties.getTimeout()); + auctionContext, properties.getTimeout(), 0.01); // then assertThat(result).isNotNull() @@ -96,7 +121,7 @@ public void shouldResolveGppAttributes() { // when final OptableAttributes result = OptableAttributesResolver.resolveAttributes( - auctionContext, properties.getTimeout()); + auctionContext, properties.getTimeout(), 0.01); // then assertThat(result).isNotNull() @@ -105,6 +130,113 @@ public void shouldResolveGppAttributes() { .returns(Set.of(1), OptableAttributes::getGppSid); } + @Test + public void shouldResolveAppWhenAppIsPresent() { + // given + final com.iab.openrtb.request.App ortbApp = com.iab.openrtb.request.App.builder() + .bundle("com.example.app") + .ver("1.2.3") + .build(); + final BidRequest bidRequest = BidRequest.builder() + .app(ortbApp) + .build(); + final AuctionContext auctionContext = givenAuctionContext(bidRequest, tcfContext, gppContext); + + // when + final OptableAttributes result = OptableAttributesResolver.resolveAttributes( + auctionContext, properties.getTimeout(), 0.01); + + // then + assertThat(result).isNotNull() + .returns(App.of("com.example.app", "1.2.3"), OptableAttributes::getApp); + } + + @Test + public void shouldNotResolveAppWhenAppIsAbsent() { + // given + final BidRequest bidRequest = BidRequest.builder().build(); + final AuctionContext auctionContext = givenAuctionContext(bidRequest, tcfContext, gppContext); + + // when + final OptableAttributes result = OptableAttributesResolver.resolveAttributes( + auctionContext, properties.getTimeout(), 0.01); + + // then + assertThat(result).isNotNull() + .returns(null, OptableAttributes::getApp); + } + + @Test + public void shouldResolveId5SignatureWhenPresentInUserExtOptable() { + // given + final BidRequest bidRequest = givenBidRequestWithId5Signature("signature"); + final AuctionContext auctionContext = givenAuctionContext(bidRequest, tcfContext, gppContext); + + // when + final OptableAttributes result = OptableAttributesResolver.resolveAttributes( + auctionContext, properties.getTimeout(), 0.01); + + // then + assertThat(result).isNotNull() + .returns("signature", OptableAttributes::getId5Signature); + } + + @Test + public void shouldNotResolveId5SignatureWhenAbsentInUserExtOptable() { + // given + final BidRequest bidRequest = givenBidRequestWithId5Signature(null); + final AuctionContext auctionContext = givenAuctionContext(bidRequest, tcfContext, gppContext); + + // when + final OptableAttributes result = OptableAttributesResolver.resolveAttributes( + auctionContext, properties.getTimeout(), 0.01); + + // then + assertThat(result).isNotNull() + .returns(null, OptableAttributes::getId5Signature); + } + + private BidRequest givenBidRequestWithId5Signature(String signature) { + final ObjectNode optable = mapper.createObjectNode(); + if (StringUtils.isNotEmpty(signature)) { + optable.set("id5_signature", TextNode.valueOf(signature)); + } + + final ExtUser extUser = ExtUser.builder().build(); + extUser.addProperty("optable", optable); + final User user = User.builder().ext(extUser).build(); + + return BidRequest.builder().user(user).build(); + } + + private BidRequest givenBidRequestWithGdprORTB26(boolean isGdprEnabled, String consent) { + final User user = User.builder() + .consent(consent) + .build(); + + return BidRequest.builder() + .user(user) + .regs(Regs.builder() + .gdpr(isGdprEnabled ? 1 : 0) + .build()) + .build(); + } + + private BidRequest givenBidRequestWithGdprORTB25(boolean isGdprEnabled, String consent) { + final User user = User.builder() + .ext(ExtUser.builder() + .consent(consent) + .build()) + .build(); + + return BidRequest.builder() + .user(user) + .regs(Regs.builder() + .ext(ExtRegs.of(isGdprEnabled ? 1 : 0, null, null, null)) + .build()) + .build(); + } + public AuctionContext givenAuctionContext(BidRequest bidRequest, TcfContext tcfContext, GppContext gppContext) { return AuctionContext.builder() .bidRequest(bidRequest) diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/PropertiesValidatorTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/PropertiesValidatorTest.java new file mode 100644 index 00000000000..0f496051b65 --- /dev/null +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/PropertiesValidatorTest.java @@ -0,0 +1,126 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +import com.iab.openrtb.request.App; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Site; +import org.junit.jupiter.api.Test; +import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties; + +import static org.assertj.core.api.Assertions.assertThat; + +public class PropertiesValidatorTest { + + @Test + public void isValidShouldReturnTrueWhenTenantAndOriginArePresent() { + // given + final OptableTargetingProperties properties = new OptableTargetingProperties(); + properties.setTenant("tenant"); + properties.setOrigin("origin"); + + // when + final boolean result = PropertiesValidator.isValid(properties); + + // then + assertThat(result).isTrue(); + } + + @Test + public void isValidShouldReturnFalseWhenTenantIsMissing() { + // given + final OptableTargetingProperties properties = new OptableTargetingProperties(); + properties.setOrigin("origin"); + + // when + final boolean result = PropertiesValidator.isValid(properties); + + // then + assertThat(result).isFalse(); + } + + @Test + public void isValidShouldReturnFalseWhenOriginIsMissing() { + // given + final OptableTargetingProperties properties = new OptableTargetingProperties(); + properties.setTenant("tenant"); + + // when + final boolean result = PropertiesValidator.isValid(properties); + + // then + assertThat(result).isFalse(); + } + + @Test + public void isTrafficSourceValidShouldReturnTrueWhenEnrichWebIsTrueAndSiteIsPresent() { + // given + final OptableTargetingProperties properties = new OptableTargetingProperties(); + properties.setEnrichWeb(true); + final BidRequest bidRequest = BidRequest.builder().site(Site.builder().build()).build(); + + // when + final boolean result = PropertiesValidator.isTrafficSourceValid(bidRequest, properties); + + // then + assertThat(result).isTrue(); + } + + @Test + public void isTrafficSourceValidShouldReturnFalseWhenEnrichWebIsTrueAndSiteIsMissing() { + // given + final OptableTargetingProperties properties = new OptableTargetingProperties(); + properties.setEnrichWeb(true); + final BidRequest bidRequest = BidRequest.builder().build(); + + // when + final boolean result = PropertiesValidator.isTrafficSourceValid(bidRequest, properties); + + // then + assertThat(result).isFalse(); + } + + @Test + public void isTrafficSourceValidShouldReturnTrueWhenEnrichAppIsTrueAndAppIsPresent() { + // given + final OptableTargetingProperties properties = new OptableTargetingProperties(); + properties.setEnrichApp(true); + final BidRequest bidRequest = BidRequest.builder().app(App.builder().build()).build(); + + // when + final boolean result = PropertiesValidator.isTrafficSourceValid(bidRequest, properties); + + // then + assertThat(result).isTrue(); + } + + @Test + public void isTrafficSourceValidShouldReturnFalseWhenEnrichAppIsTrueAndAppIsMissing() { + // given + final OptableTargetingProperties properties = new OptableTargetingProperties(); + properties.setEnrichApp(true); + final BidRequest bidRequest = BidRequest.builder().build(); + + // when + final boolean result = PropertiesValidator.isTrafficSourceValid(bidRequest, properties); + + // then + assertThat(result).isFalse(); + } + + @Test + public void isTrafficSourceValidShouldReturnFalseWhenBothEnrichWebAndEnrichAppAreFalseOrNull() { + // given + final OptableTargetingProperties properties = new OptableTargetingProperties(); + properties.setEnrichWeb(false); + properties.setEnrichApp(false); + final BidRequest bidRequest = BidRequest.builder() + .site(Site.builder().build()) + .app(App.builder().build()) + .build(); + + // when + final boolean result = PropertiesValidator.isTrafficSourceValid(bidRequest, properties); + + // then + assertThat(result).isFalse(); + } +} 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 index 0359578ee75..f8eb3a99c36 100644 --- 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 @@ -1,11 +1,14 @@ 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.App; 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 java.util.List; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -15,16 +18,21 @@ public class QueryBuilderTest { private final String idPrefixOrder = "c,c1"; + private OptableTargetingProperties properties() { + return givenProperties(idPrefixOrder, null); + } + @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); + final Query query = QueryBuilder.build(ids, optableAttributes, properties()); // then assertThat(query.getIds()).isEqualTo("&id=e%3Aemail&id=p%3A123"); + assertThat(query.getHid()).isEqualTo(""); assertThat(query.getAttributes()).isEqualTo("&gdpr_consent=tcf&gdpr=1&timeout=100ms&osdk=prebid-server"); } @@ -34,10 +42,11 @@ public void shouldBuildFullQueryString() { final List ids = List.of(Id.of(Id.EMAIL, "email"), Id.of(Id.PHONE, "123")); // when - final Query query = QueryBuilder.build(ids, optableAttributes, idPrefixOrder); + final Query query = QueryBuilder.build(ids, optableAttributes, properties()); // then assertThat(query.getIds()).isEqualTo("&id=e%3Aemail&id=p%3A123"); + assertThat(query.getHid()).isEqualTo(""); assertThat(query.getAttributes()).isEqualTo("&gdpr_consent=tcf&gdpr=1&timeout=100ms&osdk=prebid-server"); assertThat(query.toQueryString()) .isEqualTo("&id=e%3Aemail&id=p%3A123&gdpr_consent=tcf&gdpr=1&timeout=100ms&osdk=prebid-server"); @@ -49,7 +58,7 @@ public void shouldBuildQueryStringWhenHaveIds() { 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(); + final String query = QueryBuilder.build(ids, optableAttributes, properties()).toQueryString(); // then assertThat(query).contains("e%3Aemail", "p%3A123"); @@ -61,7 +70,7 @@ public void shouldBuildQueryStringWithExtraAttributes() { 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(); + final String query = QueryBuilder.build(ids, optableAttributes, properties()).toQueryString(); // then assertThat(query).contains("&gdpr=1", "&gdpr_consent=tcf", "&timeout=100ms"); @@ -77,7 +86,7 @@ public void shouldBuildQueryStringWithRightOrder() { Id.of("c", "234")); // when - final String query = QueryBuilder.build(ids, optableAttributes, idPrefixOrder).toQueryString(); + final String query = QueryBuilder.build(ids, optableAttributes, properties()).toQueryString(); // then assertThat(query).startsWith("&id=c%3A234&id=c1%3A123&id=id5%3AID5&id=e%3Aemail"); @@ -92,7 +101,7 @@ public void shouldBuildQueryStringWhenIdsListIsEmptyAndIpIsPresent() { .build(); // when - final Query query = QueryBuilder.build(ids, attributes, idPrefixOrder); + final Query query = QueryBuilder.build(ids, attributes, properties()); // then assertThat(query).isNotNull(); @@ -106,12 +115,405 @@ public void shouldNotBuildQueryStringWhenIdsListIsEmptyAndIpIsAbsent() { final OptableAttributes attributes = OptableAttributes.builder().build(); // when - final Query query = QueryBuilder.build(ids, attributes, idPrefixOrder); + final Query query = QueryBuilder.build(ids, attributes, properties()); // then assertThat(query).isNull(); } + @Test + public void shouldBuildQueryStringWithGppSid() { + // given + final List ids = List.of(Id.of(Id.EMAIL, "email")); + final OptableAttributes attributes = OptableAttributes.builder() + .ips(List.of("8.8.8.8")) + .gpp("DBABzw~1YNY~BVQqAAAAAgA") + .gppSid(Set.of(7, 22)) + .build(); + + // when + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + assertThat(query).contains("&gpp=DBABzw~1YNY~BVQqAAAAAgA"); + final String gppSidValue = query.split("gpp_sid=")[1].split("&")[0]; + assertThat(gppSidValue.split(",")).containsExactlyInAnyOrder("7", "22"); + assertThat(query).doesNotContain("Optional"); + } + + @Test + public void shouldBuildQueryStringWithSingleGppSid() { + // given + final List ids = List.of(Id.of(Id.EMAIL, "email")); + final OptableAttributes attributes = OptableAttributes.builder() + .ips(List.of("8.8.8.8")) + .gpp("DBABzw~1YNY") + .gppSid(Set.of(7)) + .build(); + + // when + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + assertThat(query).contains("&gpp_sid=7"); + assertThat(query).doesNotContain("Optional"); + } + + @Test + public void shouldLimitGppSidToTwoValues() { + // given + final List ids = List.of(Id.of(Id.EMAIL, "email")); + final OptableAttributes attributes = OptableAttributes.builder() + .ips(List.of("8.8.8.8")) + .gpp("DBABzw~1YNY~BVQqAAAAAgA") + .gppSid(Set.of(5, 7, 22)) + .build(); + + // when + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + final String gppSidValue = query.split("gpp_sid=")[1].split("&")[0]; + assertThat(gppSidValue.split(",")).hasSize(2); + assertThat(query).doesNotContain("Optional"); + } + + @Test + public void shouldNotIncludeGppSidWhenEmpty() { + // given + final List ids = List.of(Id.of(Id.EMAIL, "email")); + final OptableAttributes attributes = OptableAttributes.builder() + .ips(List.of("8.8.8.8")) + .gpp("DBABzw~1YNY") + .gppSid(Set.of()) + .build(); + + // when + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + assertThat(query).doesNotContain("gpp_sid"); + } + + @Test + public void shouldBuildHidWhenHidPrefixesMatchIds() { + // given + final OptableTargetingProperties props = givenProperties(null, "c,i6"); + final List ids = List.of( + Id.of("c", "234"), + Id.of(Id.DEVICE_IP_V_6, "0:0:0:0:0:0:0:1")); + + // when + final Query query = QueryBuilder.build(ids, optableAttributes, props); + + // then + assertThat(query.getHid()).isEqualTo("&hid=c:234&hid=i6:0%3A0%3A0%3A0%3A0%3A0%3A0%3A1"); + } + + @Test + public void shouldExcludeDeviceIpV6FromIdsString() { + // given + final OptableTargetingProperties props = givenProperties(null, "i6"); + final List ids = List.of( + Id.of(Id.EMAIL, "email"), + Id.of(Id.DEVICE_IP_V_6, "0:0:0:0:0:0:0:1")); + + // when + final Query query = QueryBuilder.build(ids, optableAttributes, props); + + // then + assertThat(query.getIds()).doesNotContain(Id.DEVICE_IP_V_6); + assertThat(query.getHid()).isEqualTo("&hid=i6:0%3A0%3A0%3A0%3A0%3A0%3A0%3A1"); + } + + @Test + public void shouldUrlEncodeHidValueWithSpecialCharacters() { + // given + final OptableTargetingProperties props = givenProperties(null, "c"); + final List ids = List.of(Id.of("c", "a b&c=d+e/f")); + + // when + final Query query = QueryBuilder.build(ids, optableAttributes, props); + + // then + assertThat(query.getHid()).isEqualTo("&hid=c:a+b%26c%3Dd%2Be%2Ff"); + } + + @Test + public void shouldLeaveHidValueUnchangedForAlphanumericValue() { + // given + final OptableTargetingProperties props = givenProperties(null, "c"); + final List ids = List.of(Id.of("c", "abc123")); + + // when + final Query query = QueryBuilder.build(ids, optableAttributes, props); + + // then + assertThat(query.getHid()).isEqualTo("&hid=c:abc123"); + } + + @Test + public void shouldNotBuildHidWhenNoMatch() { + // given + final OptableTargetingProperties props = givenProperties(null, "nonexistent"); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, optableAttributes, props); + + // then + assertThat(query.getHid()).isEqualTo(""); + } + + @Test + public void shouldNotBuildHidWhenHidPrefixesNotConfigured() { + // given + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, optableAttributes, properties()); + + // then + assertThat(query.getHid()).isEqualTo(""); + } + + @Test + public void shouldAppendBundleAndVerWhenAppHasBoth() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .app(App.of("com.example.app", "1.2.3")) + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + assertThat(query).contains("&bundle=com.example.app", "&ver=1.2.3"); + } + + @Test + public void shouldAppendBundleOnlyWhenVerIsEmpty() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .app(App.of("com.example.app", "")) + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + assertThat(query).contains("&bundle=com.example.app"); + assertThat(query).doesNotContain("&ver="); + } + + @Test + public void shouldNotAppendBundleAndVerWhenBundleIsEmpty() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .app(App.of("", "1.2.3")) + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + assertThat(query).doesNotContain("&bundle=", "&ver="); + } + + @Test + public void shouldNotAppendBundleAndVerWhenAppIsNull() { + // given + final OptableAttributes attributes = OptableAttributes.builder().build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + assertThat(query).doesNotContain("&bundle=", "&ver="); + } + + @Test + public void shouldBuildHidAttributesWithBundleAndVerWhenAppIsPresent() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .app(App.of("com.example.app", "1.2.3")) + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, attributes, properties()); + + // then + assertThat(query.getHidAttributes()) + .isEqualTo("&bundle=com.example.app&ver=1.2.3"); + } + + @Test + public void shouldBuildHidAttributesWithBundleOnlyWhenVerIsEmpty() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .app(App.of("com.example.app", "")) + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, attributes, properties()); + + // then + assertThat(query.getHidAttributes()).isEqualTo("&bundle=com.example.app"); + } + + @Test + public void shouldNotBuildHidAttributesWithBundleWhenBundleIsEmpty() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .app(App.of("", "1.2.3")) + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, attributes, properties()); + + // then + assertThat(query.getHidAttributes()).doesNotContain("&bundle=", "&ver="); + } + + @Test + public void shouldNotBuildHidAttributesWithBundleWhenAppIsNull() { + // given + final OptableAttributes attributes = OptableAttributes.builder().build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, attributes, properties()); + + // then + assertThat(query.getHidAttributes()).isEmpty(); + } + + @Test + public void shouldBuildHidAttributesWithId5SignatureWhenPresent() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .id5Signature("signature") + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, attributes, properties()); + + // then + assertThat(query.getHidAttributes()).isEqualTo("&id5_signature=signature"); + } + + @Test + public void shouldNotBuildHidAttributesWithId5SignatureWhenNull() { + // given + final OptableAttributes attributes = OptableAttributes.builder().build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, attributes, properties()); + + // then + assertThat(query.getHidAttributes()).doesNotContain("&id5_signature="); + } + + @Test + public void shouldIncludeHidAttributesInToQueryString() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .app(App.of("com.example.app", "1.2.3")) + .id5Signature("signature") + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final String queryString = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + assertThat(queryString).endsWith("&bundle=com.example.app&ver=1.2.3&id5_signature=signature"); + // they live in hidAttributes so that they take part in the cache key, and must not + // also be emitted from buildAttributesString + assertThat(queryString.split("&bundle=", -1)).hasSize(2); + assertThat(queryString.split("&ver=", -1)).hasSize(2); + assertThat(queryString.split("&id5_signature=", -1)).hasSize(2); + } + + @Test + public void shouldTrimWhitespaceAroundHidPrefixes() { + // given + final List ids = List.of(Id.of(Id.EMAIL, "email"), Id.of(Id.PHONE, "phone")); + // when + final Query query = QueryBuilder.build( + ids, OptableAttributes.builder().build(), givenProperties(idPrefixOrder, " e , p ")); + // then + assertThat(query.getHid()).isEqualTo("&hid=e:email&hid=p:phone"); + } + + @Test + public void shouldAppendBundleAndVerToHidAttributesWithSpecialCharacters() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .app(App.of("com.example app", "1.2 3")) + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, attributes, properties()); + + // then + assertThat(query.getHidAttributes()) + .isEqualTo("&bundle=com.example+app&ver=1.2+3"); + } + + @Test + public void shouldAppendId5SignatureToHidAttributesWithSpecialCharacters() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .id5Signature("a b&c=d") + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, attributes, properties()); + + // then + assertThat(query.getHidAttributes()).isEqualTo("&id5_signature=a+b%26c%3Dd"); + } + + @Test + public void shouldAppendId5SignatureWhenPresent() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .id5Signature("signature") + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + assertThat(query).contains("&id5_signature=signature"); + } + + @Test + public void shouldNotAppendId5SignatureWhenNull() { + // given + final OptableAttributes attributes = OptableAttributes.builder().build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + assertThat(query).doesNotContain("&id5_signature="); + } + private OptableAttributes givenOptableAttributes() { return OptableAttributes.builder() .timeout(100L) @@ -119,4 +521,11 @@ private OptableAttributes givenOptableAttributes() { .gdprConsent("tcf") .build(); } + + private static OptableTargetingProperties givenProperties(String idPrefixOrder, String hidPrefixes) { + final OptableTargetingProperties properties = new OptableTargetingProperties(); + properties.setIdPrefixOrder(idPrefixOrder); + properties.setHidPrefixes(hidPrefixes); + return properties; + } } 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 index e624fa56a8c..db7542c3b32 100644 --- 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 @@ -4,6 +4,7 @@ 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; @@ -13,6 +14,8 @@ import org.prebid.server.hooks.modules.optable.targeting.v1.BaseOptableTest; 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 static org.assertj.core.api.Assertions.assertThat; @@ -170,4 +173,90 @@ public void shouldCacheEmptyResultWhenCircuitBreakerIsOn() { assertThat(result.getAudience()).isNull(); verify(cache, times(1)).put(any(), eq(targetingResult.result()), anyInt()); } + + @Test + public void shouldIncludeHidInCacheKey() { + // given + final Query query = Query.of("&id=e%3Aemail", "&hid=c:234", "&gdpr=1", ""); + final ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); + when(cache.get(keyCaptor.capture())).thenReturn(Future.succeededFuture(givenTargetingResult())); + + // when + target.getTargeting( + givenOptableTargetingProperties(true), + query, + List.of("8.8.8.8"), + "user agent", + timeout); + + // then + final String key = keyCaptor.getValue(); + assertThat(key).contains(URLEncoder.encode("&hid=c:234", StandardCharsets.UTF_8)); + } + + @Test + public void shouldIncludeHidAttributesInCacheKey() { + // given + final Query query = Query.of("&id=e%3Aemail", "", "&gdpr=1", "&bundle=com.example.app"); + final ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); + when(cache.get(keyCaptor.capture())).thenReturn(Future.succeededFuture(givenTargetingResult())); + + // when + target.getTargeting( + givenOptableTargetingProperties(true), + query, + List.of("8.8.8.8"), + "user agent", + timeout); + + // then + final String key = keyCaptor.getValue(); + assertThat(key).contains(URLEncoder.encode("&bundle=com.example.app", StandardCharsets.UTF_8)); + } + + @Test + public void shouldBuildCacheKeyWithAllComponents() { + // given + final Query query = Query.of("&id=e%3Aemail", "&hid=c:234", "&gdpr=1", "&id5_signature=sig"); + final ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); + when(cache.get(keyCaptor.capture())).thenReturn(Future.succeededFuture(givenTargetingResult())); + + // when + target.getTargeting( + givenOptableTargetingProperties("key", "accountId", "origin", true), + query, + List.of("8.8.8.8"), + "user agent", + timeout); + + // then + final String key = keyCaptor.getValue(); + assertThat(key).isEqualTo( + "accountId:origin:8.8.8.8:" + + URLEncoder.encode("&id=e%3Aemail", StandardCharsets.UTF_8) + + ":" + + URLEncoder.encode("&hid=c:234", StandardCharsets.UTF_8) + + ":" + + URLEncoder.encode("&id5_signature=sig", StandardCharsets.UTF_8)); + } + + @Test + public void shouldUseNullInCacheKeyWhenHidIsNull() { + // given + final Query query = Query.of("&id=e%3Aemail", null, "&gdpr=1", null); + final ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); + when(cache.get(keyCaptor.capture())).thenReturn(Future.succeededFuture(givenTargetingResult())); + + // when + target.getTargeting( + givenOptableTargetingProperties(true), + query, + List.of("8.8.8.8"), + "user agent", + timeout); + + // then + final String key = keyCaptor.getValue(); + assertThat(key).endsWith(":null:null"); + } } diff --git a/sample/configs/prebid-config-with-optable.yaml b/sample/configs/prebid-config-with-optable.yaml index d5a938f47ac..93ebd387348 100644 --- a/sample/configs/prebid-config-with-optable.yaml +++ b/sample/configs/prebid-config-with-optable.yaml @@ -13,9 +13,21 @@ adapters: improvedigital: enabled: true colossus: - enabled: true + enabled: true triplelift: enabled: true + adf: + enabled: true + yieldmo: + enabled: true + grid: + enabled: true + unruly: + enabled: true + epsilon: + enabled: true + thirtythreeacross: + enabled: true metrics: prefix: prebid cache: @@ -52,4 +64,4 @@ hooks: enabled: true modules: optable-targeting: - api-endpoint: https://na.edge.optable.co/v2/targeting?t={{TENANT}}&o={{ORIGIN}} + 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 index 7a533da3697..58238287f58 100644 --- a/sample/configs/sample-app-settings-optable.yaml +++ b/sample/configs/sample-app-settings-optable.yaml @@ -19,8 +19,18 @@ accounts: modules: optable-targeting: api-key: key - tenant: optable - origin: web-sdk-demo + tenant: prebidtest + origin: js-sdk + enrichment-percentage: 100 + bidder-enrichment-percentages: + appnexus: 75 + rubicon: 75 + pubmatic: 0 + improvedigital: 50 + enrich-web: true + enrich-app: true + id-prefix-order: "e,v,c" + hid-prefixes: "a,c,i6" ppid-mapping: { "pubcid.org": "c" } adserver-targeting: true cache: @@ -31,14 +41,27 @@ accounts: "endpoints": { "/openrtb2/auction": { "stages": { - "processed-auction-request": { + "raw-auction-request": { "groups": [ { - "timeout": 600, + "timeout": 50, "hook-sequence": [ { "module-code": "optable-targeting", - "hook-impl-code": "optable-targeting-processed-auction-request-hook" + "hook-impl-code": "optable-targeting-raw-auction-request-hook" + } + ] + } + ] + }, + "bidder-request": { + "groups": [ + { + "timeout": 500, + "hook-sequence": [ + { + "module-code": "optable-targeting", + "hook-impl-code": "optable-targeting-bidder-request-hook" } ] }