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 extends Hook, ? extends InvocationContext>> 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 extends Hook, ? extends InvocationContext>> hooks
+) implements Module {
+ public static final String CODE = "fiftyone-devicedetection";
+
+ @Override
+ public String code() {
+ return CODE;
+ }
+
+ @Override
+ public Collection extends Hook, ? extends InvocationContext>> 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