diff --git a/Dockerfile b/Dockerfile index 3e17c3aeec7..4b0b947071b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,11 +20,19 @@ RUN apt-get update && \ # CGO must be enabled because some modules depend on native C code ENV CGO_ENABLED 1 COPY ./ ./ + +# Installing WURFL compile-time dependencies if libwurfl package is present +RUN if ls modules/scientiamobile/wurfl_devicedetection/libwurfl/libwurfl*.deb 1> /dev/null 2>&1; then \ + dpkg -i modules/scientiamobile/wurfl_devicedetection/libwurfl/libwurfl*.deb; \ + fi + RUN go mod tidy RUN go mod vendor +# Accept Go build tags as arguments (default: none) +ARG GO_BUILD_TAGS="" ARG TEST="true" RUN if [ "$TEST" != "false" ]; then ./validate.sh ; fi -RUN go build -mod=vendor -ldflags "-X github.com/prebid/prebid-server/v3/version.Ver=`git describe --tags | sed 's/^v//'` -X github.com/prebid/prebid-server/v3/version.Rev=`git rev-parse HEAD`" . +RUN go build $GO_BUILD_TAGS -mod=vendor -ldflags "-X github.com/prebid/prebid-server/v3/version.Ver=`git describe --tags | sed 's/^v//'` -X github.com/prebid/prebid-server/v3/version.Rev=`git rev-parse HEAD`" . FROM ubuntu:20.04 AS release LABEL maintainer="hans.hjort@xandr.com" @@ -35,6 +43,16 @@ COPY static static/ COPY stored_requests/data stored_requests/data RUN chmod -R a+r static/ stored_requests/data +# Installing WURFL runtime dependencies if libwurfl package is present +COPY modules/scientiamobile/wurfl_devicedetection/libwurfl/ /tmp/wurfl +RUN if ls /tmp/wurfl/libwurfl*.deb 1> /dev/null 2>&1; then \ + dpkg -i /tmp/wurfl/libwurfl*.deb; \ + apt-get update && \ + apt-get install -y curl && \ + apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*; \ + rm -rf /tmp/wurfl; \ + fi + # Installing libatomic1 as it is a runtime dependency for some modules RUN apt-get update && \ apt-get install -y ca-certificates mtr libatomic1 && \ diff --git a/Makefile b/Makefile index 8b68afe3bee..4f6b04424c2 100644 --- a/Makefile +++ b/Makefile @@ -30,6 +30,10 @@ build: test image: docker build -t prebid-server . +# wurfl-image will build a docker image with WURFL support +wurfl-image: + docker build --build-arg GO_BUILD_TAGS="-tags wurfl" -t prebid-server . + # format runs format format: ./scripts/format.sh -f true diff --git a/go.mod b/go.mod index dc6fad069df..e00036ea126 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/DATA-DOG/go-sqlmock v1.5.0 github.com/IABTechLab/adscert v0.34.0 github.com/NYTimes/gziphandler v1.1.1 + github.com/WURFL/golang-wurfl v1.30.3 github.com/alitto/pond v1.8.3 github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d github.com/benbjohnson/clock v1.3.0 @@ -35,7 +36,7 @@ require ( github.com/rs/cors v1.11.0 github.com/spf13/cast v1.5.0 github.com/spf13/viper v1.12.0 - github.com/stretchr/testify v1.8.1 + github.com/stretchr/testify v1.8.4 github.com/tidwall/gjson v1.17.1 github.com/tidwall/sjson v1.2.5 github.com/vrischmann/go-metrics-influxdb v0.1.1 diff --git a/go.sum b/go.sum index a98f1f7a720..b1352de6071 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,8 @@ github.com/IABTechLab/adscert v0.34.0/go.mod h1:pCLd3Up1kfTrH6kYFUGGeavxIc1f6Tvv github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/WURFL/golang-wurfl v1.30.3 h1:a/ZR+/mwMrA9cEVa88ig47zkVJNl3HM5OTCpPvoSYmE= +github.com/WURFL/golang-wurfl v1.30.3/go.mod h1:cKXIyA0oIrbZ7YTOhBPX29ELt6XAM1/S7qyFIrTKkS0= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -484,8 +486,9 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.3.0 h1:mjC+YW8QpAdXibNi+vNWgzmgBH4+5l5dCXv8cNysBLI= github.com/subosito/gotenv v1.3.0/go.mod h1:YzJjq/33h7nrwdY+iHMhEOEEbW0ovIz0tB6t6PwAXzs= diff --git a/modules/builder.go b/modules/builder.go index 2101b340231..69b1888095e 100644 --- a/modules/builder.go +++ b/modules/builder.go @@ -3,6 +3,7 @@ package modules import ( fiftyonedegreesDevicedetection "github.com/prebid/prebid-server/v3/modules/fiftyonedegrees/devicedetection" prebidOrtb2blocking "github.com/prebid/prebid-server/v3/modules/prebid/ortb2blocking" + wurflDevicedetection "github.com/prebid/prebid-server/v3/modules/scientiamobile/wurfl_devicedetection" ) // builders returns mapping between module name and its builder @@ -15,5 +16,8 @@ func builders() ModuleBuilders { "prebid": { "ortb2blocking": prebidOrtb2blocking.Builder, }, + "scientiamobile": { + "wurfl_devicedetection": wurflDevicedetection.Builder, + }, } } diff --git a/modules/scientiamobile/wurfl_devicedetection/README.md b/modules/scientiamobile/wurfl_devicedetection/README.md new file mode 100644 index 00000000000..b9ffb10abca --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/README.md @@ -0,0 +1,323 @@ +## WURFL Device Enrichment Module + +### Overview + +The **WURFL Device Enrichment Module** for Prebid Server enhances the OpenRTB 2.x payload +with comprehensive device detection data powered by **ScientiaMobile**’s WURFL device detection framework. +Thanks to WURFL's device database, the module provides accurate and comprehensive device-related information, +enabling bidders to make better-informed targeting and optimization decisions. + +### Key features + +#### Device Field Enrichment + +The WURFL module populates **missing or empty fields** in `ortb2.device` with the following data: + +- **make**: Manufacturer of the device (e.g., "Apple", "Samsung"). +- **model**: Device model (e.g., "iPhone 14", "Galaxy S22"). +- **os**: Operating system (e.g., "iOS", "Android"). +- **osv**: Operating system version (e.g., "16.0", "12.0"). +- **h**: Screen height in pixels. +- **w**: Screen width in pixels. +- **ppi**: Screen pixels per inch (PPI). +- **pixelratio**: Screen pixel density ratio. +- **devicetype**: Device type (e.g., mobile, tablet, desktop). + +> **Note**: If these fields are already populated in the bid request, the module will not overwrite them. + +#### Publisher-Specific Enrichment + +Device enrichment is selectively enabled for publishers based on their **account ID**. +The module identifies publishers through the following fields: + +- `site.publisher.id` (for web environments). +- `app.publisher.id` (for mobile app environments). +- `dooh.publisher.id` (for digital out-of-home environments). + +### Build prerequisites + +To build the WURFL module, you need to install the WURFL Infuze from ScientiaMobile. +For more details, visit: [ScientiaMobile WURFL Infuze](https://www.scientiamobile.com/products/wurfl-infuze/). + +#### Note + +The WURFL module requires CGO at compile time to link against the WURFL Infuze library. + +To enable the WURFL module, the `wurfl` build tag must be specified: + +```go +go build -tags wurfl . +``` + +If the `wurfl` tag is not provided, the module will compile a demo version that returns sample data, +allowing basic testing without an Infuze license. + +### Configuring the WURFL Module + +Below is a sample configuration for the WURFL module: + +```json +{ + "adapters": [ + { + "appnexus": { + "enabled": true + } + } + ], + "gdpr": { + "enabled": true, + "default_value": 0, + "timeouts_ms": { + "active_vendorlist_fetch": 900000 + } + }, + "hooks": { + "enabled": true, + "modules": { + "scientiamobile": { + "wurfl_devicedetection": { + "enabled": true, + "wurfl_snapshot_url": "", + "wurfl_file_dir_path": "/tmp", + "wurfl_run_updater": true, + "wurfl_cache_size": 200000, + "allowed_publisher_ids": ["1","3"], + "ext_caps": true + } + } + }, + "host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "entrypoint": { + "groups": [ + { + "timeout": 10, + "hook_sequence": [ + { + "module_code": "scientiamobile.wurfl_devicedetection", + "hook_impl_code": "scientiamobile-wurfl_devicedetection-entrypoint-hook" + } + ] + } + ] + }, + "raw_auction_request": { + "groups": [ + { + "timeout": 10, + "hook_sequence": [ + { + "module_code": "scientiamobile.wurfl_devicedetection", + "hook_impl_code": "scientiamobile-wurfl_devicedetection-raw-auction-request-hook" + } + ] + } + ] + } + } + } + } + } + } +} +``` + +The same configuration in YAML format + +```yaml +adapters: + - appnexus: + enabled: true +gdpr: + enabled: true + default_value: 0 + timeouts_ms: + active_vendorlist_fetch: 900000 +hooks: + enabled: true + modules: + scientiamobile: + wurfl_devicedetection: + enabled: true + wurfl_snapshot_url: "" + wurfl_file_dir_path: "/tmp" + wurfl_run_updater: true + wurfl_cache_size: 200000 + allowed_publisher_ids: + - "1" + - "3" + ext_caps: true + host_execution_plan: + endpoints: + /openrtb2/auction: + stages: + entrypoint: + groups: + - timeout: 10 + hook_sequence: + - module_code: "scientiamobile.wurfl_devicedetection" + hook_impl_code: "scientiamobile-wurfl_devicedetection-entrypoint-hook" + raw_auction_request: + groups: + - timeout: 10 + hook_sequence: + - module_code: "scientiamobile.wurfl_devicedetection" + hook_impl_code: "scientiamobile-wurfl_devicedetection-raw-auction-request-hook" +``` + +### Configuration Options + +| Parameter | Requirement | Description | +|---------------------------|-------------|-------------------------------------------------------------------------------------------------------| +| **`wurfl_file_dir_path`** | Mandatory | Path to the directory where the WURFL file is downloaded. Directory must exist and be writable. | +| **`wurfl_snapshot_url`** | Mandatory | URL of the licensed WURFL snapshot file to be downloaded when Prebid Server Java starts. | +| **`wurfl_cache_size`** | Optional | Maximum number of devices stored in the WURFL cache. Defaults to the WURFL cache's standard size. | +| **`wurfl_run_updater`** | Optional | Enables the WURFL updater. Defaults to no updates. | +| **`ext_caps`** | Optional | If `true`, the module adds all licensed capabilities to the `device.ext` object. | +| **`allowed_publisher_ids`** | Optional | List of publisher IDs permitted to use the module. Defaults to all publishers. | + +A valid WURFL license must include all the required capabilities for device enrichment. + +### Launching Prebid Server with the WURFL Module + +1. Download dependencies: + +```bash +go mod download +``` + +1. Copy the sample [config file](modules/scientiamobile/wurfl_devicedetection/sample/pbs-example.json): + +```bash +cp modules/scientiamobile/wurfl_devicedetection/sample/pbs-example.json pbs.json +``` + +1. Start the server + + ```bash + go run -tags wurfl . +``` + +When the server starts, it downloads the WURFL file from the `wurfl_snapshot_url` and loads it into the module. +Please ensure that the `wurfl_snapshot_url` is correctly configured in the configuration file. + +Sample request data for testing is available in the module's `sample` directory. +Using the `auction` endpoint, you can observe WURFL-enriched device data in the response. + +#### Start in demo mode + +To test the WURFL module without an Infuze license: + +```bash +go run wurfl . +``` + +### Sample Response + +Using the sample request data via `curl` when the module is configured with `ext_caps` set to `false` (or no value) + +```bash +curl http://localhost:8000/openrtb2/auction --data @modules/scientiamobile/wurfl_devicedetection/sample/request_data.json +``` + +the device object in the response will include WURFL device detection data: + +```json +"device": { + "ua": "Mozilla/5.0 (Linux; Android 15; Pixel 9 Pro XL Build/AP3A.241005.015;) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 EdgA/124.0.2478.64", + "devicetype": 1, + "make": "Google", + "model": "Pixel 9 Pro XL", + "os": "Android", + "osv": "15", + "h": 2992, + "w": 1344, + "ppi": 481, + "pxratio": 2.55, + "js": 1, + "ext": { + "wurfl": { + "wurfl_id": "google_pixel_9_pro_xl_ver1_suban150" + } + } +} +``` + +When `ext_caps` is set to `true`, the response will include all licensed capabilities: + +```json +"device":{ + "ua":"Mozilla/5.0 (Linux; Android 15; Pixel 9 Pro XL Build/AP3A.241005.015; ) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 EdgA/124.0.2478.64", + "devicetype":1, + "make":"Google", + "model":"Pixel 9 Pro XL", + "os":"Android", + "osv":"15", + "h":2992, + "w":1344, + "ppi":481, + "pxratio":2.55, + "js":1, + "ext":{ + "wurfl":{ + "wurfl_id":"google_pixel_9_pro_xl_ver1_suban150", + "mobile_browser_version":"", + "resolution_height":"2992", + "resolution_width":"1344", + "is_wireless_device":"true", + "is_tablet":"false", + "physical_form_factor":"phone_phablet", + "ajax_support_javascript":"true", + "preferred_markup":"html_web_4_0", + "brand_name":"Google", + "can_assign_phone_number":"true", + "xhtml_support_level":"4", + "ux_full_desktop":"false", + "device_os":"Android", + "physical_screen_width":"71", + "is_connected_tv":"false", + "is_smarttv":"false", + "physical_screen_height":"158", + "model_name":"Pixel 9 Pro XL", + "is_ott":"false", + "density_class":"2.55", + "marketing_name":"", + "device_os_version":"15.0", + "mobile_browser":"Chrome Mobile", + "pointing_method":"touchscreen", + "is_app_webview":"false", + "advertised_app_name":"Edge Browser", + "is_smartphone":"true", + "is_robot":"false", + "advertised_device_os":"Android", + "is_largescreen":"true", + "is_android":"true", + "is_xhtmlmp_preferred":"false", + "device_name":"Google Pixel 9 Pro XL", + "is_ios":"false", + "is_touchscreen":"true", + "is_wml_preferred":"false", + "is_app":"false", + "is_mobile":"true", + "is_phone":"true", + "is_full_desktop":"false", + "is_generic":"false", + "advertised_browser":"Edge", + "complete_device_name":"Google Pixel 9 Pro XL", + "advertised_browser_version":"124.0.2478.64", + "is_html_preferred":"true", + "is_windows_phone":"false", + "pixel_density":"481", + "form_factor":"Smartphone", + "advertised_device_os_version":"15" + } + } +} +``` + +## Maintainer + + diff --git a/modules/scientiamobile/wurfl_devicedetection/config.go b/modules/scientiamobile/wurfl_devicedetection/config.go new file mode 100644 index 00000000000..4736ee9d23d --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/config.go @@ -0,0 +1,57 @@ +package wurfl_devicedetection + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strconv" + + "github.com/prebid/prebid-server/v3/util/jsonutil" +) + +const ( + defaultCacheSize = "200000" +) + +// newConfig creates and validates a new config from the raw JSON data. +func newConfig(data json.RawMessage) (config, error) { + var cfg config + if err := jsonutil.UnmarshalValid(data, &cfg); err != nil { + return cfg, fmt.Errorf("failed to parse config: %s", err) + } + err := cfg.validate() + return cfg, err +} + +// config represents the configuration for the module. +type config struct { + WURFLSnapshotURL string `json:"wurfl_snapshot_url"` // WURFLSnapshotURL is the WURFL Snapshot URL. + WURFLFileDirPath string `json:"wurfl_file_dir_path"` // WURFLFileDirPath is the folder where the WURFL data is stored. Required. + WURFLRunUpdater *bool `json:"wurfl_run_updater"` // WURFLRunUpdater enable the WURFL updater. Default to true + WURFLCacheSize int `json:"wurfl_cache_size"` // WURFLCacheSize is the size of the WURFL Engine cache. Default is 200000 + AllowedPublisherIDs []string `json:"allowed_publisher_ids"` // Holds the list of allowed publisher IDs. Leave empty to allow all. + ExtCaps bool `json:"ext_caps"` // ExtCaps if true will include licensed WURFL capabilities in ortb2.Device.Ext +} + +// WURFLEngineCacheSize returns the cache size for the WURFL engine. +func (cfg config) WURFLEngineCacheSize() string { + if cfg.WURFLCacheSize > 0 { + return strconv.Itoa(cfg.WURFLCacheSize) + } + return defaultCacheSize +} + +// WURFLFilePath returns the path to the WURFL file. +func (cfg config) WURFLFilePath() string { + return filepath.Join(cfg.WURFLFileDirPath, filepath.Base(cfg.WURFLSnapshotURL)) +} + +func (cfg config) validate() error { + if cfg.WURFLSnapshotURL == "" { + return fmt.Errorf("wurfl_snapshot_url is required") + } + if cfg.WURFLFileDirPath == "" { + return fmt.Errorf("wurfl_file_dir_path is required") + } + return nil +} diff --git a/modules/scientiamobile/wurfl_devicedetection/config_test.go b/modules/scientiamobile/wurfl_devicedetection/config_test.go new file mode 100644 index 00000000000..5abc327481c --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/config_test.go @@ -0,0 +1,151 @@ +package wurfl_devicedetection + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewConfig(t *testing.T) { + tests := []struct { + name string + input json.RawMessage + expectedErr bool + validate func(t *testing.T, cfg config) + }{ + { + name: "Valid config with default cache size", + input: json.RawMessage(`{ + "wurfl_snapshot_url": "http://example.com/wurfl-data", + "wurfl_file_dir_path": "/tmp/wurfl", + "wurfl_run_updater": false + }`), + expectedErr: false, + validate: func(t *testing.T, cfg config) { + assert.Equal(t, "http://example.com/wurfl-data", cfg.WURFLSnapshotURL) + assert.Equal(t, "/tmp/wurfl", cfg.WURFLFileDirPath) + assert.False(t, *cfg.WURFLRunUpdater) + assert.Equal(t, defaultCacheSize, cfg.WURFLEngineCacheSize()) + }, + }, + { + name: "Valid config with custom cache size", + input: json.RawMessage(`{ + "wurfl_snapshot_url": "http://example.com/wurfl-data", + "wurfl_file_dir_path": "/tmp/wurfl", + "wurfl_cache_size": 5000, + "wurfl_run_updater": true + }`), + expectedErr: false, + validate: func(t *testing.T, cfg config) { + assert.Equal(t, "5000", cfg.WURFLEngineCacheSize()) + assert.True(t, *cfg.WURFLRunUpdater) + }, + }, + { + name: "Invalid config - missing wurfl_snapshot_url", + input: json.RawMessage(`{ + "wurfl_file_dir_path": "/tmp/wurfl", + }`), + expectedErr: true, + }, + { + name: "Invalid config - missing wurfl_file_dir_path", + input: json.RawMessage(`{ + "wurfl_snapshot_url": "http://example.com/wurfl-data", + }`), + expectedErr: true, + }, + { + name: "Default wurfl_run_updater", + input: json.RawMessage(`{ + "wurfl_snapshot_url": "http://example.com/wurfl-data", + "wurfl_file_dir_path": "/tmp/wurfl" + }`), + expectedErr: false, + validate: func(t *testing.T, cfg config) { + assert.Nil(t, cfg.WURFLRunUpdater) + }, + }, + { + name: "Invalid config - malformed JSON", + input: json.RawMessage(`{ "wurfl_snapshot_url": "http://example.com/wurfl-data", "wurfl_file_dir_path": "/tmp/wurfl",`), // Malformed JSON + expectedErr: true, + }, + { + name: "Empty config", + input: json.RawMessage(`{}`), + expectedErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg, err := newConfig(tc.input) + + if tc.expectedErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + if tc.validate != nil { + tc.validate(t, cfg) + } + }) + } +} + +func TestWURFLFilePath(t *testing.T) { + cfg := config{ + WURFLFileDirPath: "/tmp/wurfl", + WURFLSnapshotURL: "http://example.com/wurfl-data/wurfl.zip", + } + expectedPath := "/tmp/wurfl/wurfl.zip" + assert.Equal(t, expectedPath, cfg.WURFLFilePath()) +} + +func TestValidate(t *testing.T) { + tests := []struct { + name string + cfg config + expectedErr bool + }{ + { + name: "Valid config", + cfg: config{ + WURFLSnapshotURL: "http://example.com/wurfl-data", + WURFLFileDirPath: "/tmp/wurfl", + }, + expectedErr: false, + }, + { + name: "Invalid config - missing wurfl_snapshot_url", + cfg: config{ + WURFLFileDirPath: "/tmp/wurfl", + }, + expectedErr: true, + }, + { + name: "Invalid config - missing wurfl_file_dir_path", + cfg: config{ + WURFLSnapshotURL: "http://example.com/wurfl-data", + }, + expectedErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.cfg.validate() + + if tc.expectedErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + }) + } +} diff --git a/modules/scientiamobile/wurfl_devicedetection/libwurfl/.gitignore b/modules/scientiamobile/wurfl_devicedetection/libwurfl/.gitignore new file mode 100644 index 00000000000..a5baada18fd --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/libwurfl/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore + diff --git a/modules/scientiamobile/wurfl_devicedetection/module.go b/modules/scientiamobile/wurfl_devicedetection/module.go new file mode 100644 index 00000000000..fef9ae854b2 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/module.go @@ -0,0 +1,177 @@ +package wurfl_devicedetection + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/buger/jsonparser" + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v3/hooks/hookexecution" + "github.com/prebid/prebid-server/v3/hooks/hookstage" + "github.com/prebid/prebid-server/v3/modules/moduledeps" + "github.com/tidwall/sjson" +) + +const ( + wurflHeaderCtxKey = "wurfl_header" +) + +// declare conformity with hookstage.RawActionRequest interface +var ( + _ hookstage.RawAuctionRequest = Module{} + _ hookstage.Entrypoint = Module{} +) + +// payloadPublisherIDPaths specifies the possible paths in the Request payload JSON +// where the publisher ID can be defined. +var payloadPublisherIDPaths = [][]string{ + {"site", "publisher", "id"}, + {"app", "publisher", "id"}, + {"dooh", "publisher", "id"}, +} + +func Builder(configRaw json.RawMessage, _ moduledeps.ModuleDeps) (interface{}, error) { + cfg, err := newConfig(configRaw) + if err != nil { + return nil, err + } + + we, err := newWurflEngine(cfg) + if err != nil { + return nil, err + } + + m := Module{ + we: we, + extCaps: cfg.ExtCaps, + } + if len(cfg.AllowedPublisherIDs) > 0 { + m.allowedPublisherIDs = make(map[string]bool, len(cfg.AllowedPublisherIDs)) + for _, v := range cfg.AllowedPublisherIDs { + m.allowedPublisherIDs[v] = true + } + } + return m, nil +} + +// Module must implement at least 1 hook interface. +type Module struct { + we wurflDeviceDetection + allowedPublisherIDs map[string]bool + extCaps bool +} + +// HandleEntrypointHook implements hookstage.Entrypoint. +func (m Module) HandleEntrypointHook(ctx context.Context, invocationCtx hookstage.ModuleInvocationContext, payload hookstage.EntrypointPayload) (hookstage.HookResult[hookstage.EntrypointPayload], error) { + result := hookstage.HookResult[hookstage.EntrypointPayload]{} + if !m.isPublisherAllowed(payload.Body) { + return result, hookexecution.NewFailure("publisher not allowed") + } + header := map[string]string{} + if payload.Request != nil { + for k := range payload.Request.Header { + header[k] = payload.Request.Header.Get(k) + } + } + moduleContext := make(hookstage.ModuleContext) + moduleContext[wurflHeaderCtxKey] = header + result.ModuleContext = moduleContext + + return result, nil +} + +// HandleRawAuctioneHook implements hookstage.RawAuctionRequest. +func (m Module) HandleRawAuctionHook( + ctx context.Context, + invocationCtx hookstage.ModuleInvocationContext, + payload hookstage.RawAuctionRequestPayload, +) (hookstage.HookResult[hookstage.RawAuctionRequestPayload], error) { + result := hookstage.HookResult[hookstage.RawAuctionRequestPayload]{} + + if invocationCtx.ModuleContext == nil { + // The module context has not be inizialized in the entrypoint hook. + // This could be due to a not allowed publisher or an error. + // Return the payload as is + return result, hookexecution.NewFailure("module context has not been inizialized in the entrypoint hook") + } + + if isWURFLEnrichedRequest(payload) { + return result, nil + } + + rawHeaders := invocationCtx.ModuleContext[wurflHeaderCtxKey].(map[string]string) + result.ChangeSet.AddMutation(func(payload hookstage.RawAuctionRequestPayload) (hookstage.RawAuctionRequestPayload, error) { + ortb2Device, err := getOrtb2Device(payload) + if err != nil { + return payload, hookexecution.NewFailure("could not get ortb2.Device from payload %s", err) + } + + headers := makeHeaders(ortb2Device, rawHeaders) + + wd, err := m.we.DeviceDetection(headers) + if err != nil { + return payload, hookexecution.NewFailure("could not perform WURFL device detection %s", err) + } + + we := &wurflEnricher{ + WurflData: wd, + ExtCaps: m.extCaps, + } + we.EnrichDevice(&ortb2Device) + + updatedPayload, err := sjson.SetBytes(payload, "device", ortb2Device) + if err != nil { + return payload, hookexecution.NewFailure("could not update ortb2.Device payload %s", err) + } + return updatedPayload, nil + }, + hookstage.MutationUpdate, + "device", + ) + return result, nil +} + +// isPublisherAllowed verifies whether the publisher ID from a request payload is allowed to use this module. +// It checks against a list of authorized IDs, searching for the publisher ID in the site, app, or DOOH fields. +func (m Module) isPublisherAllowed(payload []byte) bool { + if m.allowedPublisherIDs == nil { + return true + } + var publisherID string + jsonparser.EachKey(payload, func(idx int, value []byte, vt jsonparser.ValueType, err error) { + if err != nil { + return + } + if vt != jsonparser.String { + return + } + publisherID = string(value) + }, payloadPublisherIDPaths...) + if publisherID == "" { + return false + } + _, found := m.allowedPublisherIDs[publisherID] + return found +} + +// getOrbt2Device extracts the openrtb2.Device from the bid request body. +func getOrtb2Device(payload []byte) (openrtb2.Device, error) { + device := openrtb2.Device{} + b, t, _, err := jsonparser.Get(payload, "device") + if err != nil { + return device, err + } + if t != jsonparser.Object { + return device, fmt.Errorf("expecting Object, got %s", t) + } + err = json.Unmarshal(b, &device) + return device, err +} + +// isWURFLEnrichedRequest returns true if the payload request has been already +// enriched with WURFL data like requests from Prebid.js with WURFL RTD module +func isWURFLEnrichedRequest(payload []byte) bool { + _, _, _, err := jsonparser.Get(payload, "device", "ext", ortb2WurflExtKey) + return err == nil +} diff --git a/modules/scientiamobile/wurfl_devicedetection/module_test.go b/modules/scientiamobile/wurfl_devicedetection/module_test.go new file mode 100644 index 00000000000..772ad8d7186 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/module_test.go @@ -0,0 +1,581 @@ +package wurfl_devicedetection + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + + "github.com/prebid/prebid-server/v3/hooks/hookstage" + "github.com/prebid/prebid-server/v3/modules/moduledeps" + "github.com/stretchr/testify/assert" +) + +func TestBuilder(t *testing.T) { + tests := []struct { + name string + configRaw json.RawMessage + expectedErr bool + validate func(t *testing.T, module interface{}) + }{ + { + name: "Valid configuration", + configRaw: json.RawMessage(`{ + "wurfl_snapshot_url": "http://example.com/wurfl-data", + "wurfl_file_dir_path": "/tmp/wurfl", + "allowed_publisher_ids": ["pub1", "pub2"], + "ext_caps": true + }`), + expectedErr: false, + validate: func(t *testing.T, module interface{}) { + m, ok := module.(Module) + assert.True(t, ok, "Module type assertion failed") + assert.Equal(t, map[string]bool{"pub1": true, "pub2": true}, m.allowedPublisherIDs) + assert.True(t, m.extCaps) + assert.NotNil(t, m.we) + }, + }, + { + name: "Invalid configuration - newConfig fails", + configRaw: json.RawMessage(`{ "wurfl_snapshot_url": "http://example.com/wurfl-data" }`), // Missing required fields + expectedErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + module, err := Builder(tc.configRaw, moduledeps.ModuleDeps{}) + + if tc.expectedErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + if tc.validate != nil { + tc.validate(t, module) + } + }) + } +} + +func TestHandleEntrypointHook(t *testing.T) { + tests := []struct { + name string + module Module + payload hookstage.EntrypointPayload + expectedError bool + expectedModuleCtx map[string]map[string]string + }{ + { + name: "Publisher allowed with headers", + module: Module{ + allowedPublisherIDs: map[string]bool{"pub1": true}, + }, + payload: hookstage.EntrypointPayload{ + Body: []byte(`{"site":{"publisher":{"id":"pub1"}}}`), + Request: &http.Request{ + Header: http.Header{ + "User-Agent": {"Mozilla/5.0"}, + "X-Test": {"HeaderValue"}, + }, + }, + }, + expectedError: false, + expectedModuleCtx: map[string]map[string]string{ + wurflHeaderCtxKey: { + "User-Agent": "Mozilla/5.0", + "X-Test": "HeaderValue", + }, + }, + }, + { + name: "Publisher not allowed", + module: Module{ + allowedPublisherIDs: map[string]bool{"pub1": true}, + }, + payload: hookstage.EntrypointPayload{ + Body: []byte(`{"site":{"publisher":{"id":"pub2"}}}`), + Request: &http.Request{ + Header: http.Header{ + "User-Agent": {"Mozilla/5.0"}, + }, + }, + }, + expectedError: true, + expectedModuleCtx: nil, + }, + { + name: "No publisher ID in payload", + module: Module{ + allowedPublisherIDs: map[string]bool{"pub1": true}, + }, + payload: hookstage.EntrypointPayload{ + Body: []byte(`{}`), + Request: &http.Request{ + Header: http.Header{ + "User-Agent": {"Mozilla/5.0"}, + }, + }, + }, + expectedError: true, + expectedModuleCtx: nil, + }, + { + name: "Nil Request, publisher allowed", + module: Module{ + allowedPublisherIDs: map[string]bool{"pub1": true}, + }, + payload: hookstage.EntrypointPayload{ + Body: []byte(`{"site":{"publisher":{"id":"pub1"}}}`), + Request: nil, + }, + expectedError: false, + expectedModuleCtx: map[string]map[string]string{ + wurflHeaderCtxKey: {}, + }, + }, + { + name: "Nil allowedPublisherIDs (all publishers allowed)", + module: Module{ + allowedPublisherIDs: nil, + }, + payload: hookstage.EntrypointPayload{ + Body: []byte(`{"site":{"publisher":{"id":"pub1"}}}`), + Request: &http.Request{ + Header: http.Header{ + "X-Custom-Header": {"HeaderValue"}, + }, + }, + }, + expectedError: false, + expectedModuleCtx: map[string]map[string]string{ + wurflHeaderCtxKey: { + "X-Custom-Header": "HeaderValue", + }, + }, + }, + { + name: "Malformed payload", + module: Module{ + allowedPublisherIDs: map[string]bool{"pub1": true}, + }, + payload: hookstage.EntrypointPayload{ + Body: []byte(`{"site":{"publisher": `), + Request: &http.Request{ + Header: http.Header{ + "X-Custom-Header": {"HeaderValue"}, + }, + }, + }, + expectedError: true, + expectedModuleCtx: nil, + }, + { + name: "Empty headers", + module: Module{ + allowedPublisherIDs: map[string]bool{"pub1": true}, + }, + payload: hookstage.EntrypointPayload{ + Body: []byte(`{"site":{"publisher":{"id":"pub1"}}}`), + Request: &http.Request{Header: http.Header{}}, + }, + expectedError: false, + expectedModuleCtx: map[string]map[string]string{ + wurflHeaderCtxKey: {}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := tc.module.HandleEntrypointHook(context.Background(), hookstage.ModuleInvocationContext{}, tc.payload) + + if tc.expectedError { + assert.Error(t, err) + assert.Nil(t, result.ModuleContext) + } else { + assert.NoError(t, err) + assert.NotNil(t, result.ModuleContext) + assert.Equal(t, tc.expectedModuleCtx[wurflHeaderCtxKey], result.ModuleContext[wurflHeaderCtxKey]) + } + }) + } +} + +func TestHandleRawAuctionHook(t *testing.T) { + tests := []struct { + name string + module Module + invocationCtx hookstage.ModuleInvocationContext + payload hookstage.RawAuctionRequestPayload + expectedErr bool + mutationErr bool + expectedPayload string + }{ + { + name: "Successful device enrichment without extCaps", + module: Module{ + we: &mockWurflDeviceDetection{ + detectDeviceFunc: func(headers map[string]string) (wurflData, error) { + return wurflData{ + "brand_name": "BrandX", + "model_name": "ModelY", + "is_mobile": "true", + "is_phone": "true", + "is_tablet": "false", + }, nil + }, + }, + extCaps: false, + }, + invocationCtx: hookstage.ModuleInvocationContext{ + ModuleContext: hookstage.ModuleContext{ + wurflHeaderCtxKey: map[string]string{ + "User-Agent": "Mozilla/5.0", + }, + }, + }, + payload: []byte(`{"device":{"ua":"Mozilla/5.0"}}`), + expectedErr: false, + expectedPayload: `{ + "device": { + "ua": "Mozilla/5.0", + "make": "BrandX", + "model": "ModelY", + "hwv": "ModelY", + "devicetype": 1 + } + }`, + }, + { + name: "Nil module context", + module: Module{ + we: &mockWurflDeviceDetection{ + detectDeviceFunc: func(headers map[string]string) (wurflData, error) { + return wurflData{ + "brand_name": "BrandX", + "model_name": "ModelY", + "is_mobile": "true", + "is_phone": "true", + "is_tablet": "false", + }, nil + }, + }, + extCaps: false, + }, + invocationCtx: hookstage.ModuleInvocationContext{}, + payload: []byte(`{"device":{"ua":"Mozilla/5.0"}}`), + expectedErr: true, + expectedPayload: `{"device":{"ua":"Mozilla/5.0"}}`, + }, + { + name: "Successful device enrichment with extCaps", + module: Module{ + we: &mockWurflDeviceDetection{ + detectDeviceFunc: func(headers map[string]string) (wurflData, error) { + return wurflData{ + "brand_name": "BrandX", + "model_name": "ModelY", + "is_mobile": "true", + "is_phone": "true", + "is_tablet": "false", + }, nil + }, + }, + extCaps: true, + }, + invocationCtx: hookstage.ModuleInvocationContext{ + ModuleContext: hookstage.ModuleContext{ + wurflHeaderCtxKey: map[string]string{ + "User-Agent": "Mozilla/5.0", + }, + }, + }, + payload: []byte(`{"device":{"ua":"Mozilla/5.0"}}`), + expectedErr: false, + expectedPayload: `{ + "device": { + "ua": "Mozilla/5.0", + "make": "BrandX", + "model": "ModelY", + "hwv": "ModelY", + "devicetype": 1, + "ext": { + "wurfl": { + "brand_name": "BrandX", + "model_name": "ModelY", + "is_mobile": "true", + "is_phone": "true", + "is_tablet": "false" + } + } + } + }`, + }, + { + name: "Successful device enrichment with ext data and with extCaps", + module: Module{ + we: &mockWurflDeviceDetection{ + detectDeviceFunc: func(headers map[string]string) (wurflData, error) { + return wurflData{ + "brand_name": "BrandX", + "model_name": "ModelY", + "is_mobile": "true", + "is_phone": "true", + "is_tablet": "false", + }, nil + }, + }, + extCaps: true, + }, + invocationCtx: hookstage.ModuleInvocationContext{ + ModuleContext: hookstage.ModuleContext{ + wurflHeaderCtxKey: map[string]string{ + "User-Agent": "Mozilla/5.0", + }, + }, + }, + payload: []byte(`{"device":{"ua":"Mozilla/5.0", "ext": {"test": 1}}}`), + expectedErr: false, + expectedPayload: `{ + "device": { + "ua": "Mozilla/5.0", + "make": "BrandX", + "model": "ModelY", + "hwv": "ModelY", + "devicetype": 1, + "ext": { + "test": 1, + "wurfl": { + "brand_name": "BrandX", + "model_name": "ModelY", + "is_mobile": "true", + "is_phone": "true", + "is_tablet": "false" + } + } + } + }`, + }, + { + name: "Failed device detection", + module: Module{ + we: &mockWurflDeviceDetection{ + detectDeviceFunc: func(headers map[string]string) (wurflData, error) { + return nil, errors.New("device detection error") + }, + }, + extCaps: false, + }, + invocationCtx: hookstage.ModuleInvocationContext{ + ModuleContext: hookstage.ModuleContext{ + wurflHeaderCtxKey: map[string]string{ + "User-Agent": "Mozilla/5.0", + }, + }, + }, + payload: []byte(`{"device":{"ua":"Mozilla/5.0"}}`), + expectedErr: false, + mutationErr: true, + expectedPayload: `{"device":{"ua":"Mozilla/5.0"}}`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := tc.module.HandleRawAuctionHook(context.Background(), tc.invocationCtx, tc.payload) + if tc.expectedErr { + assert.Error(t, err) + assert.JSONEq(t, tc.expectedPayload, string(tc.payload)) + return + } + assert.NoError(t, err) + + assert.Equal(t, len(result.ChangeSet.Mutations()), 1) + + assert.Equal(t, result.ChangeSet.Mutations()[0].Type(), hookstage.MutationUpdate) + + mutation := result.ChangeSet.Mutations()[0] + // Apply mutation + mutatedPayload, err := mutation.Apply(tc.payload) + if tc.mutationErr { + assert.Error(t, err) + assert.JSONEq(t, tc.expectedPayload, string(tc.payload)) + return + } + assert.NoError(t, err) + + // Verify the mutated payload + assert.JSONEq(t, tc.expectedPayload, string(mutatedPayload)) + }) + } +} + +// Mock implementation of wurflDeviceDetection +type mockWurflDeviceDetection struct { + detectDeviceFunc func(headers map[string]string) (wurflData, error) +} + +func (m *mockWurflDeviceDetection) DeviceDetection(headers map[string]string) (wurflData, error) { + return m.detectDeviceFunc(headers) +} + +func TestIsPublisherAllowed(t *testing.T) { + tests := []struct { + name string + module Module + payload []byte + expected bool + allowedPublisherIDs map[string]bool + }{ + { + name: "Allowed publisher - site.publisher.id", + module: Module{ + allowedPublisherIDs: map[string]bool{"pub1": true}, + }, + payload: []byte(`{"site":{"publisher":{"id":"pub1"}}}`), + expected: true, + }, + { + name: "Disallowed publisher - site.publisher.id", + module: Module{ + allowedPublisherIDs: map[string]bool{"pub1": true}, + }, + payload: []byte(`{"site":{"publisher":{"id":"pub2"}}}`), + expected: false, + }, + { + name: "Allowed publisher - app.publisher.id", + module: Module{ + allowedPublisherIDs: map[string]bool{"pub3": true}, + }, + payload: []byte(`{"app":{"publisher":{"id":"pub3"}}}`), + expected: true, + }, + { + name: "Disallowed publisher - app.publisher.id", + module: Module{ + allowedPublisherIDs: map[string]bool{"pub3": true}, + }, + payload: []byte(`{"app":{"publisher":{"id":"pub4"}}}`), + expected: false, + }, + { + name: "Allowed publisher - dooh.publisher.id", + module: Module{ + allowedPublisherIDs: map[string]bool{"pub5": true}, + }, + payload: []byte(`{"dooh":{"publisher":{"id":"pub5"}}}`), + expected: true, + }, + { + name: "Disallowed publisher - dooh.publisher.id", + module: Module{ + allowedPublisherIDs: map[string]bool{"pub5": true}, + }, + payload: []byte(`{"dooh":{"publisher":{"id":"pub6"}}}`), + expected: false, + }, + { + name: "Empty payload - no publisher ID", + module: Module{ + allowedPublisherIDs: map[string]bool{"pub1": true}, + }, + payload: []byte(`{}`), + expected: false, + }, + { + name: "Nil allowedPublisherIDs - all publishers allowed", + module: Module{ + allowedPublisherIDs: nil, + }, + payload: []byte(`{"site":{"publisher":{"id":"pub1"}}}`), + expected: true, + }, + { + name: "Malformed JSON - no publisher ID", + module: Module{ + allowedPublisherIDs: map[string]bool{"pub1": true}, + }, + payload: []byte(`{"site":{"publisher":{}}`), // Missing closing braces + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := tc.module.isPublisherAllowed(tc.payload) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestGetOrtb2Device(t *testing.T) { + tests := []struct { + name string + payload []byte + expectError bool + expected openrtb2.Device + }{ + { + name: "Valid device object", + payload: []byte(`{ + "device": { + "ua": "Mozilla/5.0", + "ip": "192.168.0.1", + "make": "Apple", + "model": "iPhone" + } + }`), + expectError: false, + expected: openrtb2.Device{ + UA: "Mozilla/5.0", + IP: "192.168.0.1", + Make: "Apple", + Model: "iPhone", + }, + }, + { + name: "Missing device field", + payload: []byte(`{}`), + expectError: true, + expected: openrtb2.Device{}, + }, + { + name: "Invalid device type (non-object)", + payload: []byte(`{"device": "string_instead_of_object"}`), + expectError: true, + expected: openrtb2.Device{}, + }, + { + name: "Malformed JSON", + payload: []byte(`{"device": { "ua": "Mozilla/5.0"`), // Missing closing braces + expectError: true, + expected: openrtb2.Device{}, + }, + { + name: "Empty payload", + payload: []byte(``), + expectError: true, + expected: openrtb2.Device{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + device, err := getOrtb2Device(tc.payload) + + if tc.expectError { + assert.Error(t, err) + assert.Equal(t, tc.expected, device) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expected, device) + } + }) + } +} diff --git a/modules/scientiamobile/wurfl_devicedetection/sample/pbs_example.json b/modules/scientiamobile/wurfl_devicedetection/sample/pbs_example.json new file mode 100644 index 00000000000..fd6acbcc0cb --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/sample/pbs_example.json @@ -0,0 +1,66 @@ +{ + "adapters": [ + { + "appnexus": { + "enabled": true + } + } + ], + "gdpr": { + "enabled": true, + "default_value": 0, + "timeouts_ms": { + "active_vendorlist_fetch": 900000 + } + }, + "hooks": { + "enabled": true, + "modules": { + "scientiamobile": { + "wurfl_devicedetection": { + "enabled": true, + "wurfl_snapshot_url": "", + "wurfl_file_dir_path": "/tmp", + "wurfl_run_updater": true, + "wurfl_cache_size": 200000, + "allowed_publisher_ids": ["1","3"], + "ext_caps": true + } + } + }, + "host_execution_plan": { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "entrypoint": { + "groups": [ + { + "timeout": 10, + "hook_sequence": [ + { + "module_code": "scientiamobile.wurfl_devicedetection", + "hook_impl_code": "scientiamobile-wurfl_devicedetection-entrypoint-hook" + } + ] + } + ] + }, + "raw_auction_request": { + "groups": [ + { + "timeout": 10, + "hook_sequence": [ + { + "module_code": "scientiamobile.wurfl_devicedetection", + "hook_impl_code": "scientiamobile-wurfl_devicedetection-raw-auction-request-hook" + } + ] + } + ] + } + } + } + } + } + } +} diff --git a/modules/scientiamobile/wurfl_devicedetection/sample/request_data.json b/modules/scientiamobile/wurfl_devicedetection/sample/request_data.json new file mode 100644 index 00000000000..42691bbc74d --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/sample/request_data.json @@ -0,0 +1,119 @@ +{ + "imp": [ + { + "ext": { + "data": { + "adserver": { + "name": "gam", + "adslot": "test" + }, + "pbadslot": "test", + "gpid": "test" + }, + "gpid": "test", + "prebid": { + "bidder": { + "appnexus": { + "placement_id": 1, + "use_pmt_rule": false + }, + "0test": { + "placement_id": 1 + } + }, + "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 15; Pixel 9 Pro XL Build/AP3A.241005.015; ) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 EdgA/124.0.2478.64" + }, + "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": 1799310801804, + "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": 2000 +} diff --git a/modules/scientiamobile/wurfl_devicedetection/wurfl_data.go b/modules/scientiamobile/wurfl_devicedetection/wurfl_data.go new file mode 100644 index 00000000000..0441f1a1f7f --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/wurfl_data.go @@ -0,0 +1,85 @@ +package wurfl_devicedetection + +import ( + "encoding/json" + "fmt" + "strconv" +) + +const ( + wurflID = "wurfl_id" +) + +// declare conformity with json.Marshaler interface +var _ json.Marshaler = wurflData{} + +// wurflData represents the WURFL data +type wurflData map[string]string + +// Bool retrieves a capability value as a bool +func (wd wurflData) Bool(key string) (bool, error) { + val, exists := wd[key] + if !exists { + return false, fmt.Errorf("capability not found: %q", key) + } + result, err := strconv.ParseBool(val) + if err != nil { + return false, fmt.Errorf("cound not parse %q to bool for capability %q", val, key) + } + return result, nil +} + +// Int64 retrieves a capability value as an int64 +func (wd wurflData) Int64(key string) (int64, error) { + val, exists := wd[key] + if !exists { + return 0, fmt.Errorf("capability not found: %q", key) + } + result, err := strconv.ParseInt(val, 10, 64) + if err != nil { + return 0, fmt.Errorf("cound not parse %q to int64 for capability %q", val, key) + } + return result, nil +} + +// Float64 retrieves a capability value as a float64 +func (wd wurflData) Float64(key string) (float64, error) { + val, exists := wd[key] + if !exists { + return 0.0, fmt.Errorf("capability not found: %q", key) + } + result, err := strconv.ParseFloat(val, 64) + if err != nil { + return 0.0, fmt.Errorf("cound not parse %q to float64 for capability %q", val, key) + } + return result, nil +} + +// String retrieves a capability value as a string +func (wd wurflData) String(key string) (string, error) { + val, exists := wd[key] + if !exists { + return "", fmt.Errorf("capability not found: %q", key) + } + return val, nil +} + +// WurflIDToJSON returns a JSON representation of the WURFL ID +func (wd wurflData) WurflIDToJSON() ([]byte, error) { + m := make(map[string]string) + v, ok := wd[wurflID] + if !ok { + return nil, fmt.Errorf("WURFL ID does not exits") + } + m[wurflID] = v + return json.Marshal(m) +} + +// MarshalJSON customizes the JSON marshaling for wurflData +func (wd wurflData) MarshalJSON() ([]byte, error) { + m := make(map[string]string) + for k, v := range wd { + m[k] = v + } + return json.Marshal(m) +} diff --git a/modules/scientiamobile/wurfl_devicedetection/wurfl_data_test.go b/modules/scientiamobile/wurfl_devicedetection/wurfl_data_test.go new file mode 100644 index 00000000000..257d5933d96 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/wurfl_data_test.go @@ -0,0 +1,145 @@ +package wurfl_devicedetection + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWurflData_MarshalJSON(t *testing.T) { + tests := []struct { + name string + data wurflData + expected string + }{ + { + name: "Non-empty wurflData", + data: wurflData{ + "brand_name": "BrandX", + "model_name": "ModelY", + }, + expected: `{"brand_name":"BrandX","model_name":"ModelY"}`, + }, + { + name: "Empty wurflData", + data: wurflData{}, + expected: `{}`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := tc.data.MarshalJSON() + assert.NoError(t, err) + assert.JSONEq(t, tc.expected, string(result)) + }) + } +} + +func TestWurflData_SON(t *testing.T) { + tests := []struct { + name string + data wurflData + expected string + expectedErr bool + }{ + { + name: "Non-empty wurflData", + data: wurflData{ + "brand_name": "BrandX", + "model_name": "ModelY", + "wurfl_id": "test", + }, + expected: `{"wurfl_id":"test"}`, + }, + { + name: "Missed WURFL ID", + data: wurflData{}, + expectedErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := tc.data.WurflIDToJSON() + if tc.expectedErr { + assert.Error(t, err) + assert.Nil(t, result) + return + } + assert.NoError(t, err) + assert.JSONEq(t, tc.expected, string(result)) + }) + } +} + +func TestWurflData_Bool(t *testing.T) { + data := wurflData{ + "ajax_support_javascript": "true", + "invalid_value": "not_a_bool", + } + + v, err := data.Bool("ajax_support_javascript") + assert.NoError(t, err) + assert.True(t, v) + + v, err = data.Bool("invalid_value") + assert.Error(t, err) + assert.Empty(t, v) + + v, err = data.Bool("non_existent_key") + assert.Error(t, err) + assert.Empty(t, v) +} + +func TestWurflData_Float64(t *testing.T) { + data := wurflData{ + "density_class": "2.5", + "invalid_value": "not_a_number", + } + + v, err := data.Float64("density_class") + assert.NoError(t, err) + assert.Equal(t, 2.5, v) + + v, err = data.Float64("invalid_value") + assert.Empty(t, v) + assert.Error(t, err) + + v, err = data.Float64("non_existent_key") + assert.Empty(t, v) + assert.Error(t, err) +} + +func TestWurflData_Int64(t *testing.T) { + data := wurflData{ + "resolution_height": "1080", + "invalid_value": "not_a_number", + } + + v, err := data.Int64("resolution_height") + assert.NoError(t, err) + assert.Equal(t, int64(1080), v) + + v, err = data.Int64("invalid_value") + assert.Empty(t, v) + assert.Error(t, err) + + v, err = data.Int64("non_existent_key") + assert.Empty(t, v) + assert.Error(t, err) +} + +func TestWurflData_String(t *testing.T) { + data := wurflData{ + "brand_name": "BrandX", + } + + v, err := data.String("brand_name") + assert.NoError(t, err) + assert.Equal(t, "BrandX", v) + + v, err = data.String("non_existent_key") + assert.Empty(t, v) + assert.Error(t, err) +} diff --git a/modules/scientiamobile/wurfl_devicedetection/wurfl_engine.go b/modules/scientiamobile/wurfl_devicedetection/wurfl_engine.go new file mode 100644 index 00000000000..fea5c3f6e9f --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/wurfl_engine.go @@ -0,0 +1,127 @@ +//go:build wurfl + +package wurfl_devicedetection + +import ( + "fmt" + "strings" + + wurfl "github.com/WURFL/golang-wurfl" +) + +// declare conformity with wurflDeviceDetection interface +var _ wurflDeviceDetection = (*wurflEngine)(nil) + +// newWurflEngine creates a new Enricher +func newWurflEngine(c config) (wurflDeviceDetection, error) { + err := wurfl.Download(c.WURFLSnapshotURL, c.WURFLFileDirPath) + if err != nil { + return nil, err + } + wengine, err := wurfl.Create(c.WURFLFilePath(), + nil, + nil, + -1, + wurfl.WurflCacheProviderLru, + c.WURFLEngineCacheSize(), + ) + if err != nil { + return nil, err + } + + if c.WURFLRunUpdater == nil || *c.WURFLRunUpdater { + uerr := wengine.SetUpdaterDataURL(c.WURFLSnapshotURL) + if uerr != nil { + return nil, fmt.Errorf("could not set WURFL Updater Snapshot URL: %sn", uerr.Error()) + } + + uerr = wengine.SetUpdaterDataFrequency(wurfl.WurflUpdaterFrequencyDaily) + if uerr != nil { + return nil, fmt.Errorf("could not set the WURFL Updater frequency: %s", uerr.Error()) + } + + uerr = wengine.UpdaterStart() + if uerr != nil { + return nil, fmt.Errorf("could not start the WURFL Updater: %s", uerr.Error()) + } + + } + + caps := wengine.GetAllCaps() + e := &wurflEngine{ + wengine: wengine, + caps: caps, + vcaps: vcaps, + } + + err = e.validate() + if err != nil { + return nil, err + } + + return e, nil +} + +// wurflEngine is the ortb2 enricher powered by WURFL +type wurflEngine struct { + wengine *wurfl.Wurfl + caps []string + vcaps []string +} + +// deviceDetection performs device detection using the WURFL engine. +func (e *wurflEngine) DeviceDetection(headers map[string]string) (wurflData, error) { + wurflDevice, err := e.wengine.LookupWithImportantHeaderMap(headers) + if err != nil { + return nil, err + } + defer wurflDevice.Destroy() + + wurflDeviceID, err := wurflDevice.GetDeviceID() + if err != nil { + return nil, err + } + wurflData, err := wurflDevice.GetStaticCaps(e.caps) + if err != nil { + return nil, err + } + vcaps, err := wurflDevice.GetVirtualCaps(e.vcaps) + if err != nil { + return nil, err + } + for k, v := range vcaps { + wurflData[k] = v + } + wurflData[wurflID] = wurflDeviceID + return wurflData, nil +} + +// validate checks if the WURFL file has all the required capabilities +func (e *wurflEngine) validate() error { + requiredCaps := []string{ + ajaxSupportJavascriptCapKey, + brandNameCapKey, + densityClassCapKey, + isConnectedTVCapKey, + isOTTCapKey, + isTabletCapKey, + modelNameCapKey, + physicalFormFactorCapKey, + resolutionWidthCapKey, + resolutionWidthCapKey, + } + m := map[string]bool{} + for _, val := range e.caps { + m[val] = true + } + missed := []string{} + for _, val := range requiredCaps { + if _, ok := m[val]; !ok { + missed = append(missed, val) + } + } + if len(missed) > 0 { + return fmt.Errorf("WURFL file is missing the following capabilities: %s", strings.Join(missed, ",")) + } + return nil +} diff --git a/modules/scientiamobile/wurfl_devicedetection/wurfl_engine_fallback.go b/modules/scientiamobile/wurfl_devicedetection/wurfl_engine_fallback.go new file mode 100644 index 00000000000..2af3a835856 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/wurfl_engine_fallback.go @@ -0,0 +1,58 @@ +//go:build !wurfl + +package wurfl_devicedetection + +import "github.com/golang/glog" + +// declare conformity with wurflDeviceDetection interface +var _ wurflDeviceDetection = (*wurflEngine)(nil) + +// newWurflEngine creates a new Enricher +func newWurflEngine(_ config) (wurflDeviceDetection, error) { + glog.Error("WURFL module is enabled but not installed correctly. Running fallback implementation with fake data.") + return &wurflEngine{}, nil +} + +// wurflEngine is the ortb2 enricher powered by WURFL +type wurflEngine struct{} + +// deviceDetection performs device detection using the WURFL engine. +func (e *wurflEngine) DeviceDetection(headers map[string]string) (wurflData, error) { + // wd represents the data as per sample request + wd := map[string]string{ + "physical_screen_width": "71", + "model_name": "Pixel 9 Pro XL", + "pixel_density": "481", + "device_os_version": "15.0", + "pointing_method": "touchscreen", + "is_wireless_device": "true", + "is_smarttv": "false", + "is_phone": "true", + "device_os": "Android", + "density_class": "2.55", + "resolution_width": "1344", + "resolution_height": "2992", + "ux_full_desktop": "false", + "is_full_desktop": "false", + "marketing_name": "", + "mobile_browser": "Chrome Mobile", + "preferred_markup": "html_web_4_0", + "is_connected_tv": "false", + "physical_screen_height": "158", + "advertised_device_os_version": "15", + "form_factor": "Smartphone", + "mobile_browser_version": "", + "ajax_support_javascript": "true", + "can_assign_phone_number": "true", + "is_ott": "false", + "advertised_device_os": "Android", + "wurfl_id": "google_pixel_9_pro_xl_ver1_suban150", + "complete_device_name": "Google Pixel 9 Pro XL", + "is_mobile": "true", + "is_tablet": "false", + "physical_form_factor": "phone_phablet", + "xhtml_support_level": "4", + "brand_name": "Google", + } + return wd, nil +} diff --git a/modules/scientiamobile/wurfl_devicedetection/wurfl_enricher.go b/modules/scientiamobile/wurfl_devicedetection/wurfl_enricher.go new file mode 100644 index 00000000000..65df1b30f02 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/wurfl_enricher.go @@ -0,0 +1,216 @@ +package wurfl_devicedetection + +import ( + "github.com/golang/glog" + "github.com/prebid/openrtb/v20/adcom1" + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/tidwall/sjson" +) + +const ( + advertisedDeviceOSCapKey = "advertised_device_os" + advertisedDeviceOSVersionCapKey = "advertised_device_os_version" + ajaxSupportJavascriptCapKey = "ajax_support_javascript" + brandNameCapKey = "brand_name" + completeDeviceNameCapKey = "complete_device_name" + densityClassCapKey = "density_class" + formFactorCapKey = "form_factor" + isConnectedTVCapKey = "is_connected_tv" + isFullDesktopCapKey = "is_full_desktop" + isMobileCapKey = "is_mobile" + isOTTCapKey = "is_ott" + isPhoneCapKey = "is_phone" + isTabletCapKey = "is_tablet" + modelNameCapKey = "model_name" + physicalFormFactorCapKey = "physical_form_factor" + pixelDensityCapKey = "pixel_density" + resolutionHeightCapKey = "resolution_height" + resolutionWidthCapKey = "resolution_width" +) + +const ( + ortb2WurflExtKey = "wurfl" +) + +const ( + outOfHomeDevice = "out_of_home_device" + trueString = "true" +) + +var vcaps = []string{ + advertisedDeviceOSCapKey, + advertisedDeviceOSVersionCapKey, + completeDeviceNameCapKey, + isFullDesktopCapKey, + isMobileCapKey, + isPhoneCapKey, + formFactorCapKey, + pixelDensityCapKey, +} + +// wurflDeviceDetection wraps the methods for the WURFL device detection +type wurflDeviceDetection interface { + DeviceDetection(headers map[string]string) (wurflData, error) +} + +// wurflEnricher represents the WURFL Enricher for Prebid +type wurflEnricher struct { + // WurflData holds the WURFL data + WurflData wurflData + // extCaps if true will enrchich the device.ext field with all WURFL caps + // Default to enrich only with the wurfl_id + ExtCaps bool +} + +// EnrichDevice enriches OpenRTB 2.x device with WURFL data +func (we wurflEnricher) EnrichDevice(device *openrtb2.Device) { + wd := we.WurflData + if device.Make == "" { + if v, err := wd.String(brandNameCapKey); err == nil { + device.Make = v + } + } + if device.Model == "" { + if v, err := wd.String(modelNameCapKey); err == nil { + device.Model = v + } + } + if device.DeviceType == 0 { + device.DeviceType = we.makeDeviceType() + } + if device.OS == "" { + if v, err := wd.String(advertisedDeviceOSCapKey); err == nil { + device.OS = v + } + } + if device.OSV == "" { + if v, err := wd.String(advertisedDeviceOSCapKey); err == nil { + device.OSV = v + } + } + if device.HWV == "" { + if v, err := wd.String(modelNameCapKey); err == nil { + device.HWV = v + } + } + if device.H == 0 { + if v, err := wd.Int64(resolutionHeightCapKey); err == nil { + device.H = v + } + } + if device.W == 0 { + if v, err := wd.Int64(resolutionWidthCapKey); err == nil { + device.W = v + } + } + if device.PPI == 0 { + if v, err := wd.Int64(pixelDensityCapKey); err == nil { + device.PPI = v + } + } + if device.PxRatio == 0 { + if v, err := wd.Float64(densityClassCapKey); err == nil { + device.PxRatio = v + } + } + if device.JS == nil { + if v, err := wd.Bool(ajaxSupportJavascriptCapKey); err == nil { + var js int8 + if v { + js = 1 + } + device.JS = &js + } + } + + wurflExtData, err := we.wurflExtData() + if err != nil { + return + } + // merges the WURFL data in device.ext under the wurfl "namespace" + ext, err := sjson.SetRawBytes(device.Ext, ortb2WurflExtKey, wurflExtData) + if err != nil { + return + } + device.Ext = ext +} + +// wurflExtData returns the WURFL data in JSON format for the device.ext field +func (we wurflEnricher) wurflExtData() ([]byte, error) { + if we.ExtCaps { + // return all WURFL data + return we.WurflData.MarshalJSON() + } + // return only the WURFL ID + return we.WurflData.WurflIDToJSON() +} + +// makeDeviceType returns an OpenRTB2 DeviceType from WURFL data +// see https://www.scientiamobile.com/how-to-populate-iab-openrtb-device-object/ +func (we wurflEnricher) makeDeviceType() adcom1.DeviceType { + wd := we.WurflData + unknownDeviceType := adcom1.DeviceType(0) + + isMobile, err := wd.Bool(isMobileCapKey) + if err != nil { + glog.Warning(err) + } + + isPhone, err := wd.Bool(isPhoneCapKey) + if err != nil { + glog.Warning(err) + } + + isTablet, err := wd.Bool(isTabletCapKey) + if err != nil { + glog.Warning(err) + } + + if isMobile { + if isPhone || isTablet { + return adcom1.DeviceMobile + } + return adcom1.DeviceConnected + } + + isFullDesktop, err := wd.Bool(isFullDesktopCapKey) + if err != nil { + glog.Warning(err) + } + if isFullDesktop { + return adcom1.DevicePC + } + + isConnectedTV, err := wd.Bool(isConnectedTVCapKey) + if err != nil { + glog.Warning(err) + } + if isConnectedTV { + return adcom1.DeviceTV + } + + if isPhone { + return adcom1.DevicePhone + } + + if isTablet { + return adcom1.DeviceTablet + } + + isOTT, err := wd.Bool(isOTTCapKey) + if err != nil { + glog.Warning(err) + } + if isOTT { + return adcom1.DeviceSetTopBox + } + + isOOH, err := wd.String(physicalFormFactorCapKey) + if err != nil { + glog.Warning(err) + } + if isOOH == outOfHomeDevice { + return adcom1.DeviceOOH + } + return unknownDeviceType +} diff --git a/modules/scientiamobile/wurfl_devicedetection/wurfl_enricher_test.go b/modules/scientiamobile/wurfl_devicedetection/wurfl_enricher_test.go new file mode 100644 index 00000000000..144568d5a3a --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/wurfl_enricher_test.go @@ -0,0 +1,212 @@ +package wurfl_devicedetection + +import ( + "encoding/json" + "testing" + + "github.com/prebid/openrtb/v20/adcom1" + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/stretchr/testify/assert" +) + +func TestWurflEnricher_EnrichDevice(t *testing.T) { + data := wurflData{ + "brand_name": "BrandX", + "model_name": "ModelY", + "advertised_device_os": "Android", + "resolution_height": "1080", + "resolution_width": "1920", + "pixel_density": "300", + "density_class": "2.5", + "ajax_support_javascript": "true", + "is_mobile": "true", + "is_phone": "true", + "is_tablet": "false", + } + + device := &openrtb2.Device{} + + we := &wurflEnricher{ + WurflData: data, + } + we.EnrichDevice(device) + + assert.Equal(t, "BrandX", device.Make) + assert.Equal(t, "ModelY", device.Model) + assert.Equal(t, "Android", device.OS) + assert.Equal(t, int64(1080), device.H) + assert.Equal(t, int64(1920), device.W) + assert.Equal(t, int64(300), device.PPI) + assert.Equal(t, 2.5, device.PxRatio) + assert.NotNil(t, device.JS) + assert.Equal(t, int8(1), *device.JS) + assert.Nil(t, device.Ext) +} + +func TestWurflEnricher_EnrichDeviceExt(t *testing.T) { + tests := []struct { + name string + wurflData wurflData + initialExt json.RawMessage + expectedExt string + expectNoError bool + }{ + { + name: "Add wurfl data to empty device ext", + wurflData: wurflData{ + "brand_name": "BrandX", + "model_name": "ModelY", + }, + initialExt: nil, + expectedExt: `{"wurfl":{"brand_name":"BrandX","model_name":"ModelY"}}`, + expectNoError: true, + }, + { + name: "Merge wurfl data into existing device ext", + wurflData: wurflData{ + "brand_name": "BrandZ", + }, + initialExt: json.RawMessage(`{"existing_key":"existing_value"}`), + expectedExt: `{"existing_key":"existing_value","wurfl":{"brand_name":"BrandZ"}}`, + expectNoError: true, + }, + { + name: "Invalid initial ext JSON", + wurflData: wurflData{ + "brand_name": "BrandX", + }, + initialExt: json.RawMessage(`{"invalid_json":`), // Malformed JSON + expectedExt: `{"invalid_json":`, // Should remain as is + expectNoError: false, + }, + { + name: "Empty wurfl data", + wurflData: wurflData{}, + initialExt: nil, + expectedExt: `{"wurfl":{}}`, + expectNoError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + device := &openrtb2.Device{Ext: tc.initialExt} + + we := wurflEnricher{ + WurflData: tc.wurflData, + ExtCaps: true, + } + // Call the method being tested + we.EnrichDevice(device) + + // Assert the results + if tc.expectNoError { + assert.JSONEq(t, tc.expectedExt, string(device.Ext)) + } else { + assert.NotEqual(t, tc.expectedExt, string(device.Ext)) + } + }) + } +} + +func TestWurflEnricher_MakeDeviceType(t *testing.T) { + tests := []struct { + name string + data wurflData + expected adcom1.DeviceType + }{ + { + name: "Mobile device - isMobile true, isPhone true", + data: wurflData{ + "is_mobile": "true", + "is_phone": "true", + }, + expected: adcom1.DeviceMobile, + }, + { + name: "Mobile device - isMobile true, isTablet true", + data: wurflData{ + "is_mobile": "true", + "is_tablet": "true", + }, + expected: adcom1.DeviceMobile, + }, + { + name: "Connected TV", + data: wurflData{ + "is_connected_tv": "true", + }, + expected: adcom1.DeviceTV, + }, + { + name: "Full desktop", + data: wurflData{ + "is_full_desktop": "true", + }, + expected: adcom1.DevicePC, + }, + { + name: "Phone device", + data: wurflData{ + "is_phone": "true", + }, + expected: adcom1.DevicePhone, + }, + { + name: "Tablet device", + data: wurflData{ + "is_tablet": "true", + }, + expected: adcom1.DeviceTablet, + }, + { + name: "Set-top box (OTT)", + data: wurflData{ + "is_ott": "true", + }, + expected: adcom1.DeviceSetTopBox, + }, + { + name: "Out-of-home device", + data: wurflData{ + "physical_form_factor": "out_of_home_device", + }, + expected: adcom1.DeviceOOH, + }, + { + name: "Unknown device type - no relevant flags", + data: wurflData{}, + expected: adcom1.DeviceType(0), + }, + { + name: "Error parsing isMobile", + data: wurflData{ + "is_mobile": "not_a_bool", + }, + expected: adcom1.DeviceType(0), + }, + { + name: "Error parsing isConnectedTV", + data: wurflData{ + "is_connected_tv": "not_a_bool", + }, + expected: adcom1.DeviceType(0), + }, + { + name: "Error parsing physicalFormFactor", + data: wurflData{ + "physical_form_factor": "", + }, + expected: adcom1.DeviceType(0), + }, + } + + for _, tc := range tests { + we := &wurflEnricher{ + WurflData: tc.data, + } + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, we.makeDeviceType()) + }) + } +} diff --git a/modules/scientiamobile/wurfl_devicedetection/wurfl_headers.go b/modules/scientiamobile/wurfl_devicedetection/wurfl_headers.go new file mode 100644 index 00000000000..a383df2f187 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/wurfl_headers.go @@ -0,0 +1,85 @@ +package wurfl_devicedetection + +import ( + "fmt" + "strings" + + "github.com/prebid/openrtb/v20/openrtb2" +) + +const ( + SEC_CH_UA = "Sec-CH-UA" + SEC_CH_UA_PLATFORM = "Sec-CH-UA-Platform" + SEC_CH_UA_PLATFORM_VERSION = "Sec-CH-UA-Platform-Version" + SEC_CH_UA_MOBILE = "Sec-CH-UA-Mobile" + SEC_CH_UA_ARCH = "Sec-CH-UA-Arch" + SEC_CH_UA_MODEL = "Sec-CH-UA-Model" + SEC_CH_UA_FULL_VERSION = "Sec-CH-UA-Full-Version" + SEC_CH_UA_FULL_VERSION_LIST = "Sec-CH-UA-Full-Version-List" + USER_AGENT = "User-Agent" +) + +func makeHeaders(ortb2Device openrtb2.Device, rawHeaders map[string]string) map[string]string { + sua := ortb2Device.SUA + ua := ortb2Device.UA + if ua == "" { + if sua == nil { + return rawHeaders + } + if sua.Browsers == nil { + return rawHeaders + } + } + headers := make(map[string]string) + + if ua != "" { + headers[USER_AGENT] = ua + } + + if sua == nil { + return headers + } + + if sua.Browsers == nil { + return headers + } + + brandList := makeBrandList(sua.Browsers) + headers[SEC_CH_UA] = brandList + headers[SEC_CH_UA_FULL_VERSION_LIST] = brandList + + if sua.Platform != nil { + headers[SEC_CH_UA_PLATFORM] = escapeClientHintField(sua.Platform.Brand) + headers[SEC_CH_UA_PLATFORM_VERSION] = escapeClientHintField(strings.Join(sua.Platform.Version, ".")) + } + + if sua.Model != "" { + headers[SEC_CH_UA_MODEL] = escapeClientHintField(sua.Model) + } + + if sua.Architecture != "" { + headers[SEC_CH_UA_ARCH] = escapeClientHintField(sua.Architecture) + } + + if sua.Mobile != nil { + headers[SEC_CH_UA_MOBILE] = fmt.Sprintf("?%d", *sua.Mobile) + } + + return headers +} + +func makeBrandList(brandVersions []openrtb2.BrandVersion) string { + var result []string + for _, version := range brandVersions { + if version.Brand == "" { + continue + } + brandName := escapeClientHintField(version.Brand) + result = append(result, fmt.Sprintf("%s;v=\"%s\"", brandName, strings.Join(version.Version, "."))) + } + return strings.Join(result, ", ") +} + +func escapeClientHintField(value string) string { + return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"` +} diff --git a/modules/scientiamobile/wurfl_devicedetection/wurfl_headers_test.go b/modules/scientiamobile/wurfl_devicedetection/wurfl_headers_test.go new file mode 100644 index 00000000000..4d776cff054 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/wurfl_headers_test.go @@ -0,0 +1,106 @@ +package wurfl_devicedetection + +import ( + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/stretchr/testify/assert" +) + +func TestMakeHeaders(t *testing.T) { + tests := []struct { + name string + device openrtb2.Device + rawHeaders map[string]string + expected map[string]string + }{ + { + name: "No SUA and no UA", + device: openrtb2.Device{}, + rawHeaders: map[string]string{"Custom-Header": "Value"}, + expected: map[string]string{"Custom-Header": "Value"}, + }, + { + name: "Only UA", + device: openrtb2.Device{ + UA: "Mozilla/5.0", + }, + rawHeaders: map[string]string{}, + expected: map[string]string{"User-Agent": "Mozilla/5.0"}, + }, + { + name: "UA and SUA without Browsers", + device: openrtb2.Device{ + UA: "Mozilla/5.0", + SUA: &openrtb2.UserAgent{ + Platform: &openrtb2.BrandVersion{ + Brand: "Android", + Version: []string{"12"}, + }, + }, + }, + rawHeaders: map[string]string{}, + expected: map[string]string{"User-Agent": "Mozilla/5.0"}, + }, + { + name: "No UA and SUA without Browsers", + device: openrtb2.Device{ + SUA: &openrtb2.UserAgent{ + Platform: &openrtb2.BrandVersion{ + Brand: "Android", + Version: []string{"12"}, + }, + }, + }, + rawHeaders: map[string]string{"User-Agent": "Mozilla/5.0"}, + expected: map[string]string{"User-Agent": "Mozilla/5.0"}, + }, + { + name: "SUA with browsers and platform", + device: openrtb2.Device{ + SUA: &openrtb2.UserAgent{ + Browsers: []openrtb2.BrandVersion{ + {Brand: "Google Chrome", Version: []string{"114", "0", "5735"}}, + }, + Platform: &openrtb2.BrandVersion{ + Brand: "Android", + Version: []string{"12"}, + }, + }, + }, + rawHeaders: map[string]string{}, + expected: map[string]string{ + "Sec-CH-UA": `"Google Chrome";v="114.0.5735"`, + "Sec-CH-UA-Full-Version-List": `"Google Chrome";v="114.0.5735"`, + "Sec-CH-UA-Platform": `"Android"`, + "Sec-CH-UA-Platform-Version": `"12"`, + }, + }, + { + name: "SUA with mobile and model", + device: openrtb2.Device{ + SUA: &openrtb2.UserAgent{ + Browsers: []openrtb2.BrandVersion{ + {Brand: "Google Chrome", Version: []string{"114", "0", "5735"}}, + }, + Mobile: func(i int8) *int8 { return &i }(1), + Model: "Pixel 6", + }, + }, + rawHeaders: map[string]string{}, + expected: map[string]string{ + "Sec-CH-UA": `"Google Chrome";v="114.0.5735"`, + "Sec-CH-UA-Full-Version-List": `"Google Chrome";v="114.0.5735"`, + "Sec-CH-UA-Mobile": `?1`, + "Sec-CH-UA-Model": `"Pixel 6"`, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := makeHeaders(test.device, test.rawHeaders) + assert.Equal(t, test.expected, result) + }) + } +}