diff --git a/extra/bundle/pom.xml b/extra/bundle/pom.xml index 79cd00f31e6..ae47d8a620b 100644 --- a/extra/bundle/pom.xml +++ b/extra/bundle/pom.xml @@ -25,6 +25,11 @@ confiant-ad-quality ${project.version} + + org.prebid.server.hooks.modules + fiftyone-devicedetection + ${project.version} + org.prebid.server.hooks.modules ortb2-blocking diff --git a/extra/modules/fiftyone-devicedetection/README.md b/extra/modules/fiftyone-devicedetection/README.md new file mode 100644 index 00000000000..fbe254b28c1 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/README.md @@ -0,0 +1,181 @@ +# Overview + +51Degrees module enriches an incoming OpenRTB request [51Degrees Device Data](https://51degrees.com/documentation/_device_detection__overview.html). + +51Degrees module sets the following fields of the device object: `make`, `model`, `os`, `osv`, `h`, `w`, `ppi`, `pixelratio` - interested bidder adapters may use these fields as needed. In addition the module sets `device.ext.fiftyonedegrees_deviceId` to a permanent device ID which can be rapidly looked up in on premise data exposing over 250 properties including the device age, chip set, codec support, and price, operating system and app/browser versions, age, and embedded features. + +## Setup + +The 51Degrees module operates using a data file. You can get started with a free Lite data file that can be downloaded here: [https://github.com/51Degrees/device-detection-data/blob/main/51Degrees-LiteV4.1.hash](https://github.com/51Degrees/device-detection-data/blob/main/51Degrees-LiteV4.1.hash). The Lite file is capable of detecting limited device information, so if you need in-depth device data, please contact 51Degrees to obtain a license: [https://51degrees.com/contact-us](https://51degrees.com/contact-us?ContactReason=Free%20Trial). + +Put the data file in a file system location writable by the user that is running the Prebid Server module and specify that directory location in the configuration parameters. The location needs to be writable if you would like to enable [automatic data file updates](https://51degrees.com/documentation/_features__automatic_datafile_updates.html). + +## Configuration + +To start using current module you have to enable module and add `fiftyone-devicedetection-entrypoint-hook` and `fiftyone-devicedetection-raw-auction-request-hook` into hooks execution plan inside your yaml file: + +```yaml +hooks: + fiftyone-devicedetection: + enabled: true + host-execution-plan: > + { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "entrypoint": { + "groups": [ + { + "timeout": 100, + "hook-sequence": [ + { + "module-code": "fiftyone-devicedetection", + "hook-impl-code": "fiftyone-devicedetection-entrypoint-hook" + } + ] + } + ] + }, + "raw-auction-request": { + "groups": [ + { + "timeout": 100, + "hook-sequence": [ + { + "module-code": "fiftyone-devicedetection", + "hook-impl-code": "fiftyone-devicedetection-raw-auction-request-hook" + } + ] + } + ] + } + } + } + } + } +``` + +And configure + +## List of module configuration options + +- `account-filter` + - `allow-list` - _(list of strings)_ - A list of account IDs that are allowed to use this module. If empty, everyone is allowed. Full-string match is performed (whitespaces and capitalization matter). Defaults to empty. +- `data-file` + - `path` - _(string, **REQUIRED**)_ - The full path to the device detection data file. Sample file can be downloaded from [[data repo on GitHub](https://github.com/51Degrees/device-detection-data/blob/main/51Degrees-LiteV4.1.hash)]. + - `make-temp-copy` - _(boolean)_ - If true, the engine will create a temporary copy of the data file rather than using the data file directly. Defaults to false. + - `update` + - `auto` - _(boolean)_ - Enable/Disable auto update. Defaults to enabled. If enabled, the auto update system will automatically download and apply new data files for device detection. + - `on-startup` - _(boolean)_ - Enable/Disable update on startup. Defaults to enabled. If enabled, the auto update system will be used to check for an update before the device detection engine is created. If an update is available, it will be downloaded and applied before the pipeline is built and returned for use so this may take some time. + - `url` - _(string)_ - Configure the engine to use the specified URL when looking for an updated data file. Default is the 51Degrees update URL. + - `license-key` - _(string)_ - Set the license key used when checking for new device detection data files. Defaults to null. + - `watch-file-system` - _(boolean)_ - The DataUpdateService has the ability to watch a file on disk and refresh the engine as soon as that file is updated. This setting enables/disables that feature. Defaults to true. + - `polling-interval` - _(int, seconds)_ - Set the time between checks for a new data file made by the DataUpdateService in seconds. Default = 30 minutes. +- `performance` + - `profile` - _(string)_ - Set the performance profile for the device detection engine. Must be one of: LowMemory, MaxPerformance, HighPerformance, Balanced, BalancedTemp. Defaults to balanced. + - `concurrency` - _(int)_ - Set the expected number of concurrent operations using the engine. This sets the concurrency of the internal caches to avoid excessive locking. Default: 10. + - `difference` - _(int)_ - Set the maximum difference to allow when processing HTTP headers. The meaning of difference depends on the Device Detection API being used. The difference is the difference in hash value between the hash that was found, and the hash that is being searched for. By default this is 0. For more information see [51Degrees documentation](https://51degrees.com/documentation/_device_detection__hash.html). + - `allow-unmatched` - _(boolean)_ - If set to false, a non-matching User-Agent will result in properties without set values. + If set to true, a non-matching User-Agent will cause the 'default profiles' to be returned. This means that properties will always have values (i.e. no need to check .hasValue) but some may be inaccurate. By default, this is false. + - `drift` - _(int)_ - Set the maximum drift to allow when matching hashes. If the drift is exceeded, the result is considered invalid and values will not be returned. By default this is 0. For more information see [51Degrees documentation](https://51degrees.com/documentation/_device_detection__hash.html). + +```yaml +hooks: + modules: + fiftyone-devicedetection: + account-filter: + allow-list: [] # list of strings, account ids for enabled publishers, or empty for all + data-file: + path: ~ # string, REQUIRED, download the sample from https://github.com/51Degrees/device-detection-data/blob/main/51Degrees-LiteV4.1.hash or Enterprise from https://51degrees.com/pricing + make-temp-copy: ~ # boolean + update: + auto: ~ # boolean + on-startup: ~ # boolean + url: ~ # string + license-key: ~ # string + watch-file-system: ~ # boolean + polling-interval: ~ # int, seconds + performance: + profile: ~ # string, one of [LowMemory,MaxPerformance,HighPerformance,Balanced,BalancedTemp] + concurrency: ~ # int + difference: ~ # int + allow-unmatched: ~ # boolean + drift: ~ # int +``` + +Minimal sample (only required): + +```yaml + modules: + fiftyone-devicedetection: + data-file: + path: "51Degrees-LiteV4.1.hash" # string, REQUIRED, download the sample from https://github.com/51Degrees/device-detection-data/blob/main/51Degrees-LiteV4.1.hash or Enterprise from https://51degrees.com/pricing +``` + +## Running the demo + +1. Build the server bundle JAR as described in [[Build Project](../../../docs/build.md#build-project)], e.g. + +```bash +mvn clean package --file extra/pom.xml +``` + +2. Download `51Degrees-LiteV4.1.hash` from [[GitHub](https://github.com/51Degrees/device-detection-data/blob/main/51Degrees-LiteV4.1.hash)] and put it in the project root directory. + +```bash +curl -o 51Degrees-LiteV4.1.hash -L https://github.com/51Degrees/device-detection-data/raw/main/51Degrees-LiteV4.1.hash +``` + +3. Start server bundle JAR as described in [[Running project](../../../docs/run.md#running-project)], e.g. + +```bash +java -jar target/prebid-server-bundle.jar --spring.config.additional-location=sample/prebid-config-with-51d-dd.yaml +``` + +4. Run sample request against the server as described in [[requests/README](../../../sample/requests/README.txt)], e.g. + +```bash +curl http://localhost:8080/openrtb2/auction --data @extra/modules/fiftyone-devicedetection/sample-requests/data.json +``` + +5. See the `device` object be enriched + +```diff + "device": { +- "ua": "Mozilla/5.0 (Linux; Android 11; SM-G998W) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Mobile Safari/537.36" ++ "ua": "Mozilla/5.0 (Linux; Android 11; SM-G998W) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Mobile Safari/537.36", ++ "os": "Android", ++ "osv": "11.0", ++ "h": 3200, ++ "w": 1440, ++ "ext": { ++ "fiftyonedegrees_deviceId": "110698-102757-105219-0" ++ } + }, +``` + +[[Enterprise](https://51degrees.com/pricing)] files can provide even more information: + +```diff + "device": { + "ua": "Mozilla/5.0 (Linux; Android 11; SM-G998W) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Mobile Safari/537.36", ++ "devicetype": 1, ++ "make": "Samsung", ++ "model": "SM-G998W", + "os": "Android", + "osv": "11.0", + "h": 3200, + "w": 1440, ++ "ppi": 516, ++ "pxratio": 3.44, + "ext": { +- "fiftyonedegrees_deviceId": "110698-102757-105219-0" ++ "fiftyonedegrees_deviceId": "110698-102757-105219-18092" + } +``` + +## Maintainer contacts + +Any suggestions or questions can be directed to [support@51degrees.com](support@51degrees.com) e-mail. + +Or just open new [issue](https://github.com/prebid/prebid-server-java/issues/new) or [pull request](https://github.com/prebid/prebid-server-java/pulls) in this repository. \ No newline at end of file diff --git a/extra/modules/fiftyone-devicedetection/pom.xml b/extra/modules/fiftyone-devicedetection/pom.xml new file mode 100644 index 00000000000..556d5df1fe4 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/pom.xml @@ -0,0 +1,49 @@ + + + 4.0.0 + + + org.prebid.server.hooks.modules + all-modules + 3.5.0-SNAPSHOT + + + fiftyone-devicedetection + + fiftyone-devicedetection + 51Degrees Device Detection module + + + 4.4.94 + 1.2.13 + + + + + + com.51degrees + device-detection.hash.engine.on-premise + ${fiftyone-device-detection.version} + + + + + com.51degrees + device-detection + ${fiftyone-device-detection.version} + + + + ch.qos.logback + logback-classic + ${logback.version} + test + + + ch.qos.logback + logback-core + ${logback.version} + test + + + diff --git a/extra/modules/fiftyone-devicedetection/sample-requests/data.json b/extra/modules/fiftyone-devicedetection/sample-requests/data.json new file mode 100644 index 00000000000..c87b9876553 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/sample-requests/data.json @@ -0,0 +1,146 @@ +{ + "imp": + [ + { + "ext": + { + "data": + { + "adserver": + { + "name": "gam", + "adslot": "test" + }, + "pbadslot": "test", + "gpid": "test" + }, + "gpid": "test", + "prebid": + { + "bidder": + { + "appnexus": + { + "placement_id": 1, + "use_pmt_rule": false + } + }, + "adunitcode": "25e8ad9f-13a4-4404-ba74-f9eebff0e86c", + "floors": + { + "floorMin": 0.01 + } + } + }, + "id": "2529eeea-813e-4da6-838f-f91c28d64867", + "banner": + { + "topframe": 1, + "format": + [ + { + "w": 728, + "h": 90 + } + ], + "pos": 1 + }, + "bidfloor": 0.01, + "bidfloorcur": "USD" + } + ], + "site": + { + "domain": "test.com", + "publisher": + { + "domain": "test.com", + "id": "1" + }, + "page": "https://www.test.com/" + }, + "device": + { + "ua": "Mozilla/5.0 (Linux; Android 11; SM-G998W) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Mobile Safari/537.36" + }, + "id": "fc4670ce-4985-4316-a245-b43c885dc37a", + "test": 1, + "cur": + [ + "USD" + ], + "source": + { + "ext": + { + "schain": + { + "ver": "1.0", + "complete": 1, + "nodes": + [ + { + "asi": "example.com", + "sid": "1234", + "hp": 1 + } + ] + } + } + }, + "ext": + { + "prebid": + { + "cache": + { + "bids": + { + "returnCreative": true + }, + "vastxml": + { + "returnCreative": true + } + }, + "auctiontimestamp": 1698390609882, + "targeting": + { + "includewinners": true, + "includebidderkeys": false + }, + "schains": + [ + { + "bidders": + [ + "appnexus" + ], + "schain": + { + "ver": "1.0", + "complete": 1, + "nodes": + [ + { + "asi": "example.com", + "sid": "1234", + "hp": 1 + } + ] + } + } + ], + "floors": + { + "enabled": false, + "floorMin": 0.01, + "floorMinCur": "USD" + }, + "createtids": false + } + }, + "user": + {}, + "tmax": 1700 +} diff --git a/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/config/FiftyOneDeviceDetectionModuleConfiguration.java b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/config/FiftyOneDeviceDetectionModuleConfiguration.java new file mode 100644 index 00000000000..175bc5db1dc --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/config/FiftyOneDeviceDetectionModuleConfiguration.java @@ -0,0 +1,49 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.config; + +import fiftyone.devicedetection.DeviceDetectionPipelineBuilder; +import fiftyone.pipeline.core.flowelements.Pipeline; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.FiftyOneDeviceDetectionModule; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.core.DeviceEnricher; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.core.PipelineBuilder; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.hooks.FiftyOneDeviceDetectionEntrypointHook; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.hooks.FiftyOneDeviceDetectionRawAuctionRequestHook; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config.ModuleConfig; +import org.prebid.server.hooks.v1.Hook; +import org.prebid.server.hooks.v1.InvocationContext; +import org.prebid.server.hooks.v1.Module; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.Set; + +@Configuration +@ConditionalOnProperty(prefix = "hooks." + FiftyOneDeviceDetectionModule.CODE, name = "enabled", havingValue = "true") +public class FiftyOneDeviceDetectionModuleConfiguration { + @Bean + @ConfigurationProperties(prefix = "hooks.modules." + FiftyOneDeviceDetectionModule.CODE) + ModuleConfig moduleConfig() { + return new ModuleConfig(); + } + + @Bean + Pipeline pipeline(ModuleConfig moduleConfig) throws Exception { + return new PipelineBuilder(moduleConfig).build(new DeviceDetectionPipelineBuilder()); + } + + @Bean + DeviceEnricher deviceEnricher(Pipeline pipeline) { + return new DeviceEnricher(pipeline); + } + + @Bean + Module fiftyOneDeviceDetectionModule(ModuleConfig moduleConfig, DeviceEnricher deviceEnricher) { + final Set> hooks = Set.of( + new FiftyOneDeviceDetectionEntrypointHook(), + new FiftyOneDeviceDetectionRawAuctionRequestHook(moduleConfig.getAccountFilter(), deviceEnricher) + ); + + return new FiftyOneDeviceDetectionModule(hooks); + } +} diff --git a/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/boundary/CollectedEvidence.java b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/boundary/CollectedEvidence.java new file mode 100644 index 00000000000..d6ed6ab4f53 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/boundary/CollectedEvidence.java @@ -0,0 +1,14 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.model.boundary; + +import lombok.Builder; + +import java.util.Collection; +import java.util.Map; + +@Builder(toBuilder = true) +public record CollectedEvidence( + Collection> rawHeaders, + String deviceUA, + Map secureHeaders +) { +} diff --git a/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/AccountFilter.java b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/AccountFilter.java new file mode 100644 index 00000000000..20b22cc4e3d --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/AccountFilter.java @@ -0,0 +1,10 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config; + +import lombok.Data; + +import java.util.List; + +@Data +public final class AccountFilter { + List allowList; +} diff --git a/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/DataFile.java b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/DataFile.java new file mode 100644 index 00000000000..6cc0dc64b7e --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/DataFile.java @@ -0,0 +1,10 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config; + +import lombok.Data; + +@Data +public final class DataFile { + String path; + Boolean makeTempCopy; + DataFileUpdate update; +} diff --git a/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/DataFileUpdate.java b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/DataFileUpdate.java new file mode 100644 index 00000000000..2ae0655c59c --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/DataFileUpdate.java @@ -0,0 +1,13 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config; + +import lombok.Data; + +@Data +public final class DataFileUpdate { + Boolean auto; + Boolean onStartup; + String url; + String licenseKey; + Boolean watchFileSystem; + Integer pollingInterval; +} diff --git a/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/ModuleConfig.java b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/ModuleConfig.java new file mode 100644 index 00000000000..9783317ce5c --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/ModuleConfig.java @@ -0,0 +1,10 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config; + +import lombok.Data; + +@Data +public final class ModuleConfig { + AccountFilter accountFilter; + DataFile dataFile; + PerformanceConfig performance; +} diff --git a/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/PerformanceConfig.java b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/PerformanceConfig.java new file mode 100644 index 00000000000..088e25eae34 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/PerformanceConfig.java @@ -0,0 +1,12 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config; + +import lombok.Data; + +@Data +public final class PerformanceConfig { + String profile; + Integer concurrency; + Integer difference; + Boolean allowUnmatched; + Integer drift; +} diff --git a/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/FiftyOneDeviceDetectionModule.java b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/FiftyOneDeviceDetectionModule.java new file mode 100644 index 00000000000..5bc2b8e82ab --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/FiftyOneDeviceDetectionModule.java @@ -0,0 +1,23 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.v1; + +import org.prebid.server.hooks.v1.Hook; +import org.prebid.server.hooks.v1.InvocationContext; +import org.prebid.server.hooks.v1.Module; + +import java.util.Collection; + +public record FiftyOneDeviceDetectionModule( + Collection> hooks +) implements Module { + public static final String CODE = "fiftyone-devicedetection"; + + @Override + public String code() { + return CODE; + } + + @Override + public Collection> hooks() { + return hooks; + } +} diff --git a/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/DeviceEnricher.java b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/DeviceEnricher.java new file mode 100644 index 00000000000..8b34666efbf --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/DeviceEnricher.java @@ -0,0 +1,327 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.core; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.iab.openrtb.request.Device; +import fiftyone.devicedetection.shared.DeviceData; +import fiftyone.pipeline.core.data.FlowData; +import fiftyone.pipeline.core.flowelements.Pipeline; +import fiftyone.pipeline.engines.data.AspectPropertyValue; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.boundary.CollectedEvidence; +import org.prebid.server.model.UpdateResult; +import org.prebid.server.proto.openrtb.ext.request.ExtDevice; + +import jakarta.annotation.Nonnull; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +public class DeviceEnricher { + private static final String EXT_DEVICE_ID_KEY = "fiftyonedegrees_deviceId"; + + private final Pipeline pipeline; + + public DeviceEnricher(@Nonnull Pipeline pipeline) { + this.pipeline = Objects.requireNonNull(pipeline); + } + + public static boolean shouldSkipEnriching(Device device) { + return StringUtils.isNotEmpty(getDeviceId(device)); + } + + public EnrichmentResult populateDeviceInfo(Device device, CollectedEvidence collectedEvidence) throws Exception { + try (FlowData data = pipeline.createFlowData()) { + data.addEvidence(pickRelevantFrom(collectedEvidence)); + data.process(); + final DeviceData deviceData = data.get(DeviceData.class); + if (deviceData == null) { + return null; + } + final Device properDevice = Optional.ofNullable(device).orElseGet(() -> Device.builder().build()); + return patchDevice(properDevice, deviceData); + } + } + + private Map pickRelevantFrom(CollectedEvidence collectedEvidence) { + final Map evidence = new HashMap<>(); + + final String ua = collectedEvidence.deviceUA(); + if (StringUtils.isNotBlank(ua)) { + evidence.put("header.user-agent", ua); + } + final Map secureHeaders = collectedEvidence.secureHeaders(); + if (MapUtils.isNotEmpty(secureHeaders)) { + evidence.putAll(secureHeaders); + } + if (!evidence.isEmpty()) { + return evidence; + } + + Stream.ofNullable(collectedEvidence.rawHeaders()) + .flatMap(Collection::stream) + .forEach(rawHeader -> evidence.put("header." + rawHeader.getKey(), rawHeader.getValue())); + + return evidence; + } + + private EnrichmentResult patchDevice(Device device, DeviceData deviceData) { + final List updatedFields = new ArrayList<>(); + final Device.DeviceBuilder deviceBuilder = device.toBuilder(); + + final UpdateResult resolvedDeviceType = resolveDeviceType(device, deviceData); + if (resolvedDeviceType.isUpdated()) { + deviceBuilder.devicetype(resolvedDeviceType.getValue()); + updatedFields.add("devicetype"); + } + + final UpdateResult resolvedMake = resolveMake(device, deviceData); + if (resolvedMake.isUpdated()) { + deviceBuilder.make(resolvedMake.getValue()); + updatedFields.add("make"); + } + + final UpdateResult resolvedModel = resolveModel(device, deviceData); + if (resolvedModel.isUpdated()) { + deviceBuilder.model(resolvedModel.getValue()); + updatedFields.add("model"); + } + + final UpdateResult resolvedOs = resolveOs(device, deviceData); + if (resolvedOs.isUpdated()) { + deviceBuilder.os(resolvedOs.getValue()); + updatedFields.add("os"); + } + + final UpdateResult resolvedOsv = resolveOsv(device, deviceData); + if (resolvedOsv.isUpdated()) { + deviceBuilder.osv(resolvedOsv.getValue()); + updatedFields.add("osv"); + } + + final UpdateResult resolvedH = resolveH(device, deviceData); + if (resolvedH.isUpdated()) { + deviceBuilder.h(resolvedH.getValue()); + updatedFields.add("h"); + } + + final UpdateResult resolvedW = resolveW(device, deviceData); + if (resolvedW.isUpdated()) { + deviceBuilder.w(resolvedW.getValue()); + updatedFields.add("w"); + } + + final UpdateResult resolvedPpi = resolvePpi(device, deviceData); + if (resolvedPpi.isUpdated()) { + deviceBuilder.ppi(resolvedPpi.getValue()); + updatedFields.add("ppi"); + } + + final UpdateResult resolvedPixelRatio = resolvePixelRatio(device, deviceData); + if (resolvedPixelRatio.isUpdated()) { + deviceBuilder.pxratio(resolvedPixelRatio.getValue()); + updatedFields.add("pxratio"); + } + + final UpdateResult resolvedDeviceId = resolveDeviceId(device, deviceData); + if (resolvedDeviceId.isUpdated()) { + setDeviceId(deviceBuilder, device, resolvedDeviceId.getValue()); + updatedFields.add("ext." + EXT_DEVICE_ID_KEY); + } + + if (updatedFields.isEmpty()) { + return null; + } + + return EnrichmentResult.builder() + .enrichedDevice(deviceBuilder.build()) + .enrichedFields(updatedFields) + .build(); + } + + private UpdateResult resolveDeviceType(Device device, DeviceData deviceData) { + final Integer currentDeviceType = device.getDevicetype(); + if (isPositive(currentDeviceType)) { + return UpdateResult.unaltered(currentDeviceType); + } + + final String rawDeviceType = getSafe(deviceData, DeviceData::getDeviceType); + if (rawDeviceType == null) { + return UpdateResult.unaltered(currentDeviceType); + } + + final OrtbDeviceType properDeviceType = OrtbDeviceType.resolveFrom(rawDeviceType); + return properDeviceType != OrtbDeviceType.UNKNOWN + ? UpdateResult.updated(properDeviceType.ordinal()) + : UpdateResult.unaltered(currentDeviceType); + } + + private UpdateResult resolveMake(Device device, DeviceData deviceData) { + final String currentMake = device.getMake(); + if (StringUtils.isNotBlank(currentMake)) { + return UpdateResult.unaltered(currentMake); + } + + final String make = getSafe(deviceData, DeviceData::getHardwareVendor); + return StringUtils.isNotBlank(make) + ? UpdateResult.updated(make) + : UpdateResult.unaltered(currentMake); + } + + private UpdateResult resolveModel(Device device, DeviceData deviceData) { + final String currentModel = device.getModel(); + if (StringUtils.isNotBlank(currentModel)) { + return UpdateResult.unaltered(currentModel); + } + + final String model = getSafe(deviceData, DeviceData::getHardwareModel); + if (StringUtils.isNotBlank(model)) { + return UpdateResult.updated(model); + } + + final List names = getSafe(deviceData, DeviceData::getHardwareName); + return CollectionUtils.isNotEmpty(names) + ? UpdateResult.updated(String.join(",", names)) + : UpdateResult.unaltered(currentModel); + } + + private UpdateResult resolveOs(Device device, DeviceData deviceData) { + final String currentOs = device.getOs(); + if (StringUtils.isNotBlank(currentOs)) { + return UpdateResult.unaltered(currentOs); + } + + final String os = getSafe(deviceData, DeviceData::getPlatformName); + return StringUtils.isNotBlank(os) + ? UpdateResult.updated(os) + : UpdateResult.unaltered(currentOs); + } + + private UpdateResult resolveOsv(Device device, DeviceData deviceData) { + final String currentOsv = device.getOsv(); + if (StringUtils.isNotBlank(currentOsv)) { + return UpdateResult.unaltered(currentOsv); + } + + final String osv = getSafe(deviceData, DeviceData::getPlatformVersion); + return StringUtils.isNotBlank(osv) + ? UpdateResult.updated(osv) + : UpdateResult.unaltered(currentOsv); + } + + private UpdateResult resolveH(Device device, DeviceData deviceData) { + final Integer currentH = device.getH(); + if (isPositive(currentH)) { + return UpdateResult.unaltered(currentH); + } + + final Integer h = getSafe(deviceData, DeviceData::getScreenPixelsHeight); + return isPositive(h) + ? UpdateResult.updated(h) + : UpdateResult.unaltered(currentH); + } + + private UpdateResult resolveW(Device device, DeviceData deviceData) { + final Integer currentW = device.getW(); + if (isPositive(currentW)) { + return UpdateResult.unaltered(currentW); + } + + final Integer w = getSafe(deviceData, DeviceData::getScreenPixelsWidth); + return isPositive(w) + ? UpdateResult.updated(w) + : UpdateResult.unaltered(currentW); + } + + private UpdateResult resolvePpi(Device device, DeviceData deviceData) { + final Integer currentPpi = device.getPpi(); + if (isPositive(currentPpi)) { + return UpdateResult.unaltered(currentPpi); + } + + final Integer pixelsHeight = getSafe(deviceData, DeviceData::getScreenPixelsHeight); + if (pixelsHeight == null) { + return UpdateResult.unaltered(currentPpi); + } + + final Double inchesHeight = getSafe(deviceData, DeviceData::getScreenInchesHeight); + return isPositive(inchesHeight) + ? UpdateResult.updated((int) Math.round(pixelsHeight / inchesHeight)) + : UpdateResult.unaltered(currentPpi); + } + + private UpdateResult resolvePixelRatio(Device device, DeviceData deviceData) { + final BigDecimal currentPixelRatio = device.getPxratio(); + if (currentPixelRatio != null && currentPixelRatio.intValue() > 0) { + return UpdateResult.unaltered(currentPixelRatio); + } + + final Double rawRatio = getSafe(deviceData, DeviceData::getPixelRatio); + return isPositive(rawRatio) + ? UpdateResult.updated(BigDecimal.valueOf(rawRatio)) + : UpdateResult.unaltered(currentPixelRatio); + } + + private UpdateResult resolveDeviceId(Device device, DeviceData deviceData) { + final String currentDeviceId = getDeviceId(device); + if (StringUtils.isNotBlank(currentDeviceId)) { + return UpdateResult.unaltered(currentDeviceId); + } + + final String deviceID = getSafe(deviceData, DeviceData::getDeviceId); + return StringUtils.isNotBlank(deviceID) + ? UpdateResult.updated(deviceID) + : UpdateResult.unaltered(currentDeviceId); + } + + private static boolean isPositive(Integer value) { + return value != null && value > 0; + } + + private static boolean isPositive(Double value) { + return value != null && value > 0; + } + + private static String getDeviceId(Device device) { + final ExtDevice ext = device.getExt(); + if (ext == null) { + return null; + } + final JsonNode savedValue = ext.getProperty(EXT_DEVICE_ID_KEY); + return (savedValue != null && savedValue.isTextual()) ? savedValue.textValue() : null; + } + + private static void setDeviceId(Device.DeviceBuilder deviceBuilder, Device device, String deviceId) { + ExtDevice ext = null; + if (device != null) { + ext = device.getExt(); + } + if (ext == null) { + ext = ExtDevice.empty(); + } + ext.addProperty(EXT_DEVICE_ID_KEY, new TextNode(deviceId)); + deviceBuilder.ext(ext); + } + + private T getSafe(DeviceData deviceData, Function> propertyGetter) { + try { + final AspectPropertyValue propertyValue = propertyGetter.apply(deviceData); + if (propertyValue != null && propertyValue.hasValue()) { + return propertyValue.getValue(); + } + } catch (Exception e) { + // nop -- not interested in errors on getting missing values. + } + return null; + } +} + diff --git a/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/EnrichmentResult.java b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/EnrichmentResult.java new file mode 100644 index 00000000000..237846d679b --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/EnrichmentResult.java @@ -0,0 +1,13 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.core; + +import com.iab.openrtb.request.Device; +import lombok.Builder; + +import java.util.Collection; + +@Builder +public record EnrichmentResult( + Device enrichedDevice, + Collection enrichedFields +) { +} diff --git a/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/OrtbDeviceType.java b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/OrtbDeviceType.java new file mode 100644 index 00000000000..078279fb2a3 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/OrtbDeviceType.java @@ -0,0 +1,39 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.core; + +import java.util.Map; +import java.util.Optional; + +// https://github.com/InteractiveAdvertisingBureau/AdCOM/blob/main/AdCOM%20v1.0%20FINAL.md#list--device-types- +public enum OrtbDeviceType { + UNKNOWN, + MOBILE_TABLET, + PERSONAL_COMPUTER, + CONNECTED_TV, + PHONE, + TABLET, + CONNECTED_DEVICE, + SET_TOP_BOX, + OOH_DEVICE; + + private static final Map DEVICE_FIELD_MAPPING = Map.ofEntries( + Map.entry("Phone", OrtbDeviceType.PHONE), + Map.entry("Console", OrtbDeviceType.SET_TOP_BOX), + Map.entry("Desktop", OrtbDeviceType.PERSONAL_COMPUTER), + Map.entry("EReader", OrtbDeviceType.PERSONAL_COMPUTER), + Map.entry("IoT", OrtbDeviceType.CONNECTED_DEVICE), + Map.entry("Kiosk", OrtbDeviceType.OOH_DEVICE), + Map.entry("MediaHub", OrtbDeviceType.SET_TOP_BOX), + Map.entry("Mobile", OrtbDeviceType.MOBILE_TABLET), + Map.entry("Router", OrtbDeviceType.CONNECTED_DEVICE), + Map.entry("SmallScreen", OrtbDeviceType.CONNECTED_DEVICE), + Map.entry("SmartPhone", OrtbDeviceType.MOBILE_TABLET), + Map.entry("SmartSpeaker", OrtbDeviceType.CONNECTED_DEVICE), + Map.entry("SmartWatch", OrtbDeviceType.CONNECTED_DEVICE), + Map.entry("Tablet", OrtbDeviceType.TABLET), + Map.entry("Tv", OrtbDeviceType.CONNECTED_TV), + Map.entry("Vehicle Display", OrtbDeviceType.PERSONAL_COMPUTER)); + + public static OrtbDeviceType resolveFrom(String deviceType) { + return Optional.ofNullable(DEVICE_FIELD_MAPPING.get(deviceType)).orElse(UNKNOWN); + } +} diff --git a/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/PipelineBuilder.java b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/PipelineBuilder.java new file mode 100644 index 00000000000..2b10e932f5f --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/PipelineBuilder.java @@ -0,0 +1,203 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.core; + +import fiftyone.devicedetection.DeviceDetectionOnPremisePipelineBuilder; +import fiftyone.devicedetection.DeviceDetectionPipelineBuilder; +import fiftyone.pipeline.core.flowelements.Pipeline; +import fiftyone.pipeline.engines.Constants; +import fiftyone.pipeline.engines.services.DataUpdateServiceDefault; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config.DataFile; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config.DataFileUpdate; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config.ModuleConfig; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config.PerformanceConfig; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +public class PipelineBuilder { + private static final Collection PROPERTIES_USED = List.of( + "devicetype", + "hardwarevendor", + "hardwaremodel", + "hardwarename", + "platformname", + "platformversion", + "screenpixelsheight", + "screenpixelswidth", + "screeninchesheight", + "pixelratio", + + "BrowserName", + "BrowserVersion", + "IsCrawler", + + "BrowserVendor", + "PlatformVendor", + "Javascript", + "GeoLocation", + "HardwareModelVariants"); + + private final ModuleConfig moduleConfig; + + public PipelineBuilder(ModuleConfig moduleConfig) { + this.moduleConfig = moduleConfig; + } + + public Pipeline build(DeviceDetectionPipelineBuilder premadeBuilder) throws Exception { + final DataFile dataFile = moduleConfig.getDataFile(); + + final Boolean shouldMakeDataCopy = dataFile.getMakeTempCopy(); + final DeviceDetectionOnPremisePipelineBuilder builder = premadeBuilder.useOnPremise( + dataFile.getPath(), + BooleanUtils.isTrue(shouldMakeDataCopy)); + + applyUpdateOptions(builder, dataFile.getUpdate()); + applyPerformanceOptions(builder, moduleConfig.getPerformance()); + PROPERTIES_USED.forEach(builder::setProperty); + return builder.build(); + } + + private static void applyUpdateOptions(DeviceDetectionOnPremisePipelineBuilder pipelineBuilder, + DataFileUpdate updateConfig) { + if (updateConfig == null) { + return; + } + pipelineBuilder.setDataUpdateService(new DataUpdateServiceDefault()); + + resolveAutoUpdate(pipelineBuilder, updateConfig); + resolveUpdateOnStartup(pipelineBuilder, updateConfig); + resolveUpdateURL(pipelineBuilder, updateConfig); + resolveLicenseKey(pipelineBuilder, updateConfig); + resolveWatchFileSystem(pipelineBuilder, updateConfig); + resolveUpdatePollingInterval(pipelineBuilder, updateConfig); + } + + private static void resolveAutoUpdate( + DeviceDetectionOnPremisePipelineBuilder pipelineBuilder, + DataFileUpdate updateConfig) { + final Boolean auto = updateConfig.getAuto(); + if (auto != null) { + pipelineBuilder.setAutoUpdate(auto); + } + } + + private static void resolveUpdateOnStartup( + DeviceDetectionOnPremisePipelineBuilder pipelineBuilder, + DataFileUpdate updateConfig) { + final Boolean onStartup = updateConfig.getOnStartup(); + if (onStartup != null) { + pipelineBuilder.setDataUpdateOnStartup(onStartup); + } + } + + private static void resolveUpdateURL( + DeviceDetectionOnPremisePipelineBuilder pipelineBuilder, + DataFileUpdate updateConfig) { + final String url = updateConfig.getUrl(); + if (StringUtils.isNotEmpty(url)) { + pipelineBuilder.setDataUpdateUrl(url); + } + } + + private static void resolveLicenseKey( + DeviceDetectionOnPremisePipelineBuilder pipelineBuilder, + DataFileUpdate updateConfig) { + final String licenseKey = updateConfig.getLicenseKey(); + if (StringUtils.isNotEmpty(licenseKey)) { + pipelineBuilder.setDataUpdateLicenseKey(licenseKey); + } + } + + private static void resolveWatchFileSystem( + DeviceDetectionOnPremisePipelineBuilder pipelineBuilder, + DataFileUpdate updateConfig) { + final Boolean watchFileSystem = updateConfig.getWatchFileSystem(); + if (watchFileSystem != null) { + pipelineBuilder.setDataFileSystemWatcher(watchFileSystem); + } + } + + private static void resolveUpdatePollingInterval( + DeviceDetectionOnPremisePipelineBuilder pipelineBuilder, + DataFileUpdate updateConfig) { + final Integer pollingInterval = updateConfig.getPollingInterval(); + if (pollingInterval != null) { + pipelineBuilder.setUpdatePollingInterval(pollingInterval); + } + } + + private static void applyPerformanceOptions(DeviceDetectionOnPremisePipelineBuilder pipelineBuilder, + PerformanceConfig performanceConfig) { + if (performanceConfig == null) { + return; + } + resolvePerformanceProfile(pipelineBuilder, performanceConfig); + resolveConcurrency(pipelineBuilder, performanceConfig); + resolveDifference(pipelineBuilder, performanceConfig); + resolveAllowUnmatched(pipelineBuilder, performanceConfig); + resolveDrift(pipelineBuilder, performanceConfig); + } + + private static void resolvePerformanceProfile( + DeviceDetectionOnPremisePipelineBuilder pipelineBuilder, + PerformanceConfig performanceConfig) { + final String profile = performanceConfig.getProfile(); + if (StringUtils.isEmpty(profile)) { + return; + } + for (Constants.PerformanceProfiles nextProfile : Constants.PerformanceProfiles.values()) { + if (StringUtils.equalsIgnoreCase(nextProfile.name(), profile)) { + pipelineBuilder.setPerformanceProfile(nextProfile); + return; + } + } + throw new IllegalArgumentException( + "Invalid value for performance profile (" + + profile + + ") -- should be one of: " + + Arrays.stream(Constants.PerformanceProfiles.values()) + .map(Enum::name) + .collect(Collectors.joining(", ")) + ); + } + + private static void resolveConcurrency( + DeviceDetectionOnPremisePipelineBuilder pipelineBuilder, + PerformanceConfig performanceConfig) { + final Integer concurrency = performanceConfig.getConcurrency(); + if (concurrency != null) { + pipelineBuilder.setConcurrency(concurrency); + } + } + + private static void resolveDifference( + DeviceDetectionOnPremisePipelineBuilder pipelineBuilder, + PerformanceConfig performanceConfig) { + final Integer difference = performanceConfig.getDifference(); + if (difference != null) { + pipelineBuilder.setDifference(difference); + } + } + + private static void resolveAllowUnmatched( + DeviceDetectionOnPremisePipelineBuilder pipelineBuilder, + PerformanceConfig performanceConfig) { + final Boolean allowUnmatched = performanceConfig.getAllowUnmatched(); + if (allowUnmatched != null) { + pipelineBuilder.setAllowUnmatched(allowUnmatched); + } + } + + private static void resolveDrift( + DeviceDetectionOnPremisePipelineBuilder pipelineBuilder, + PerformanceConfig performanceConfig) { + final Integer drift = performanceConfig.getDrift(); + if (drift != null) { + pipelineBuilder.setDrift(drift); + } + } + +} diff --git a/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/SecureHeadersRetriever.java b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/SecureHeadersRetriever.java new file mode 100644 index 00000000000..142e789adc3 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/SecureHeadersRetriever.java @@ -0,0 +1,100 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.core; + +import com.iab.openrtb.request.BrandVersion; +import com.iab.openrtb.request.UserAgent; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import jakarta.annotation.Nonnull; + +public class SecureHeadersRetriever { + private SecureHeadersRetriever() { + } + + public static Map retrieveFrom(@Nonnull UserAgent userAgent) { + final Map secureHeaders = new HashMap<>(); + + final List versions = userAgent.getBrowsers(); + if (CollectionUtils.isNotEmpty(versions)) { + final String fullUA = brandListToString(versions); + secureHeaders.put("header.Sec-CH-UA", fullUA); + secureHeaders.put("header.Sec-CH-UA-Full-Version-List", fullUA); + } + + final BrandVersion platform = userAgent.getPlatform(); + if (platform != null) { + final String platformName = platform.getBrand(); + if (StringUtils.isNotBlank(platformName)) { + secureHeaders.put("header.Sec-CH-UA-Platform", toHeaderSafe(platformName)); + } + + final List platformVersions = platform.getVersion(); + if (CollectionUtils.isNotEmpty(platformVersions)) { + final StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append('"'); + appendVersionList(stringBuilder, platformVersions); + stringBuilder.append('"'); + secureHeaders.put("header.Sec-CH-UA-Platform-Version", stringBuilder.toString()); + } + } + + final Integer isMobile = userAgent.getMobile(); + if (isMobile != null) { + secureHeaders.put("header.Sec-CH-UA-Mobile", "?" + isMobile); + } + + final String architecture = userAgent.getArchitecture(); + if (StringUtils.isNotBlank(architecture)) { + secureHeaders.put("header.Sec-CH-UA-Arch", toHeaderSafe(architecture)); + } + + final String bitness = userAgent.getBitness(); + if (StringUtils.isNotBlank(bitness)) { + secureHeaders.put("header.Sec-CH-UA-Bitness", toHeaderSafe(bitness)); + } + + final String model = userAgent.getModel(); + if (StringUtils.isNotBlank(model)) { + secureHeaders.put("header.Sec-CH-UA-Model", toHeaderSafe(model)); + } + + return secureHeaders; + } + + private static String toHeaderSafe(String rawValue) { + return '"' + rawValue.replace("\"", "\\\"") + '"'; + } + + private static String brandListToString(List versions) { + final StringBuilder stringBuilder = new StringBuilder(); + for (BrandVersion nextBrandVersion : versions) { + final String brandName = nextBrandVersion.getBrand(); + if (brandName == null) { + continue; + } + if (!stringBuilder.isEmpty()) { + stringBuilder.append(", "); + } + stringBuilder.append(toHeaderSafe(brandName)); + stringBuilder.append(";v=\""); + appendVersionList(stringBuilder, nextBrandVersion.getVersion()); + stringBuilder.append('"'); + } + return stringBuilder.toString(); + } + + private static void appendVersionList(StringBuilder stringBuilder, List versions) { + if (CollectionUtils.isEmpty(versions)) { + return; + } + + stringBuilder.append(versions.getFirst()); + for (int i = 1; i < versions.size(); i++) { + stringBuilder.append('.'); + stringBuilder.append(versions.get(i)); + } + } +} diff --git a/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/hooks/FiftyOneDeviceDetectionEntrypointHook.java b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/hooks/FiftyOneDeviceDetectionEntrypointHook.java new file mode 100644 index 00000000000..9df4e2a0237 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/hooks/FiftyOneDeviceDetectionEntrypointHook.java @@ -0,0 +1,42 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.hooks; + +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.boundary.CollectedEvidence; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.model.ModuleContext; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.model.InvocationResultImpl; +import org.prebid.server.hooks.v1.InvocationAction; +import org.prebid.server.hooks.v1.InvocationContext; +import org.prebid.server.hooks.v1.InvocationResult; +import org.prebid.server.hooks.v1.InvocationStatus; +import org.prebid.server.hooks.v1.entrypoint.EntrypointHook; +import org.prebid.server.hooks.v1.entrypoint.EntrypointPayload; +import io.vertx.core.Future; + +public class FiftyOneDeviceDetectionEntrypointHook implements EntrypointHook { + private static final String CODE = "fiftyone-devicedetection-entrypoint-hook"; + + @Override + public String code() { + return CODE; + } + + @Override + public Future> call( + EntrypointPayload payload, + InvocationContext invocationContext) { + return Future.succeededFuture( + InvocationResultImpl.builder() + .status(InvocationStatus.success) + .action(InvocationAction.no_action) + .moduleContext( + ModuleContext + .builder() + .collectedEvidence( + CollectedEvidence + .builder() + .rawHeaders(payload.headers().entries()) + .build() + ) + .build()) + .build()); + } +} diff --git a/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/hooks/FiftyOneDeviceDetectionRawAuctionRequestHook.java b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/hooks/FiftyOneDeviceDetectionRawAuctionRequestHook.java new file mode 100644 index 00000000000..081177e8ca1 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/hooks/FiftyOneDeviceDetectionRawAuctionRequestHook.java @@ -0,0 +1,153 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.hooks; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.UserAgent; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.boundary.CollectedEvidence; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config.AccountFilter; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.core.DeviceEnricher; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.core.EnrichmentResult; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.core.SecureHeadersRetriever; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.model.ModuleContext; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.model.InvocationResultImpl; +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.hooks.v1.auction.RawAuctionRequestHook; +import io.vertx.core.Future; +import org.prebid.server.settings.model.Account; +import org.prebid.server.util.ObjectUtil; + +import java.util.List; +import java.util.Optional; + +public class FiftyOneDeviceDetectionRawAuctionRequestHook implements RawAuctionRequestHook { + private static final String CODE = "fiftyone-devicedetection-raw-auction-request-hook"; + + private final AccountFilter accountFilter; + private final DeviceEnricher deviceEnricher; + + public FiftyOneDeviceDetectionRawAuctionRequestHook(AccountFilter accountFilter, DeviceEnricher deviceEnricher) { + this.accountFilter = accountFilter; + this.deviceEnricher = deviceEnricher; + } + + @Override + public String code() { + return CODE; + } + + @Override + public Future> call(AuctionRequestPayload payload, + AuctionInvocationContext invocationContext) { + final ModuleContext oldModuleContext = (ModuleContext) invocationContext.moduleContext(); + + if (shouldSkipEnriching(payload, invocationContext)) { + return Future.succeededFuture( + InvocationResultImpl.builder() + .status(InvocationStatus.success) + .action(InvocationAction.no_action) + .moduleContext(oldModuleContext) + .build()); + } + + final ModuleContext moduleContext = addEvidenceToContext( + oldModuleContext, + payload.bidRequest()); + + return Future.succeededFuture( + InvocationResultImpl.builder() + .status(InvocationStatus.success) + .action(InvocationAction.update) + .payloadUpdate(freshPayload -> updatePayload(freshPayload, moduleContext.collectedEvidence())) + .moduleContext(moduleContext) + .build() + ); + } + + private boolean shouldSkipEnriching(AuctionRequestPayload payload, AuctionInvocationContext invocationContext) { + if (!isAccountAllowed(invocationContext)) { + return true; + } + final Device device = ObjectUtil.getIfNotNull(payload.bidRequest(), BidRequest::getDevice); + return device != null && DeviceEnricher.shouldSkipEnriching(device); + } + + private boolean isAccountAllowed(AuctionInvocationContext invocationContext) { + final List allowList = ObjectUtil.getIfNotNull(accountFilter, AccountFilter::getAllowList); + if (CollectionUtils.isEmpty(allowList)) { + return true; + } + return Optional.ofNullable(invocationContext) + .map(AuctionInvocationContext::auctionContext) + .map(AuctionContext::getAccount) + .map(Account::getId) + .filter(StringUtils::isNotBlank) + .map(allowList::contains) + .orElse(false); + } + + private ModuleContext addEvidenceToContext(ModuleContext moduleContext, BidRequest bidRequest) { + final CollectedEvidence.CollectedEvidenceBuilder evidenceBuilder = Optional.ofNullable(moduleContext) + .map(ModuleContext::collectedEvidence) + .map(CollectedEvidence::toBuilder) + .orElseGet(CollectedEvidence::builder); + + collectEvidence(evidenceBuilder, bidRequest); + + return Optional.ofNullable(moduleContext) + .map(ModuleContext::toBuilder) + .orElseGet(ModuleContext::builder) + .collectedEvidence(evidenceBuilder.build()) + .build(); + } + + private void collectEvidence(CollectedEvidence.CollectedEvidenceBuilder evidenceBuilder, BidRequest bidRequest) { + final Device device = ObjectUtil.getIfNotNull(bidRequest, BidRequest::getDevice); + if (device == null) { + return; + } + final String ua = device.getUa(); + if (ua != null) { + evidenceBuilder.deviceUA(ua); + } + final UserAgent sua = device.getSua(); + if (sua != null) { + evidenceBuilder.secureHeaders(SecureHeadersRetriever.retrieveFrom(sua)); + } + } + + private AuctionRequestPayload updatePayload(AuctionRequestPayload existingPayload, + CollectedEvidence collectedEvidence) { + final BidRequest currentRequest = existingPayload.bidRequest(); + try { + final BidRequest patchedRequest = enrichDevice(currentRequest, collectedEvidence); + return patchedRequest == null ? existingPayload : AuctionRequestPayloadImpl.of(patchedRequest); + } catch (Exception ignored) { + return existingPayload; + } + } + + private BidRequest enrichDevice(BidRequest bidRequest, CollectedEvidence collectedEvidence) throws Exception { + if (bidRequest == null) { + return null; + } + + final CollectedEvidence.CollectedEvidenceBuilder evidenceBuilder = collectedEvidence.toBuilder(); + collectEvidence(evidenceBuilder, bidRequest); + + final EnrichmentResult mergeResult = deviceEnricher.populateDeviceInfo( + bidRequest.getDevice(), + evidenceBuilder.build()); + return Optional.ofNullable(mergeResult) + .map(EnrichmentResult::enrichedDevice) + .map(mergedDevice -> bidRequest.toBuilder().device(mergedDevice).build()) + .orElse(null); + } +} diff --git a/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/model/InvocationResultImpl.java b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/model/InvocationResultImpl.java new file mode 100644 index 00000000000..ead75085974 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/model/InvocationResultImpl.java @@ -0,0 +1,24 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.model; + +import lombok.Builder; +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 java.util.List; + +@Builder +public record InvocationResultImpl( + InvocationStatus status, + String message, + InvocationAction action, + PayloadUpdate payloadUpdate, + List errors, + List warnings, + List debugMessages, + Object moduleContext, + Tags analyticsTags +) implements InvocationResult { +} diff --git a/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/model/ModuleContext.java b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/model/ModuleContext.java new file mode 100644 index 00000000000..2ec7af61bf5 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/model/ModuleContext.java @@ -0,0 +1,8 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.model; + +import lombok.Builder; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.boundary.CollectedEvidence; + +@Builder(toBuilder = true) +public record ModuleContext(CollectedEvidence collectedEvidence) { +} diff --git a/extra/modules/fiftyone-devicedetection/src/main/resources/module-config/fiftyone-devicedetection.yaml b/extra/modules/fiftyone-devicedetection/src/main/resources/module-config/fiftyone-devicedetection.yaml new file mode 100644 index 00000000000..c54ab0d86f8 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/main/resources/module-config/fiftyone-devicedetection.yaml @@ -0,0 +1,21 @@ +hooks: + modules: + fiftyone-devicedetection: + account-filter: + allow-list: [] # list of strings + data-file: + path: ~ # string, REQUIRED, download the sample from https://github.com/51Degrees/device-detection-data/blob/main/51Degrees-LiteV4.1.hash or Enterprise from https://51degrees.com/pricing + make-temp-copy: ~ # boolean + update: + auto: ~ # boolean + on-startup: ~ # boolean + url: ~ # string + license-key: ~ # string + watch-file-system: ~ # boolean + polling-interval: ~ # int, seconds + performance: + profile: ~ # string, one of [LowMemory,MaxPerformance,HighPerformance,Balanced,BalancedTemp] + concurrency: ~ # int + difference: ~ # int + allow-unmatched: ~ # boolean + drift: ~ # int diff --git a/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/AccountFilterTest.java b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/AccountFilterTest.java new file mode 100644 index 00000000000..d5891fbff19 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/AccountFilterTest.java @@ -0,0 +1,34 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config; + +import org.junit.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AccountFilterTest { + private static final List TEST_ALLOW_LIST = List.of( + "sister", + "cousin" + ); + + @Test + public void shouldReturnAllowList() { + // given + final AccountFilter accountFilter = new AccountFilter(); + accountFilter.setAllowList(TEST_ALLOW_LIST); + + // when and then + assertThat(accountFilter.getAllowList()).isEqualTo(TEST_ALLOW_LIST); + } + + @Test + public void shouldHaveDescription() { + // given + final AccountFilter accountFilter = new AccountFilter(); + accountFilter.setAllowList(TEST_ALLOW_LIST); + + // when and then + assertThat(accountFilter.toString()).isNotBlank(); + } +} diff --git a/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/DataFileTest.java b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/DataFileTest.java new file mode 100644 index 00000000000..c2a36957631 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/DataFileTest.java @@ -0,0 +1,57 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class DataFileTest { + @Test + public void shouldReturnPath() { + // given + final String path = "/path/to/file.txt"; + + // when + final DataFile dataFile = new DataFile(); + dataFile.setPath(path); + + // then + assertThat(dataFile.getPath()).isEqualTo(path); + } + + @Test + public void shouldReturnMakeTempCopy() { + // given + final boolean makeCopy = true; + + // when + final DataFile dataFile = new DataFile(); + dataFile.setMakeTempCopy(makeCopy); + + // then + assertThat(dataFile.getMakeTempCopy()).isEqualTo(makeCopy); + } + + @Test + public void shouldReturnUpdate() { + // given + final DataFileUpdate dataFileUpdate = new DataFileUpdate(); + dataFileUpdate.setUrl("www.void"); + + // when + final DataFile dataFile = new DataFile(); + dataFile.setUpdate(dataFileUpdate); + + // then + assertThat(dataFile.getUpdate()).isEqualTo(dataFileUpdate); + } + + @Test + public void shouldHaveDescription() { + // given + final DataFile dataFile = new DataFile(); + dataFile.setPath("/etc/null"); + + // when and then + assertThat(dataFile.toString()).isNotBlank(); + } +} diff --git a/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/DataFileUpdateTest.java b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/DataFileUpdateTest.java new file mode 100644 index 00000000000..75529f961b2 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/DataFileUpdateTest.java @@ -0,0 +1,95 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class DataFileUpdateTest { + @Test + public void shouldReturnAuto() { + // given + final boolean value = true; + + // when + final DataFileUpdate dataFileUpdate = new DataFileUpdate(); + dataFileUpdate.setAuto(value); + + // then + assertThat(dataFileUpdate.getAuto()).isEqualTo(value); + } + + @Test + public void shouldReturnOnStartup() { + // given + final boolean value = true; + + // when + final DataFileUpdate dataFileUpdate = new DataFileUpdate(); + dataFileUpdate.setOnStartup(value); + + // then + assertThat(dataFileUpdate.getOnStartup()).isEqualTo(value); + } + + @Test + public void shouldReturnUrl() { + // given + final String value = "/path/to/file.txt"; + + // when + final DataFileUpdate dataFileUpdate = new DataFileUpdate(); + dataFileUpdate.setUrl(value); + + // then + assertThat(dataFileUpdate.getUrl()).isEqualTo(value); + } + + @Test + public void shouldReturnLicenseKey() { + // given + final String value = "/path/to/file.txt"; + + // when + final DataFileUpdate dataFileUpdate = new DataFileUpdate(); + dataFileUpdate.setLicenseKey(value); + + // then + assertThat(dataFileUpdate.getLicenseKey()).isEqualTo(value); + } + + @Test + public void shouldReturnWatchFileSystem() { + // given + final boolean value = true; + + // when + final DataFileUpdate dataFileUpdate = new DataFileUpdate(); + dataFileUpdate.setWatchFileSystem(value); + + // then + assertThat(dataFileUpdate.getWatchFileSystem()).isEqualTo(value); + } + + @Test + public void shouldReturnPollingInterval() { + // given + final int value = 42; + + // when + final DataFileUpdate dataFileUpdate = new DataFileUpdate(); + dataFileUpdate.setPollingInterval(value); + + // then + assertThat(dataFileUpdate.getPollingInterval()).isEqualTo(value); + } + + @Test + public void shouldHaveDescription() { + // given + final DataFileUpdate dataFileUpdate = new DataFileUpdate(); + dataFileUpdate.setPollingInterval(29); + + // when and then + assertThat(dataFileUpdate.toString()).isNotBlank(); + } +} diff --git a/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/ModuleConfigTest.java b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/ModuleConfigTest.java new file mode 100644 index 00000000000..5465bb110d7 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/ModuleConfigTest.java @@ -0,0 +1,65 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config; + +import org.junit.Test; + +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ModuleConfigTest { + @Test + public void shouldReturnAccountFilter() { + // given + final AccountFilter accountFilter = new AccountFilter(); + accountFilter.setAllowList(Collections.singletonList("raccoon")); + + // when + final ModuleConfig moduleConfig = new ModuleConfig(); + moduleConfig.setAccountFilter(accountFilter); + + // then + assertThat(moduleConfig.getAccountFilter()).isEqualTo(accountFilter); + } + + @Test + public void shouldReturnDataFile() { + // given + final DataFile dataFile = new DataFile(); + dataFile.setPath("B:\\archive"); + + // when + final ModuleConfig moduleConfig = new ModuleConfig(); + moduleConfig.setDataFile(dataFile); + + // then + assertThat(moduleConfig.getDataFile()).isEqualTo(dataFile); + } + + @Test + public void shouldReturnPerformanceConfig() { + // given + final PerformanceConfig performanceConfig = new PerformanceConfig(); + performanceConfig.setProfile("SilentHunter"); + + // when + final ModuleConfig moduleConfig = new ModuleConfig(); + moduleConfig.setPerformance(performanceConfig); + + // then + assertThat(moduleConfig.getPerformance()).isEqualTo(performanceConfig); + } + + @Test + public void shouldHaveDescription() { + // given + final DataFile dataFile = new DataFile(); + dataFile.setPath("Z:\\virtual-drive"); + + // when + final ModuleConfig moduleConfig = new ModuleConfig(); + moduleConfig.setDataFile(dataFile); + + // when and then + assertThat(moduleConfig.toString()).isNotBlank(); + } +} diff --git a/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/PerformanceConfigTest.java b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/PerformanceConfigTest.java new file mode 100644 index 00000000000..c84a74fb7e2 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/model/config/PerformanceConfigTest.java @@ -0,0 +1,82 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class PerformanceConfigTest { + @Test + public void shouldReturnProfile() { + // given + final String profile = "TurtleSlow"; + + // when + final PerformanceConfig performanceConfig = new PerformanceConfig(); + performanceConfig.setProfile(profile); + + // then + assertThat(performanceConfig.getProfile()).isEqualTo(profile); + } + + @Test + public void shouldReturnConcurrency() { + // given + final int concurrency = 5438; + + // when + final PerformanceConfig performanceConfig = new PerformanceConfig(); + performanceConfig.setConcurrency(concurrency); + + // then + assertThat(performanceConfig.getConcurrency()).isEqualTo(concurrency); + } + + @Test + public void shouldReturnDifference() { + // given + final int difference = 5438; + + // when + final PerformanceConfig performanceConfig = new PerformanceConfig(); + performanceConfig.setDifference(difference); + + // then + assertThat(performanceConfig.getDifference()).isEqualTo(difference); + } + + @Test + public void shouldReturnAllowUnmatched() { + // given + final boolean allowUnmatched = true; + + // when + final PerformanceConfig performanceConfig = new PerformanceConfig(); + performanceConfig.setAllowUnmatched(allowUnmatched); + + // then + assertThat(performanceConfig.getAllowUnmatched()).isEqualTo(allowUnmatched); + } + + @Test + public void shouldReturnDrift() { + // given + final int drift = 8624; + + // when + final PerformanceConfig performanceConfig = new PerformanceConfig(); + performanceConfig.setDrift(drift); + + // then + assertThat(performanceConfig.getDrift()).isEqualTo(drift); + } + + @Test + public void shouldHaveDescription() { + // given and when + final PerformanceConfig performanceConfig = new PerformanceConfig(); + performanceConfig.setProfile("LightningFast"); + + // when and then + assertThat(performanceConfig.toString()).isNotBlank(); + } +} diff --git a/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/FiftyOneDeviceDetectionModuleTest.java b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/FiftyOneDeviceDetectionModuleTest.java new file mode 100644 index 00000000000..0fdfe798be8 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/FiftyOneDeviceDetectionModuleTest.java @@ -0,0 +1,32 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.v1; + +import org.junit.Test; +import org.prebid.server.hooks.v1.Hook; +import org.prebid.server.hooks.v1.InvocationContext; +import org.prebid.server.hooks.v1.Module; + +import java.util.Collection; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; + +public class FiftyOneDeviceDetectionModuleTest { + @Test + public void shouldReturnNonBlankCode() { + // given + final Module module = new FiftyOneDeviceDetectionModule(null); + + // when and then + assertThat(module.code()).isNotBlank(); + } + + @Test + public void shouldReturnSavedHooks() { + // given + final Collection> hooks = Collections.emptyList(); + final Module module = new FiftyOneDeviceDetectionModule(hooks); + + // when and then + assertThat(module.hooks()).isEqualTo(hooks); + } +} diff --git a/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/DeviceEnricherTest.java b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/DeviceEnricherTest.java new file mode 100644 index 00000000000..79a4765a135 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/DeviceEnricherTest.java @@ -0,0 +1,644 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.core; + +import com.fasterxml.jackson.databind.node.TextNode; +import com.iab.openrtb.request.Device; +import fiftyone.devicedetection.shared.DeviceData; +import fiftyone.pipeline.core.data.FlowData; +import fiftyone.pipeline.core.flowelements.Pipeline; +import fiftyone.pipeline.engines.data.AspectPropertyValue; +import fiftyone.pipeline.engines.exceptions.NoValueException; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.boundary.CollectedEvidence; +import org.prebid.server.proto.openrtb.ext.request.ExtDevice; + +import java.math.BigDecimal; +import java.util.AbstractMap; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class DeviceEnricherTest { + @Rule + public final MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock + private Pipeline pipeline; + + @Mock + private FlowData flowData; + + @Mock + private DeviceData deviceData; + + private DeviceEnricher target; + + @Before + public void setUp() { + when(pipeline.createFlowData()).thenReturn(flowData); + when(flowData.get(DeviceData.class)).thenReturn(deviceData); + target = new DeviceEnricher(pipeline); + } + + @Test + public void shouldSkipEnrichingShouldReturnFalseWhenExtIsNull() { + // given + final Device device = Device.builder().build(); + + // when and then + assertThat(DeviceEnricher.shouldSkipEnriching(device)).isFalse(); + } + + @Test + public void shouldSkipEnrichingShouldReturnFalseWhenExtIsEmpty() { + // given + final ExtDevice ext = ExtDevice.empty(); + final Device device = Device.builder().ext(ext).build(); + + // when and then + assertThat(DeviceEnricher.shouldSkipEnriching(device)).isFalse(); + } + + @Test + public void shouldSkipEnrichingShouldReturnTrueWhenExtContainsProfileID() { + // given + final ExtDevice ext = ExtDevice.empty(); + ext.addProperty("fiftyonedegrees_deviceId", new TextNode("0-0-0-0")); + final Device device = Device.builder().ext(ext).build(); + + // when and then + assertThat(DeviceEnricher.shouldSkipEnriching(device)).isTrue(); + } + + @Test + public void populateDeviceInfoShouldReportErrorWhenPipelineThrowsException() { + // given + final Exception e = new RuntimeException(); + when(pipeline.createFlowData()).thenThrow(e); + + // when and then + assertThatThrownBy(() -> target.populateDeviceInfo(null, null)).isEqualTo(e); + } + + @Test + public void populateDeviceInfoShouldReportErrorWhenProcessThrowsException() { + // given + final Exception e = new RuntimeException(); + doThrow(e).when(flowData).process(); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder().build(); + + // when and then + assertThatThrownBy(() -> target.populateDeviceInfo(null, collectedEvidence)).isEqualTo(e); + } + + @Test + public void populateDeviceInfoShouldReturnNullWhenDeviceDataIsNull() throws Exception { + // given + when(flowData.get(DeviceData.class)).thenReturn(null); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder().build(); + + // when + final EnrichmentResult result = target.populateDeviceInfo( + null, + collectedEvidence); + + // then + assertThat(result).isNull(); + verify(flowData, times(1)).get(DeviceData.class); + } + + @Test + public void populateDeviceInfoShouldPassToFlowDataHeadersMadeFromSuaWhenPresent() throws Exception { + // given + final Map secureHeaders = Collections.singletonMap("ua", "fake-ua"); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .secureHeaders(secureHeaders) + .rawHeaders(Collections.singletonMap("ua", "zumba").entrySet()) + .build(); + + // when + target.populateDeviceInfo(null, collectedEvidence); + + // then + final ArgumentCaptor> evidenceCaptor = ArgumentCaptor.forClass(Map.class); + verify(flowData).addEvidence(evidenceCaptor.capture()); + final Map evidence = evidenceCaptor.getValue(); + + assertThat(evidence).isNotSameAs(secureHeaders); + assertThat(evidence).containsExactlyEntriesOf(secureHeaders); + } + + @Test + public void populateDeviceInfoShouldPassToFlowDataHeadersMadeFromUaWhenNoSuaPresent() throws Exception { + // given + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .deviceUA("dummy-ua") + .rawHeaders(Collections.singletonMap("ua", "zumba").entrySet()) + .build(); + + // when + target.populateDeviceInfo(null, collectedEvidence); + + // then + final ArgumentCaptor> evidenceCaptor = ArgumentCaptor.forClass(Map.class); + verify(flowData).addEvidence(evidenceCaptor.capture()); + final Map evidence = evidenceCaptor.getValue(); + + assertThat(evidence.size()).isEqualTo(1); + final Map.Entry evidenceFragment = evidence.entrySet().stream().findFirst().get(); + assertThat(evidenceFragment.getKey()).isEqualTo("header.user-agent"); + assertThat(evidenceFragment.getValue()).isEqualTo(collectedEvidence.deviceUA()); + } + + @Test + public void populateDeviceInfoShouldPassToFlowDataMergedHeadersMadeFromUaAndSuaWhenBothPresent() throws Exception { + // given + final Map suaHeaders = Collections.singletonMap("ua", "fake-ua"); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .secureHeaders(suaHeaders) + .deviceUA("dummy-ua") + .rawHeaders(Collections.singletonMap("ua", "zumba").entrySet()) + .build(); + + // when + target.populateDeviceInfo(null, collectedEvidence); + + // then + final ArgumentCaptor> evidenceCaptor = ArgumentCaptor.forClass(Map.class); + verify(flowData).addEvidence(evidenceCaptor.capture()); + final Map evidence = evidenceCaptor.getValue(); + + assertThat(evidence).isNotEqualTo(suaHeaders); + assertThat(evidence).containsAllEntriesOf(suaHeaders); + assertThat(evidence).containsEntry("header.user-agent", collectedEvidence.deviceUA()); + assertThat(evidence.size()).isEqualTo(suaHeaders.size() + 1); + } + + @Test + public void populateDeviceInfoShouldPassToFlowDataRawHeaderWhenNoDeviceInfoPresent() throws Exception { + // given + final List> rawHeaders = List.of( + new AbstractMap.SimpleEntry<>("ua", "zumba"), + new AbstractMap.SimpleEntry<>("sec-ua", "astrolabe") + ); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .rawHeaders(rawHeaders) + .build(); + + // when + target.populateDeviceInfo(null, collectedEvidence); + + // then + final ArgumentCaptor> evidenceCaptor = ArgumentCaptor.forClass(Map.class); + verify(flowData).addEvidence(evidenceCaptor.capture()); + final Map evidence = evidenceCaptor.getValue(); + + final List> evidenceFragments = evidence.entrySet().stream().toList(); + assertThat(evidenceFragments.size()).isEqualTo(rawHeaders.size()); + for (int i = 0, n = rawHeaders.size(); i < n; ++i) { + final Map.Entry rawEntry = rawHeaders.get(i); + final Map.Entry newEntry = evidenceFragments.get(i); + assertThat(newEntry.getKey()).isEqualTo("header." + rawEntry.getKey()); + assertThat(newEntry.getValue()).isEqualTo(rawEntry.getValue()); + } + } + + @Test + public void populateDeviceInfoShouldPassToFlowDataLatestRawHeaderWhenMultiplePresentWithSameKey() throws Exception { + // given + final String theKey = "ua"; + final List> rawHeaders = List.of( + new AbstractMap.SimpleEntry<>(theKey, "zumba"), + new AbstractMap.SimpleEntry<>(theKey, "astrolabe") + ); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .rawHeaders(rawHeaders) + .build(); + + // when + target.populateDeviceInfo(null, collectedEvidence); + + // then + final ArgumentCaptor> evidenceCaptor = ArgumentCaptor.forClass(Map.class); + verify(flowData).addEvidence(evidenceCaptor.capture()); + final Map evidence = evidenceCaptor.getValue(); + + final List> evidenceFragments = evidence.entrySet().stream().toList(); + assertThat(evidenceFragments.size()).isEqualTo(1); + assertThat(evidenceFragments.get(0).getValue()).isEqualTo(rawHeaders.get(1).getValue()); + } + + @Test + public void populateDeviceInfoShouldPassToFlowDataEmptyMapWhenNoEvidenceToPick() throws Exception { + // given + final CollectedEvidence collectedEvidence = CollectedEvidence.builder().build(); + + // when + target.populateDeviceInfo(null, collectedEvidence); + + // then + final ArgumentCaptor> evidenceCaptor = ArgumentCaptor.forClass(Map.class); + verify(flowData).addEvidence(evidenceCaptor.capture()); + final Map evidence = evidenceCaptor.getValue(); + + assertThat(evidence).isNotNull(); + assertThat(evidence).isEmpty(); + } + + @Test + public void populateDeviceInfoShouldEnrichAllPropertiesWhenDeviceIsEmpty() throws Exception { + // given + final Device device = Device.builder().build(); + + // when + buildCompleteDeviceData(); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .deviceUA("fake-UserAgent") + .build(); + final EnrichmentResult result = target.populateDeviceInfo(device, collectedEvidence); + + // then + assertThat(result.enrichedFields()).containsExactly( + "devicetype", + "make", + "model", + "os", + "osv", + "h", + "w", + "ppi", + "pxratio", + "ext.fiftyonedegrees_deviceId" + ); + } + + @Test + public void populateDeviceInfoShouldReturnNullWhenDeviceIsFull() throws Exception { + // given and when + buildCompleteDeviceData(); + final Device device = buildCompleteDevice(); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .deviceUA("fake-UserAgent") + .build(); + final EnrichmentResult result = target.populateDeviceInfo(device, collectedEvidence); + + // then + assertThat(result).isNull(); + } + + @Test + public void populateDeviceInfoShouldEnrichDeviceTypeWhenItIsMissing() throws Exception { + // given + final Device testDevice = buildCompleteDevice().toBuilder() + .devicetype(null) + .build(); + + // when + buildCompleteDeviceData(); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .deviceUA("fake-UserAgent") + .build(); + final EnrichmentResult result = target.populateDeviceInfo(testDevice, collectedEvidence); + + // then + assertThat(result.enrichedFields()).hasSize(1); + assertThat(result.enrichedDevice().getDevicetype()).isEqualTo(buildCompleteDevice().getDevicetype()); + } + + @Test + public void populateDeviceInfoShouldEnrichMakeWhenItIsMissing() throws Exception { + // given + final Device testDevice = buildCompleteDevice().toBuilder() + .make(null) + .build(); + + // when + buildCompleteDeviceData(); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .deviceUA("fake-UserAgent") + .build(); + final EnrichmentResult result = target.populateDeviceInfo(testDevice, collectedEvidence); + + // then + assertThat(result.enrichedFields()).hasSize(1); + assertThat(result.enrichedDevice().getMake()).isEqualTo(buildCompleteDevice().getMake()); + } + + @Test + public void populateDeviceInfoShouldEnrichModelWithHWNameWhenHWModelIsMissing() throws Exception { + // given + final Device testDevice = buildCompleteDevice().toBuilder() + .model(null) + .build(); + final String expectedModel = "NinjaTech8888"; + when(deviceData.getHardwareName()) + .thenReturn(aspectPropertyValueWith(Collections.singletonList(expectedModel))); + when(deviceData.getHardwareModel()).thenThrow(new RuntimeException()); + + // when + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .deviceUA("fake-UserAgent") + .build(); + final EnrichmentResult result = target.populateDeviceInfo(testDevice, collectedEvidence); + + // then + assertThat(result.enrichedFields()).hasSize(1); + assertThat(result.enrichedDevice().getModel()).isEqualTo(expectedModel); + } + + @Test + public void populateDeviceInfoShouldEnrichModelWhenItIsMissing() throws Exception { + // given + final Device testDevice = buildCompleteDevice().toBuilder() + .model(null) + .build(); + + // when + buildCompleteDeviceData(); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .deviceUA("fake-UserAgent") + .build(); + final EnrichmentResult result = target.populateDeviceInfo(testDevice, collectedEvidence); + + // then + assertThat(result.enrichedFields()).hasSize(1); + assertThat(result.enrichedDevice().getModel()).isEqualTo(buildCompleteDevice().getModel()); + } + + @Test + public void populateDeviceInfoShouldEnrichOsWhenItIsMissing() throws Exception { + // given + final Device testDevice = buildCompleteDevice().toBuilder() + .os(null) + .build(); + + // when + buildCompleteDeviceData(); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .deviceUA("fake-UserAgent") + .build(); + final EnrichmentResult result = target.populateDeviceInfo(testDevice, collectedEvidence); + + // then + assertThat(result.enrichedFields()).hasSize(1); + assertThat(result.enrichedDevice().getOs()).isEqualTo(buildCompleteDevice().getOs()); + } + + @Test + public void populateDeviceInfoShouldEnrichOsvWhenItIsMissing() throws Exception { + // given + final Device testDevice = buildCompleteDevice().toBuilder() + .osv(null) + .build(); + + // when + buildCompleteDeviceData(); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .deviceUA("fake-UserAgent") + .build(); + final EnrichmentResult result = target.populateDeviceInfo(testDevice, collectedEvidence); + + // then + assertThat(result.enrichedFields()).hasSize(1); + assertThat(result.enrichedDevice().getOsv()).isEqualTo(buildCompleteDevice().getOsv()); + } + + @Test + public void populateDeviceInfoShouldEnrichHWhenItIsMissing() throws Exception { + // given + final Device testDevice = buildCompleteDevice().toBuilder() + .h(null) + .build(); + + // when + buildCompleteDeviceData(); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .deviceUA("fake-UserAgent") + .build(); + final EnrichmentResult result = target.populateDeviceInfo(testDevice, collectedEvidence); + + // then + assertThat(result.enrichedFields()).hasSize(1); + assertThat(result.enrichedDevice().getH()).isEqualTo(buildCompleteDevice().getH()); + } + + @Test + public void populateDeviceInfoShouldEnrichWWhenItIsMissing() throws Exception { + // given + final Device testDevice = buildCompleteDevice().toBuilder() + .w(null) + .build(); + + // when + buildCompleteDeviceData(); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .deviceUA("fake-UserAgent") + .build(); + final EnrichmentResult result = target.populateDeviceInfo(testDevice, collectedEvidence); + + // then + assertThat(result.enrichedFields()).hasSize(1); + assertThat(result.enrichedDevice().getW()).isEqualTo(buildCompleteDevice().getW()); + } + + @Test + public void populateDeviceInfoShouldEnrichPpiWhenItIsMissing() throws Exception { + // given + final Device testDevice = buildCompleteDevice().toBuilder() + .ppi(null) + .build(); + + // when + buildCompleteDeviceData(); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .deviceUA("fake-UserAgent") + .build(); + final EnrichmentResult result = target.populateDeviceInfo(testDevice, collectedEvidence); + + // then + assertThat(result.enrichedFields()).hasSize(1); + assertThat(result.enrichedDevice().getPpi()).isEqualTo(buildCompleteDevice().getPpi()); + } + + @Test + public void populateDeviceInfoShouldReturnNullWhenScreenInchesHeightIsZero() throws Exception { + // given + final Device testDevice = buildCompleteDevice().toBuilder() + .ppi(null) + .build(); + + // when + buildCompleteDeviceData(); + when(deviceData.getScreenInchesHeight()).thenReturn(aspectPropertyValueWith(0.0)); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .deviceUA("fake-UserAgent") + .build(); + final EnrichmentResult result = target.populateDeviceInfo(testDevice, collectedEvidence); + + // then + assertThat(result).isNull(); + } + + @Test + public void populateDeviceInfoShouldEnrichPXRatioWhenItIsMissing() throws Exception { + // given + final Device testDevice = buildCompleteDevice().toBuilder() + .pxratio(null) + .build(); + + // when + buildCompleteDeviceData(); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .deviceUA("fake-UserAgent") + .build(); + final EnrichmentResult result = target.populateDeviceInfo(testDevice, collectedEvidence); + + // then + assertThat(result.enrichedFields()).hasSize(1); + assertThat(result.enrichedDevice().getPxratio()).isEqualTo(buildCompleteDevice().getPxratio()); + } + + @Test + public void populateDeviceInfoShouldEnrichDeviceIDWhenItIsMissing() throws Exception { + // given + final Device testDevice = buildCompleteDevice().toBuilder() + .ext(null) + .build(); + + // when + buildCompleteDeviceData(); + final CollectedEvidence collectedEvidence = CollectedEvidence.builder() + .deviceUA("fake-UserAgent") + .build(); + final EnrichmentResult result = target.populateDeviceInfo(testDevice, collectedEvidence); + + // then + assertThat(result.enrichedFields()).hasSize(1); + assertThat(result.enrichedDevice().getExt().getProperty("fiftyonedegrees_deviceId").textValue()) + .isEqualTo("fake-device-id"); + } + + private static Device buildCompleteDevice() { + final Device device = Device.builder() + .devicetype(1) + .make("StarFleet") + .model("communicator") + .os("NeutronAI") + .osv("X-502") + .h(5051) + .w(3001) + .ppi(1010) + .pxratio(BigDecimal.valueOf(1.5)) + .ext(ExtDevice.empty()) + .build(); + device.getExt().addProperty("fiftyonedegrees_deviceId", new TextNode("fake-device-id")); + return device; + } + + private void buildCompleteDeviceData() { + when(deviceData.getDeviceType()).thenReturn(aspectPropertyValueWith("Mobile")); + when(deviceData.getHardwareVendor()).thenReturn(aspectPropertyValueWith("StarFleet")); + when(deviceData.getHardwareModel()).thenReturn(aspectPropertyValueWith("communicator")); + when(deviceData.getPlatformName()).thenReturn(aspectPropertyValueWith("NeutronAI")); + when(deviceData.getPlatformVersion()).thenReturn(aspectPropertyValueWith("X-502")); + when(deviceData.getScreenPixelsHeight()).thenReturn(aspectPropertyValueWith(5051)); + when(deviceData.getScreenPixelsWidth()).thenReturn(aspectPropertyValueWith(3001)); + when(deviceData.getScreenInchesHeight()).thenReturn(aspectPropertyValueWith(5.0)); + when(deviceData.getPixelRatio()).thenReturn(aspectPropertyValueWith(1.5)); + when(deviceData.getDeviceId()).thenReturn(aspectPropertyValueWith("fake-device-id")); + } + + @Test + public void populateDeviceInfoShouldEnrichDeviceTypeWithFourWhenDeviceTypeStringIsPhone() throws Exception { + // given + final String typeString = "Phone"; + + // when + when(deviceData.getDeviceType()).thenReturn(aspectPropertyValueWith(typeString)); + final EnrichmentResult result = target.populateDeviceInfo( + null, + CollectedEvidence.builder() + .deviceUA("fake-UserAgent") + .build()); + final Integer foundValue = result.enrichedDevice().getDevicetype(); + + // then + assertThat(foundValue).isEqualTo(4); + } + + @Test + public void populateDeviceInfoShouldEnrichDeviceTypeWithSevenWhenDeviceTypeStringIsMediaHub() throws Exception { + // given + final String typeString = "MediaHub"; + + // when + when(deviceData.getDeviceType()).thenReturn(aspectPropertyValueWith(typeString)); + final EnrichmentResult result = target.populateDeviceInfo( + null, + CollectedEvidence.builder() + .deviceUA("fake-UserAgent") + .build()); + final Integer foundValue = result.enrichedDevice().getDevicetype(); + + // then + assertThat(foundValue).isEqualTo(7); + } + + @Test + public void populateDeviceInfoShouldReturnNullWhenDeviceTypeStringIsUnexpected() throws Exception { + // given + final String typeString = "BattleStar Atlantis"; + + // when + when(deviceData.getDeviceType()).thenReturn(aspectPropertyValueWith(typeString)); + final EnrichmentResult result = target.populateDeviceInfo( + null, + CollectedEvidence.builder() + .deviceUA("fake-UserAgent") + .build()); + + // then + assertThat(result).isNull(); + } + + private static AspectPropertyValue aspectPropertyValueWith(T value) { + return new AspectPropertyValue<>() { + @Override + public boolean hasValue() { + return true; + } + + @Override + public T getValue() throws NoValueException { + return value; + } + + @Override + public void setValue(T t) { + throw new UnsupportedOperationException(); + } + + @Override + public String getNoValueMessage() { + throw new UnsupportedOperationException(); + } + + @Override + public void setNoValueMessage(String s) { + throw new UnsupportedOperationException(); + } + }; + } +} diff --git a/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/PipelineBuilderTest.java b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/PipelineBuilderTest.java new file mode 100644 index 00000000000..8c3b5012a52 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/PipelineBuilderTest.java @@ -0,0 +1,307 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.core; + +import fiftyone.devicedetection.DeviceDetectionOnPremisePipelineBuilder; +import fiftyone.devicedetection.DeviceDetectionPipelineBuilder; +import fiftyone.pipeline.core.flowelements.Pipeline; +import fiftyone.pipeline.engines.Constants; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config.DataFile; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config.DataFileUpdate; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config.ModuleConfig; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config.PerformanceConfig; + +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class PipelineBuilderTest { + @Rule + public final MockitoRule mockitoRule = MockitoJUnit.rule(); + + private ModuleConfig moduleConfig; + private DataFileUpdate dataFileUpdate; + private PerformanceConfig performanceConfig; + + @Mock + private DeviceDetectionPipelineBuilder builderPrime; + @Mock + private DeviceDetectionOnPremisePipelineBuilder builder; + @Mock + private Pipeline pipeline; + + @Before + public void setUp() throws Exception { + dataFileUpdate = new DataFileUpdate(); + performanceConfig = new PerformanceConfig(); + moduleConfig = new ModuleConfig(); + moduleConfig.setDataFile(new DataFile()); + moduleConfig.getDataFile().setUpdate(dataFileUpdate); + moduleConfig.setPerformance(performanceConfig); + when(builderPrime.useOnPremise(any(), anyBoolean())).thenReturn(builder); + when(builder.build()).thenReturn(pipeline); + } + + @Test + public void buildShouldIgnoreEmptyUrl() throws Exception { + // given + dataFileUpdate.setUrl(""); + + // when + new PipelineBuilder(moduleConfig).build(builderPrime); + + // then + verify(builder, never()).setPerformanceProfile(any()); + } + + @Test + public void buildShouldAssignURL() throws Exception { + // given + dataFileUpdate.setUrl("http://void/"); + + final ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(String.class); + + // when + new PipelineBuilder(moduleConfig).build(builderPrime); + + // then + verify(builder).setDataUpdateUrl(argumentCaptor.capture()); + assertThat(argumentCaptor.getAllValues()).containsExactly(dataFileUpdate.getUrl()); + } + + @Test + public void buildShouldIgnoreEmptyLicenseKey() throws Exception { + // given + dataFileUpdate.setLicenseKey(""); + + // when + new PipelineBuilder(moduleConfig).build(builderPrime); + + // then + verify(builder, never()).setDataUpdateLicenseKey(any()); + } + + @Test + public void buildShouldAssignKey() throws Exception { + // given + dataFileUpdate.setLicenseKey("687-398475-34876-384678-34756-3487"); + + final ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(String.class); + + // when + new PipelineBuilder(moduleConfig).build(builderPrime); + + // then + verify(builder).setDataUpdateLicenseKey(argumentCaptor.capture()); + assertThat(argumentCaptor.getAllValues()).containsExactly(dataFileUpdate.getLicenseKey()); + } + + @Test + public void buildShouldAssignAuto() throws Exception { + // given + dataFileUpdate.setAuto(true); + + final ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Boolean.class); + + // when + new PipelineBuilder(moduleConfig).build(builderPrime); + + // then + verify(builder).setAutoUpdate(argumentCaptor.capture()); + assertThat(argumentCaptor.getAllValues()).containsExactly(dataFileUpdate.getAuto()); + } + + @Test + public void buildShouldAssignOnStartup() throws Exception { + // given + dataFileUpdate.setOnStartup(true); + + final ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Boolean.class); + + // when + new PipelineBuilder(moduleConfig).build(builderPrime); + + // then + verify(builder).setDataUpdateOnStartup(argumentCaptor.capture()); + assertThat(argumentCaptor.getAllValues()).containsExactly(dataFileUpdate.getOnStartup()); + } + + @Test + public void buildShouldAssignWatchFileSystem() throws Exception { + // given + dataFileUpdate.setWatchFileSystem(true); + + final ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Boolean.class); + + // when + new PipelineBuilder(moduleConfig).build(builderPrime); + + // then + verify(builder).setDataFileSystemWatcher(argumentCaptor.capture()); + assertThat(argumentCaptor.getAllValues()).containsExactly(dataFileUpdate.getWatchFileSystem()); + } + + @Test + public void buildShouldAssignPollingInterval() throws Exception { + // given + dataFileUpdate.setPollingInterval(643); + + final ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Integer.class); + + // when + new PipelineBuilder(moduleConfig).build(builderPrime); + + // then + verify(builder).setUpdatePollingInterval(argumentCaptor.capture()); + assertThat(argumentCaptor.getAllValues()).containsExactly(dataFileUpdate.getPollingInterval()); + } + + @Test(expected = IllegalArgumentException.class) + public void buildShouldThrowWhenProfileIsUnknown() throws Exception { + // given + performanceConfig.setProfile("ghost"); + + try { + // when + new PipelineBuilder(moduleConfig).build(builderPrime); + } finally { + // then + verify(builder, never()).setPerformanceProfile(any()); + } + } + + @Test + public void buildShouldIgnoreEmptyProfile() throws Exception { + // given + performanceConfig.setProfile(""); + + // when + new PipelineBuilder(moduleConfig).build(builderPrime); + + // then + verify(builder, never()).setPerformanceProfile(any()); + } + + @Test + public void buildShouldAssignMaxPerformance() throws Exception { + // given + performanceConfig.setProfile("mAxperFORMance"); + + final ArgumentCaptor profilesArgumentCaptor + = ArgumentCaptor.forClass(Constants.PerformanceProfiles.class); + + // when + new PipelineBuilder(moduleConfig).build(builderPrime); + + // then + verify(builder).setPerformanceProfile(profilesArgumentCaptor.capture()); + assertThat(profilesArgumentCaptor.getAllValues()).containsExactly(Constants.PerformanceProfiles.MaxPerformance); + } + + @Test + public void buildShouldAssignConcurrency() throws Exception { + // given + performanceConfig.setConcurrency(398476); + + final ArgumentCaptor concurrenciesArgumentCaptor = ArgumentCaptor.forClass(Integer.class); + + // when + new PipelineBuilder(moduleConfig).build(builderPrime); + + // then + verify(builder).setConcurrency(concurrenciesArgumentCaptor.capture()); + assertThat(concurrenciesArgumentCaptor.getAllValues()).containsExactly(performanceConfig.getConcurrency()); + } + + @Test + public void buildShouldAssignDifference() throws Exception { + // given + performanceConfig.setDifference(498756); + + final ArgumentCaptor profilesArgumentCaptor = ArgumentCaptor.forClass(Integer.class); + + // when + new PipelineBuilder(moduleConfig).build(builderPrime); + + // then + verify(builder).setDifference(profilesArgumentCaptor.capture()); + assertThat(profilesArgumentCaptor.getAllValues()).containsExactly(performanceConfig.getDifference()); + } + + @Test + public void buildShouldAssignAllowUnmatched() throws Exception { + // given + performanceConfig.setAllowUnmatched(true); + + final ArgumentCaptor allowUnmatchedArgumentCaptor = ArgumentCaptor.forClass(Boolean.class); + + // when + new PipelineBuilder(moduleConfig).build(builderPrime); + + // then + verify(builder).setAllowUnmatched(allowUnmatchedArgumentCaptor.capture()); + assertThat(allowUnmatchedArgumentCaptor.getAllValues()).containsExactly(performanceConfig.getAllowUnmatched()); + } + + @Test + public void buildShouldAssignDrift() throws Exception { + // given + performanceConfig.setDrift(1348); + + final ArgumentCaptor driftsArgumentCaptor = ArgumentCaptor.forClass(Integer.class); + + // when + new PipelineBuilder(moduleConfig).build(builderPrime); + + // then + verify(builder).setDrift(driftsArgumentCaptor.capture()); + assertThat(driftsArgumentCaptor.getAllValues()).containsExactly(performanceConfig.getDrift()); + } + + @Test + public void buildShouldReturnNonNull() throws Exception { + // given + moduleConfig.getDataFile().setPath("dummy.hash"); + + // when + final Pipeline returnedPipeline = new PipelineBuilder(moduleConfig).build(builderPrime); + + // then + assertThat(returnedPipeline).isEqualTo(pipeline); + } + + @Test + public void buildShouldReturnNonNullWithCopy() throws Exception { + // given + moduleConfig.getDataFile().setPath("dummy.hash"); + moduleConfig.getDataFile().setMakeTempCopy(true); + + // when + final Pipeline returnedPipeline = new PipelineBuilder(moduleConfig).build(builderPrime); + + // then + assertThat(returnedPipeline).isEqualTo(pipeline); + } + + @Test + public void buildShouldNotThrowWhenMinimal() throws Exception { + // given + moduleConfig.getDataFile().setPath("dummy.hash"); + moduleConfig.getDataFile().setUpdate(null); + moduleConfig.setPerformance(null); + + // when + final Pipeline returnedPipeline = new PipelineBuilder(moduleConfig).build(builderPrime); + + // then + assertThat(returnedPipeline).isEqualTo(pipeline); + } +} diff --git a/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/SecureHeadersRetrieverTest.java b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/SecureHeadersRetrieverTest.java new file mode 100644 index 00000000000..5cc691dc909 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/core/SecureHeadersRetrieverTest.java @@ -0,0 +1,130 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.core; + +import com.iab.openrtb.request.BrandVersion; +import com.iab.openrtb.request.UserAgent; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class SecureHeadersRetrieverTest { + + @Test + public void callShouldAddEmptyMapOfSecureHeadersWhenUserAgentIsEmpty() { + // given + final UserAgent userAgent = UserAgent.builder().build(); + + // when + final Map evidence = SecureHeadersRetriever.retrieveFrom(userAgent); + + // then + assertThat(evidence).isNotNull(); + assertThat(evidence).isEmpty(); + } + + @Test + public void callShouldAddBrowsersToSecureHeaders() { + // given + final UserAgent userAgent = UserAgent.builder() + .browsers(List.of( + new BrandVersion("Nickel", List.of("6", "3", "1", "a"), null), + new BrandVersion(null, List.of("7", "52"), null), // should be skipped + new BrandVersion("FrostCat", List.of("9", "2", "5", "8"), null) + )) + .build(); + final String expectedBrowsers = "\"Nickel\";v=\"6.3.1.a\", \"FrostCat\";v=\"9.2.5.8\""; + + // when + final Map evidence = SecureHeadersRetriever.retrieveFrom(userAgent); + + // then + assertThat(evidence).isNotNull(); + assertThat(evidence.size()).isEqualTo(2); + assertThat(evidence.get("header.Sec-CH-UA")).isEqualTo(expectedBrowsers); + assertThat(evidence.get("header.Sec-CH-UA-Full-Version-List")).isEqualTo(expectedBrowsers); + } + + @Test + public void callShouldAddPlatformToSecureHeaders() { + final UserAgent userAgent = UserAgent.builder() + .platform(new BrandVersion("Cyborg", List.of("19", "5"), null)) + .build(); + final String expectedPlatformName = "\"Cyborg\""; + final String expectedPlatformVersion = "\"19.5\""; + + // when + final Map evidence = SecureHeadersRetriever.retrieveFrom(userAgent); + + // then + assertThat(evidence).isNotNull(); + assertThat(evidence.size()).isEqualTo(2); + assertThat(evidence.get("header.Sec-CH-UA-Platform")).isEqualTo(expectedPlatformName); + assertThat(evidence.get("header.Sec-CH-UA-Platform-Version")).isEqualTo(expectedPlatformVersion); + } + + @Test + public void callShouldAddIsMobileToSecureHeaders() { + final UserAgent userAgent = UserAgent.builder() + .mobile(5) + .build(); + final String expectedIsMobile = "?5"; + + // when + final Map evidence = SecureHeadersRetriever.retrieveFrom(userAgent); + + // then + assertThat(evidence).isNotNull(); + assertThat(evidence.size()).isEqualTo(1); + assertThat(evidence.get("header.Sec-CH-UA-Mobile")).isEqualTo(expectedIsMobile); + } + + @Test + public void callShouldAddArchitectureToSecureHeaders() { + final UserAgent userAgent = UserAgent.builder() + .architecture("LEG") + .build(); + final String expectedArchitecture = "\"LEG\""; + + // when + final Map evidence = SecureHeadersRetriever.retrieveFrom(userAgent); + + // then + assertThat(evidence).isNotNull(); + assertThat(evidence.size()).isEqualTo(1); + assertThat(evidence.get("header.Sec-CH-UA-Arch")).isEqualTo(expectedArchitecture); + } + + @Test + public void callShouldAddBitnessToSecureHeaders() { + final UserAgent userAgent = UserAgent.builder() + .bitness("doubtful") + .build(); + final String expectedBitness = "\"doubtful\""; + + // when + final Map evidence = SecureHeadersRetriever.retrieveFrom(userAgent); + + // then + assertThat(evidence).isNotNull(); + assertThat(evidence.size()).isEqualTo(1); + assertThat(evidence.get("header.Sec-CH-UA-Bitness")).isEqualTo(expectedBitness); + } + + @Test + public void callShouldAddModelToSecureHeaders() { + final UserAgent userAgent = UserAgent.builder() + .model("reflectivity") + .build(); + final String expectedModel = "\"reflectivity\""; + + // when + final Map evidence = SecureHeadersRetriever.retrieveFrom(userAgent); + + // then + assertThat(evidence).isNotNull(); + assertThat(evidence.size()).isEqualTo(1); + assertThat(evidence.get("header.Sec-CH-UA-Model")).isEqualTo(expectedModel); + } +} diff --git a/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/hooks/FiftyOneDeviceDetectionEntrypointHookTest.java b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/hooks/FiftyOneDeviceDetectionEntrypointHookTest.java new file mode 100644 index 00000000000..5c1b8707dfa --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/hooks/FiftyOneDeviceDetectionEntrypointHookTest.java @@ -0,0 +1,76 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.hooks; + +import io.vertx.core.Future; +import org.junit.Before; +import org.junit.Test; +import org.prebid.server.hooks.execution.v1.entrypoint.EntrypointPayloadImpl; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.FiftyOneDeviceDetectionModule; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.boundary.CollectedEvidence; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.model.ModuleContext; +import org.prebid.server.hooks.v1.InvocationResult; +import org.prebid.server.hooks.v1.entrypoint.EntrypointHook; +import org.prebid.server.hooks.v1.entrypoint.EntrypointPayload; +import org.prebid.server.model.CaseInsensitiveMultiMap; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class FiftyOneDeviceDetectionEntrypointHookTest { + private EntrypointHook target; + + @Before + public void setUp() { + target = new FiftyOneDeviceDetectionEntrypointHook(); + } + + @Test + public void codeShouldStartWithModuleCode() { + // when and then + assertThat(target.code()).startsWith(FiftyOneDeviceDetectionModule.CODE); + } + + @Test + public void callShouldReturnPatchedModule() { + // given + final EntrypointPayload entrypointPayload = EntrypointPayloadImpl.of( + null, + CaseInsensitiveMultiMap.builder().build(), + null + ); + + // when + final Future> result = target.call(entrypointPayload, null); + + // then + assertThat(result.succeeded()).isTrue(); + assertThat(result.result().moduleContext()).isNotNull(); + } + + @Test + public void callShouldAddRawRequestHeadersToModuleEvidence() { + // given + final String key = "ua"; + final String value = "AI-scape Imitator"; + final EntrypointPayload entrypointPayload = EntrypointPayloadImpl.of( + null, + CaseInsensitiveMultiMap.builder() + .add(key, value) + .build(), + null + ); + + // when + final Future> result = target.call(entrypointPayload, null); + + // then + assertThat(result.succeeded()).isTrue(); + assertThat(result.result().moduleContext()).isInstanceOf(ModuleContext.class); + final CollectedEvidence evidence = ((ModuleContext) result.result().moduleContext()).collectedEvidence(); + assertThat(evidence).isNotNull(); + assertThat(evidence.rawHeaders()).hasSize(1); + final Map.Entry firstHeader = evidence.rawHeaders().stream().findFirst().get(); + assertThat(firstHeader.getKey()).isEqualTo(key); + assertThat(firstHeader.getValue()).isEqualTo(value); + } +} diff --git a/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/hooks/FiftyOneDeviceDetectionRawAuctionRequestHookTest.java b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/hooks/FiftyOneDeviceDetectionRawAuctionRequestHookTest.java new file mode 100644 index 00000000000..2c4e8b75f84 --- /dev/null +++ b/extra/modules/fiftyone-devicedetection/src/test/java/org/prebid/server/hooks/modules/fiftyone/devicedetection/v1/hooks/FiftyOneDeviceDetectionRawAuctionRequestHookTest.java @@ -0,0 +1,897 @@ +package org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.hooks; + +import com.fasterxml.jackson.databind.node.TextNode; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.UserAgent; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.boundary.CollectedEvidence; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.model.config.AccountFilter; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.FiftyOneDeviceDetectionModule; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.core.DeviceEnricher; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.core.EnrichmentResult; +import org.prebid.server.hooks.modules.fiftyone.devicedetection.v1.model.ModuleContext; +import org.prebid.server.hooks.v1.InvocationAction; +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.proto.openrtb.ext.request.ExtDevice; +import org.prebid.server.settings.model.Account; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class FiftyOneDeviceDetectionRawAuctionRequestHookTest { + @Rule + public final MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock + private DeviceEnricher deviceEnricher; + private AccountFilter accountFilter; + private RawAuctionRequestHook target; + + @Before + public void setUp() { + accountFilter = new AccountFilter(); + target = new FiftyOneDeviceDetectionRawAuctionRequestHook(accountFilter, deviceEnricher); + } + + @Test + public void callShouldMakeNewContextWhenNullIsPassedIn() { + // given + final BidRequest bidRequest = BidRequest.builder() + .device(null) + .build(); + final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest); + final AuctionInvocationContext invocationContext = AuctionInvocationContextImpl.of( + null, + null, + false, + null, + null + ); + + // when + final ModuleContext newContext = (ModuleContext) target.call(auctionRequestPayload, invocationContext) + .result() + .moduleContext(); + + // then + assertThat(newContext).isNotNull(); + assertThat(newContext.collectedEvidence()).isNotNull(); + } + + @Test + public void callShouldMakeNewEvidenceWhenNoneWasPresent() { + // given + final ModuleContext moduleContext = ModuleContext.builder().build(); + final BidRequest bidRequest = BidRequest.builder() + .device(null) + .build(); + final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest); + final AuctionInvocationContext invocationContext = AuctionInvocationContextImpl.of( + null, + null, + false, + null, + moduleContext + ); + + // when + final ModuleContext newContext = (ModuleContext) target.call(auctionRequestPayload, invocationContext) + .result() + .moduleContext(); + + // then + assertThat(newContext).isNotNull(); + assertThat(newContext.collectedEvidence()).isNotNull(); + } + + @Test + public void callShouldMergeEvidences() { + // given + final String ua = "mad-hatter"; + final HashMap sua = new HashMap<>(); + final ModuleContext existingContext = ModuleContext.builder() + .collectedEvidence(CollectedEvidence.builder() + .secureHeaders(sua) + .build()) + .build(); + final Device device = Device.builder().ua(ua).build(); + final BidRequest bidRequest = BidRequest.builder() + .device(device) + .build(); + final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest); + final AuctionInvocationContext invocationContext = AuctionInvocationContextImpl.of( + null, + null, + false, + null, + existingContext + ); + + // when + final ModuleContext newContext = (ModuleContext) target.call(auctionRequestPayload, invocationContext) + .result() + .moduleContext(); + + // then + assertThat(newContext).isNotNull(); + final CollectedEvidence newEvidence = newContext.collectedEvidence(); + assertThat(newEvidence).isNotNull(); + assertThat(newEvidence.deviceUA()).isEqualTo(ua); + assertThat(newEvidence.secureHeaders()).isEqualTo(sua); + } + + @Test + public void callShouldNotFailWhenNoDevice() { + // given + final BidRequest bidRequest = BidRequest.builder().build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + final AuctionInvocationContext auctionInvocationContext = AuctionInvocationContextImpl.of( + null, + null, + false, + null, + null + ); + + // when + final CollectedEvidence evidence = ((ModuleContext) target.call(payload, auctionInvocationContext) + .result() + .moduleContext()) + .collectedEvidence(); + + // then + assertThat(evidence).isNotNull(); + } + + @Test + public void callShouldAddUAToModuleContextEvidence() { + // given + final String testUA = "MindScape Crawler"; + final BidRequest bidRequest = BidRequest.builder() + .device(Device.builder().ua(testUA).build()) + .build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + final AuctionInvocationContext auctionInvocationContext = AuctionInvocationContextImpl.of( + null, + null, + false, + null, + null + ); + + // when + final CollectedEvidence evidence = ((ModuleContext) target.call(payload, auctionInvocationContext) + .result() + .moduleContext()) + .collectedEvidence(); + + // then + assertThat(evidence.deviceUA()).isEqualTo(testUA); + } + + @Test + public void callShouldAddSUAToModuleContextEvidence() { + // given + final UserAgent testSUA = UserAgent.builder().build(); + final BidRequest bidRequest = BidRequest.builder() + .device(Device.builder().sua(testSUA).build()) + .build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + final AuctionInvocationContext auctionInvocationContext = AuctionInvocationContextImpl.of( + null, + null, + false, + null, + null + ); + + // when + final CollectedEvidence evidence = ((ModuleContext) target.call(payload, auctionInvocationContext) + .result() + .moduleContext()) + .collectedEvidence(); + + // then + assertThat(evidence.secureHeaders()).isEmpty(); + } + + @Test + public void payloadUpdateShouldReturnNullWhenRequestIsNull() { + // given + final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(null); + final AuctionInvocationContext invocationContext = AuctionInvocationContextImpl.of( + null, + null, + false, + null, + ModuleContext.builder() + .collectedEvidence(null) + .build() + ); + + // when + final BidRequest newBidRequest = target.call(auctionRequestPayload, invocationContext) + .result() + .payloadUpdate() + .apply(auctionRequestPayload) + .bidRequest(); + + // then + assertThat(newBidRequest).isNull(); + } + + @Test + public void payloadUpdateShouldReturnOldRequestWhenPopulateDeviceInfoThrows() throws Exception { + // given + final BidRequest bidRequest = BidRequest.builder().build(); + final CollectedEvidence savedEvidence = CollectedEvidence.builder().build(); + final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest); + final AuctionInvocationContext invocationContext = AuctionInvocationContextImpl.of( + null, + null, + false, + null, + ModuleContext.builder() + .collectedEvidence(savedEvidence) + .build() + ); + final Exception e = new RuntimeException(); + when(deviceEnricher.populateDeviceInfo(any(), any())).thenThrow(e); + + // when + final BidRequest newBidRequest = target.call(auctionRequestPayload, invocationContext) + .result() + .payloadUpdate() + .apply(auctionRequestPayload) + .bidRequest(); + + // then + assertThat(newBidRequest).isEqualTo(bidRequest); + verify(deviceEnricher, times(1)).populateDeviceInfo(any(), any()); + } + + @Test + public void payloadUpdateShouldReturnOldRequestWhenMergedDeviceIsNull() throws Exception { + // given + final BidRequest bidRequest = BidRequest.builder().build(); + final CollectedEvidence savedEvidence = CollectedEvidence.builder().build(); + final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest); + final AuctionInvocationContext invocationContext = AuctionInvocationContextImpl.of( + null, + null, + false, + null, + ModuleContext.builder() + .collectedEvidence(savedEvidence) + .build() + ); + when(deviceEnricher.populateDeviceInfo(any(), any())) + .thenReturn(EnrichmentResult.builder().build()); + + // when + final BidRequest newBidRequest = target.call(auctionRequestPayload, invocationContext) + .result() + .payloadUpdate() + .apply(auctionRequestPayload) + .bidRequest(); + + // then + assertThat(newBidRequest).isEqualTo(bidRequest); + verify(deviceEnricher, times(1)).populateDeviceInfo(any(), any()); + } + + @Test + public void payloadUpdateShouldPassMergedEvidenceToDeviceRefiner() throws Exception { + // given + final BidRequest bidRequest = BidRequest.builder().build(); + final String fakeUA = "crystal-ball-navigator"; + final CollectedEvidence savedEvidence = CollectedEvidence.builder() + .rawHeaders(Collections.emptySet()) + .deviceUA(fakeUA) + .build(); + final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest); + final AuctionInvocationContext invocationContext = AuctionInvocationContextImpl.of( + null, + null, + false, + null, + ModuleContext.builder() + .collectedEvidence(savedEvidence) + .build() + ); + when(deviceEnricher.populateDeviceInfo(any(), any())) + .thenReturn(EnrichmentResult.builder().build()); + + // when + final BidRequest newBidRequest = target.call(auctionRequestPayload, invocationContext) + .result() + .payloadUpdate() + .apply(auctionRequestPayload) + .bidRequest(); + + // then + assertThat(newBidRequest).isEqualTo(bidRequest); + verify(deviceEnricher, times(1)).populateDeviceInfo(any(), any()); + + final ArgumentCaptor evidenceCaptor = ArgumentCaptor.forClass(CollectedEvidence.class); + verify(deviceEnricher).populateDeviceInfo(any(), evidenceCaptor.capture()); + final List allEvidences = evidenceCaptor.getAllValues(); + assertThat(allEvidences).hasSize(1); + assertThat(allEvidences.getFirst().deviceUA()).isEqualTo(fakeUA); + } + + @Test + public void payloadUpdateShouldInjectReturnedDevice() throws Exception { + // given + final BidRequest bidRequest = BidRequest.builder().build(); + final CollectedEvidence savedEvidence = CollectedEvidence.builder().build(); + final Device mergedDevice = Device.builder().build(); + final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(bidRequest); + final AuctionInvocationContext invocationContext = AuctionInvocationContextImpl.of( + null, + null, + false, + null, + ModuleContext.builder() + .collectedEvidence(savedEvidence) + .build() + ); + when(deviceEnricher.populateDeviceInfo(any(), any())) + .thenReturn(EnrichmentResult + .builder() + .enrichedDevice(mergedDevice) + .build()); + + // when + final BidRequest newBidRequest = target.call(auctionRequestPayload, invocationContext) + .result() + .payloadUpdate() + .apply(auctionRequestPayload) + .bidRequest(); + + // then + assertThat(newBidRequest.getDevice()).isEqualTo(mergedDevice); + verify(deviceEnricher, times(1)).populateDeviceInfo(any(), any()); + } + + @Test + public void codeShouldStartWithModuleCode() { + // when and then + assertThat(target.code()).startsWith(FiftyOneDeviceDetectionModule.CODE); + } + + @Test + public void callShouldReturnUpdateActionWhenFilterIsNull() { + // given + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + null, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.update); + } + + @Test + public void callShouldReturnUpdateActionWhenNoWhitelistAndNoAuctionContext() { + // given + + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + null, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.update); + } + + @Test + public void callShouldReturnUpdateActionWhenWhitelistEmptyAndNoAuctionContext() { + // given + accountFilter.setAllowList(Collections.emptyList()); + + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + null, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.update); + } + + @Test + public void callShouldReturnNoUpdateActionWhenWhitelistFilledAndNoAuctionContext() { + // given + accountFilter.setAllowList(Collections.singletonList("42")); + + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + null, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.no_action); + } + + @Test + public void callShouldReturnUpdateActionWhenNoWhitelistAndNoAccount() { + // given + + final AuctionContext auctionContext = AuctionContext.builder().build(); + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + auctionContext, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.update); + } + + @Test + public void callShouldReturnNoUpdateActionWhenNoWhitelistAndNoAccountButDeviceIdIsSet() { + // given + + final AuctionContext auctionContext = AuctionContext.builder().build(); + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + auctionContext, + false, + null, + null + ); + final ExtDevice ext = ExtDevice.empty(); + final Device device = Device.builder().ext(ext).build(); + final BidRequest bidRequest = BidRequest.builder().device(device).build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + ext.addProperty("fiftyonedegrees_deviceId", new TextNode("0-0-0-0")); + + // when + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.no_action); + } + + @Test + public void callShouldReturnUpdateActionWhenWhitelistEmptyAndNoAccount() { + // given + accountFilter.setAllowList(Collections.emptyList()); + + final AuctionContext auctionContext = AuctionContext.builder().build(); + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + auctionContext, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.update); + } + + @Test + public void callShouldReturnNoUpdateActionWhenWhitelistFilledAndNoAccount() { + // given + accountFilter.setAllowList(Collections.singletonList("42")); + + final AuctionContext auctionContext = AuctionContext.builder().build(); + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + auctionContext, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.no_action); + } + + @Test + public void callShouldReturnUpdateActionWhenNoWhitelistAndNoAccountID() { + // given + + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder() + .build()) + .build(); + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + auctionContext, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.update); + } + + @Test + public void callShouldReturnUpdateActionWhenWhitelistEmptyAndNoAccountID() { + // given + accountFilter.setAllowList(Collections.emptyList()); + + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder() + .build()) + .build(); + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + auctionContext, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.update); + } + + @Test + public void callShouldReturnNoUpdateActionWhenWhitelistFilledAndNoAccountID() { + // given + accountFilter.setAllowList(Collections.singletonList("42")); + + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder() + .build()) + .build(); + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + auctionContext, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.no_action); + } + + @Test + public void callShouldReturnUpdateActionWhenNoWhitelistAndEmptyAccountID() { + // given + + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder() + .id("") + .build()) + .build(); + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + auctionContext, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.update); + } + + @Test + public void callShouldReturnUpdateActionWhenWhitelistEmptyAndEmptyAccountID() { + // given + accountFilter.setAllowList(Collections.emptyList()); + + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder() + .id("") + .build()) + .build(); + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + auctionContext, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.update); + } + + @Test + public void callShouldReturnNoUpdateActionWhenWhitelistFilledAndEmptyAccountID() { + // given + accountFilter.setAllowList(Collections.singletonList("42")); + + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder() + .id("") + .build()) + .build(); + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + auctionContext, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.no_action); + } + + @Test + public void callShouldReturnUpdateActionWhenNoWhitelistAndAllowedAccountID() { + // given + + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder() + .id("42") + .build()) + .build(); + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + auctionContext, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.update); + } + + @Test + public void callShouldReturnUpdateActionWhenWhitelistEmptyAndAllowedAccountID() { + // given + accountFilter.setAllowList(Collections.emptyList()); + + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder() + .id("42") + .build()) + .build(); + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + auctionContext, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.update); + } + + @Test + public void callShouldReturnUpdateActionWhenWhitelistFilledAndAllowedAccountID() { + // given + accountFilter.setAllowList(Collections.singletonList("42")); + + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder() + .id("42") + .build()) + .build(); + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + auctionContext, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.update); + } + + @Test + public void callShouldReturnUpdateActionWhenNoWhitelistAndNotAllowedAccountID() { + // given + + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder() + .id("29") + .build()) + .build(); + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + auctionContext, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.update); + } + + @Test + public void callShouldReturnUpdateActionWhenWhitelistEmptyAndNotAllowedAccountID() { + // given + accountFilter.setAllowList(Collections.emptyList()); + + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder() + .id("29") + .build()) + .build(); + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + auctionContext, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.update); + } + + @Test + public void callShouldReturnNoUpdateActionWhenWhitelistFilledAndNotAllowedAccountID() { + // given + accountFilter.setAllowList(Collections.singletonList("42")); + + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder() + .id("29") + .build()) + .build(); + final AuctionInvocationContext context = AuctionInvocationContextImpl.of( + null, + auctionContext, + false, + null, + null + ); + + // when + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().build()); + final InvocationAction invocationAction = target.call(payload, context) + .result() + .action(); + + // then + assertThat(invocationAction).isEqualTo(InvocationAction.no_action); + } +} diff --git a/extra/modules/pom.xml b/extra/modules/pom.xml index f06a66e3c36..f5524af6547 100644 --- a/extra/modules/pom.xml +++ b/extra/modules/pom.xml @@ -20,6 +20,7 @@ ortb2-blocking confiant-ad-quality pb-richmedia-filter + fiftyone-devicedetection diff --git a/sample/prebid-config-with-51d-dd.yaml b/sample/prebid-config-with-51d-dd.yaml new file mode 100644 index 00000000000..f32674538a3 --- /dev/null +++ b/sample/prebid-config-with-51d-dd.yaml @@ -0,0 +1,99 @@ +status-response: "ok" +adapters: + appnexus: + enabled: true + ix: + enabled: true + openx: + enabled: true + pubmatic: + enabled: true + rubicon: + enabled: true +metrics: + prefix: prebid +cache: + scheme: http + host: localhost + path: /cache + query: uuid= +settings: + enforce-valid-account: false + generate-storedrequest-bidrequest-id: true + filesystem: + settings-filename: sample/sample-app-settings.yaml + stored-requests-dir: sample/stored + stored-imps-dir: sample/stored + stored-responses-dir: sample/stored + categories-dir: +gdpr: + default-value: 1 + vendorlist: + v2: + cache-dir: /var/tmp/vendor2 + v3: + cache-dir: /var/tmp/vendor3 +admin-endpoints: + logging-changelevel: + enabled: true + path: /logging/changelevel + on-application-port: true + protected: false +hooks: + fiftyone-devicedetection: + enabled: true + host-execution-plan: > + { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "entrypoint": { + "groups": [ + { + "timeout": 100, + "hook-sequence": [ + { + "module-code": "fiftyone-devicedetection", + "hook-impl-code": "fiftyone-devicedetection-entrypoint-hook" + } + ] + } + ] + }, + "raw-auction-request": { + "groups": [ + { + "timeout": 100, + "hook-sequence": [ + { + "module-code": "fiftyone-devicedetection", + "hook-impl-code": "fiftyone-devicedetection-raw-auction-request-hook" + } + ] + } + ] + } + } + } + } + } + modules: + fiftyone-devicedetection: + account-filter: + allow-list: [] # list of strings + data-file: + path: "51Degrees-LiteV4.1.hash" # string, REQUIRED, download the sample from https://github.com/51Degrees/device-detection-data/blob/main/51Degrees-LiteV4.1.hash or Enterprise from https://51degrees.com/pricing + make-temp-copy: ~ # boolean + update: + auto: ~ # boolean + on-startup: ~ # boolean + url: ~ # string + license-key: ~ # string + watch-file-system: ~ # boolean + polling-interval: ~ # int, seconds + performance: + profile: ~ # string, one of [LowMemory,MaxPerformance,HighPerformance,Balanced,BalancedTemp] + concurrency: ~ # int + difference: ~ # int + allow-unmatched: ~ # boolean + drift: ~ # int