diff --git a/go.mod b/go.mod index c1a9bee8..28070272 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/IABTechLab/adscert v0.34.0 github.com/IBM/sarama v1.46.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/aws/aws-sdk-go-v2 v1.39.2 @@ -39,7 +40,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.11.0 + 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 b1062eb9..6a7bd9c2 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,8 @@ github.com/IBM/sarama v1.46.0/go.mod h1:0lOcuQziJ1/mBGHkdp5uYrltqQuKQKM5O5FOWUQV 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= @@ -545,10 +547,17 @@ github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiu github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +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/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 ada151b1..5971f1c9 100644 --- a/modules/builder.go +++ b/modules/builder.go @@ -1,11 +1,11 @@ package modules import ( - fiftyonedegreesDevicedetection "github.com/prebid/prebid-server/v3/modules/fiftyonedegrees/devicedetection" - openadsSignatures "github.com/prebid/prebid-server/v3/modules/openads/signatures" - prebidOrtb2blocking "github.com/prebid/prebid-server/v3/modules/prebid/ortb2blocking" - prebidRulesengine "github.com/prebid/prebid-server/v3/modules/prebid/rulesengine" - scope3Rtd "github.com/prebid/prebid-server/v3/modules/scope3/rtd" + fiftyonedegreesDevicedetection "github.com/prebid/prebid-server/v4/modules/fiftyonedegrees/devicedetection" + prebidOrtb2blocking "github.com/prebid/prebid-server/v4/modules/prebid/ortb2blocking" + prebidRulesengine "github.com/prebid/prebid-server/v4/modules/prebid/rulesengine" + wurflDevicedetection "github.com/prebid/prebid-server/v4/modules/scientiamobile/wurfl_devicedetection" + scope3Rtd "github.com/prebid/prebid-server/v4/modules/scope3/rtd" ) // builders returns mapping between module name and its builder @@ -22,6 +22,9 @@ func builders() ModuleBuilders { "ortb2blocking": prebidOrtb2blocking.Builder, "rulesengine": prebidRulesengine.Builder, }, + "scientiamobile": { + "wurfl_devicedetection": wurflDevicedetection.Builder, + }, "scope3": { "rtd": scope3Rtd.Builder, }, diff --git a/modules/scientiamobile/wurfl_devicedetection/README.md b/modules/scientiamobile/wurfl_devicedetection/README.md new file mode 100644 index 00000000..023b5828 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/README.md @@ -0,0 +1,357 @@ +## 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). +- **pxratio**: Screen pixel density ratio. +- **devicetype**: Device type (e.g., mobile, tablet, desktop). +- **js**: Support for JavaScript, where 0 = no, 1 = yes + +> **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://docs.scientiamobile.com/documentation/infuze/infuze-c-api-user-guide). + +#### 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 fail to initialize at startup. + +### 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_file_path": "/path/to/wurfl.zip", + "wurfl_snapshot_url": "", + "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_file_path: "/path/to/wurfl.zip" + wurfl_snapshot_url: "" + 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_path`** | Mandatory | Path to the WURFL file (e.g. /path/to/wurfl.zip). | +| **`wurfl_snapshot_url`** | Optional | URL of the licensed WURFL snapshot. If set, it periodically updates the WURFL file in the `wurfl_file_path` directory, which must be writable. | +| **`wurfl_cache_size`** | Optional | Maximum number of devices stored in the WURFL cache. Defaults to the WURFL cache's standard size. | +| **`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 +``` + +2. 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 +``` + +3. Copy the WURFL file to `wurfl_file_path` + +In order for the WURFL module to work, you need a WURFL file installed on your system. The WURFL Infuze package comes with a recent evaluation copy of the WURFL file called wurfl.zip. + +Commercial licensees of WURFL are granted access to "The Customer Vault", a personal virtual space containing purchased software licenses and weekly updated versions of the WURFL repository. + +For more details, visit: [ScientiaMobile WURFL Infuze](https://docs.scientiamobile.com/documentation/infuze/infuze-c-api-user-guide). + +4. Start the server + + ```bash + go run -tags wurfl . +``` + +When the server starts, it loads the WURFL file from `wurfl_file_path` into the module. +To enable automatic updates, ensure that `wurfl_snapshot_url` is correctly configured. + +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. + +### 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" + } + } +} +``` + +### Building Docker images with WURFL support + +When building Prebid Server with the WURFL module using Docker, you need to install both compile-time and runtime WURFL dependencies. + +To build a Docker image with WURFL support: + +1. **Place the WURFL library package**: Copy the `libwurfl*.deb` package files into the `modules/scientiamobile/wurfl_devicedetection/libwurfl/` directory before building the Docker image. + +2. **Install compile-time dependencies**: In the build stage of your Dockerfile, before the `go build` command, add: + + ```dockerfile + # 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 + ``` + + And build with the WURFL tag: + + ```dockerfile + RUN go build -tags wurfl -mod=vendor -ldflags "-X github.com/prebid/prebid-server/v4/version.Ver=`git describe --tags | sed 's/^v//'` -X github.com/prebid/prebid-server/v4/version.Rev=`git rev-parse HEAD`" . + ``` + +3. **Install runtime dependencies**: In the runtime stage, add the WURFL runtime dependencies: + + ```dockerfile + # 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 + ``` + +## Maintainer + + diff --git a/modules/scientiamobile/wurfl_devicedetection/config.go b/modules/scientiamobile/wurfl_devicedetection/config.go new file mode 100644 index 00000000..8702e643 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/config.go @@ -0,0 +1,58 @@ +package wurfl_devicedetection + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + + "github.com/prebid/prebid-server/v4/util/jsonutil" +) + +const ( + defaultCacheSize = "200000" +) + +var ErrWURFLFilePathRequired = errors.New("wurfl_file_path is required") + +// 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 { + // WURFLFilePath is the path to the WURFL file (i.e. /path/to/wurfl.zip). Required. + WURFLFilePath string `json:"wurfl_file_path"` + // WURFLSnapshotURL is the URL of the WURFL Snapshot. + // If set, it will be used to periodically update the WURFL file. + // The snapshot will be downloaded to the same directory as the WURFLFilePath. + // Make sure this directory is writable. + WURFLSnapshotURL string `json:"wurfl_snapshot_url"` + // WURFLCacheSize is the size of the WURFL Engine cache. Default is 200000 + WURFLCacheSize int `json:"wurfl_cache_size"` + // Holds the list of allowed publisher IDs. Leave empty to allow all. + AllowedPublisherIDs []string `json:"allowed_publisher_ids"` + // ExtCaps if true will include licensed WURFL capabilities in ortb2.Device.Ext + ExtCaps bool `json:"ext_caps"` +} + +// 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 +} + +func (cfg config) validate() error { + if cfg.WURFLFilePath == "" { + return ErrWURFLFilePathRequired + } + 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 00000000..fba8845e --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/config_test.go @@ -0,0 +1,121 @@ +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_path": "/tmp/wurfl.zip" + }`), + expectedErr: false, + validate: func(t *testing.T, cfg config) { + assert.Equal(t, "http://example.com/wurfl-data", cfg.WURFLSnapshotURL) + assert.Equal(t, "/tmp/wurfl.zip", cfg.WURFLFilePath) + 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_path": "/tmp/wurfl.zip", + "wurfl_cache_size": 5000, + "wurfl_run_updater": true + }`), + expectedErr: false, + validate: func(t *testing.T, cfg config) { + assert.Equal(t, "5000", cfg.WURFLEngineCacheSize()) + }, + }, + { + name: "Invalid config - missing wurfl_snapshot_url", + input: json.RawMessage(`{ + "wurfl_file_path": "/tmp/wurfl" + }`), + expectedErr: false, + }, + { + name: "Invalid config - missing wurfl_file_path", + input: json.RawMessage(`{ + "wurfl_snapshot_url": "http://example.com/wurfl-data" + }`), + expectedErr: true, + }, + { + name: "Invalid config - malformed JSON", + input: json.RawMessage(`{ "wurfl_snapshot_url": "http://example.com/wurfl-data", "wurfl_file_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 TestValidate(t *testing.T) { + tests := []struct { + name string + cfg config + expectedErr bool + }{ + { + name: "Valid config", + cfg: config{ + WURFLSnapshotURL: "http://example.com/wurfl-data", + WURFLFilePath: "/tmp/wurfl.zip", + }, + expectedErr: false, + }, + { + name: "Invalid config - missing wurfl_file_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 00000000..a5baada1 --- /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 00000000..53bb26e7 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/module.go @@ -0,0 +1,181 @@ +package wurfl_devicedetection + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/buger/jsonparser" + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v4/hooks/hookexecution" + "github.com/prebid/prebid-server/v4/hooks/hookstage" + "github.com/prebid/prebid-server/v4/modules/moduledeps" + "github.com/prebid/prebid-server/v4/util/jsonutil" + "github.com/tidwall/sjson" +) + +const ( + wurflHeaderCtxKey = "wurfl_header" +) + +// declare conformity with hookstage.RawAuctionRequest 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]struct{}, len(cfg.AllowedPublisherIDs)) + for _, v := range cfg.AllowedPublisherIDs { + m.allowedPublisherIDs[v] = struct{}{} + } + } + return m, nil +} + +// Module must implement at least 1 hook interface. +type Module struct { + we wurflDeviceDetection + allowedPublisherIDs map[string]struct{} + 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 +} + +// HandleRawAuctionHook 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 been initialized 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 initialized in the entrypoint hook") + } + + if isWURFLEnrichedRequest(payload) { + return result, nil + } + + rawHeaders, ok := invocationCtx.ModuleContext[wurflHeaderCtxKey].(map[string]string) + if !ok { + return result, hookexecution.NewFailure("invalid module context type") + } + 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 +} + +// getOrtb2Device 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 = jsonutil.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 00000000..64aaefe7 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/module_test.go @@ -0,0 +1,537 @@ +package wurfl_devicedetection + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + + "github.com/prebid/prebid-server/v4/hooks/hookstage" + "github.com/stretchr/testify/assert" +) + +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]struct{}{"pub1": {}}, + }, + 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]struct{}{"pub1": {}}, + }, + 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]struct{}{"pub1": {}}, + }, + 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]struct{}{"pub1": {}}, + }, + 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]struct{}{"pub1": {}}, + }, + 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]struct{}{"pub1": {}}, + }, + 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", + "form_factor": "Other Mobile", + }, 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", + "form_factor": "Other Mobile", + }, 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", + "form_factor": "Other Mobile", + }, 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", + "form_factor": "Other Mobile" + } + } + } + }`, + }, + { + 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", + "form_factor": "Other Mobile", + }, 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", + "form_factor": "Other Mobile" + } + } + } + }`, + }, + { + 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]struct{}{"pub1": {}}, + }, + payload: []byte(`{"site":{"publisher":{"id":"pub1"}}}`), + expected: true, + }, + { + name: "Disallowed publisher - site.publisher.id", + module: Module{ + allowedPublisherIDs: map[string]struct{}{"pub1": {}}, + }, + payload: []byte(`{"site":{"publisher":{"id":"pub2"}}}`), + expected: false, + }, + { + name: "Allowed publisher - app.publisher.id", + module: Module{ + allowedPublisherIDs: map[string]struct{}{"pub3": {}}, + }, + payload: []byte(`{"app":{"publisher":{"id":"pub3"}}}`), + expected: true, + }, + { + name: "Disallowed publisher - app.publisher.id", + module: Module{ + allowedPublisherIDs: map[string]struct{}{"pub3": {}}, + }, + payload: []byte(`{"app":{"publisher":{"id":"pub4"}}}`), + expected: false, + }, + { + name: "Allowed publisher - dooh.publisher.id", + module: Module{ + allowedPublisherIDs: map[string]struct{}{"pub5": {}}, + }, + payload: []byte(`{"dooh":{"publisher":{"id":"pub5"}}}`), + expected: true, + }, + { + name: "Disallowed publisher - dooh.publisher.id", + module: Module{ + allowedPublisherIDs: map[string]struct{}{"pub5": {}}, + }, + payload: []byte(`{"dooh":{"publisher":{"id":"pub6"}}}`), + expected: false, + }, + { + name: "Empty payload - no publisher ID", + module: Module{ + allowedPublisherIDs: map[string]struct{}{"pub1": {}}, + }, + 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]struct{}{"pub1": {}}, + }, + 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/module_wurfl_test.go b/modules/scientiamobile/wurfl_devicedetection/module_wurfl_test.go new file mode 100644 index 00000000..e89784d5 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/module_wurfl_test.go @@ -0,0 +1,59 @@ +//go:build wurfl + +package wurfl_devicedetection + +import ( + "encoding/json" + "testing" + + "github.com/prebid/prebid-server/v4/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_path": "/tmp/wurfl.zip", + "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]struct{}{"pub1": {}, "pub2": {}}, 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) + } + }) + } +} 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 00000000..2a0e7b60 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/sample/pbs_example.json @@ -0,0 +1,68 @@ +{ + "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_file_path": "/path/to/wurfl.zip", + "wurfl_snapshot_url": "", + "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 00000000..42691bbc --- /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 00000000..7cc12844 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/wurfl_data.go @@ -0,0 +1,86 @@ +package wurfl_devicedetection + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + + "github.com/prebid/prebid-server/v4/util/jsonutil" +) + +const ( + wurflID = "wurfl_id" +) + +var ErrWURFLIDNotExist = errors.New("WURFL ID does not exist") + +// 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("could 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("could 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("could 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, ErrWURFLIDNotExist + } + m[wurflID] = v + return jsonutil.Marshal(m) +} + +// MarshalJSON customizes the JSON marshaling for wurflData +func (wd wurflData) MarshalJSON() ([]byte, error) { + return jsonutil.Marshal(map[string]string(wd)) +} 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 00000000..257d5933 --- /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 00000000..ebcad9c6 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/wurfl_engine.go @@ -0,0 +1,145 @@ +//go:build wurfl + +package wurfl_devicedetection + +import ( + "fmt" + "strings" + + wurfl "github.com/WURFL/golang-wurfl" + "github.com/golang/glog" +) + +// declare conformity with wurflDeviceDetection interface +var _ wurflDeviceDetection = (*wurflEngine)(nil) + +// vcaps is the list of WURFL virtual capabilities to request +var vcaps = []string{ + advertisedDeviceOSCapKey, + advertisedDeviceOSVersionCapKey, + completeDeviceNameCapKey, + isFullDesktopCapKey, + isMobileCapKey, + isPhoneCapKey, + formFactorCapKey, + pixelDensityCapKey, +} + +// newWurflEngine creates a new Enricher +func newWurflEngine(c config) (wurflDeviceDetection, error) { + wengine, err := wurfl.Create(c.WURFLFilePath, + nil, + nil, + -1, + wurfl.WurflCacheProviderLru, + c.WURFLEngineCacheSize(), + ) + if err != nil { + return nil, err + } + + caps := wengine.GetAllCaps() + e := &wurflEngine{ + wengine: wengine, + caps: caps, + vcaps: vcaps, + } + + err = e.validate() + if err != nil { + return nil, err + } + + e.startUpdater(c.WURFLSnapshotURL) + + 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 +} + +func (e *wurflEngine) startUpdater(snapshotURL string) { + if snapshotURL == "" { + return + } + + err := e.wengine.SetUpdaterDataURL(snapshotURL) + if err != nil { + glog.Errorf("could not set WURFL Updater Snapshot URL: %s", err.Error()) + return + } + + err = e.wengine.SetUpdaterDataFrequency(wurfl.WurflUpdaterFrequencyDaily) + if err != nil { + glog.Errorf("could not set the WURFL Updater frequency: %s", err.Error()) + return + } + + err = e.wengine.UpdaterStart() + if err != nil { + glog.Errorf("could not start the WURFL Updater: %s", err.Error()) + return + } +} + +// validate checks if the WURFL file has all the required capabilities +func (e *wurflEngine) validate() error { + requiredCaps := []string{ + ajaxSupportJavascriptCapKey, + brandNameCapKey, + densityClassCapKey, + isConnectedTVCapKey, + isConsoleCapKey, + isOTTCapKey, + isTabletCapKey, + modelNameCapKey, + physicalFormFactorCapKey, + resolutionHeightCapKey, + resolutionWidthCapKey, + } + m := map[string]struct{}{} + for _, val := range e.caps { + m[val] = struct{}{} + } + 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 00000000..8c83b9f8 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/wurfl_engine_fallback.go @@ -0,0 +1,11 @@ +//go:build !wurfl + +package wurfl_devicedetection + +import "errors" + +const wurflBuildTagMissingError = "wurfl module requires the wurfl build tag; build with: go build -tags wurfl" + +func newWurflEngine(_ config) (wurflDeviceDetection, error) { + return nil, errors.New(wurflBuildTagMissingError) +} diff --git a/modules/scientiamobile/wurfl_devicedetection/wurfl_engine_fallback_test.go b/modules/scientiamobile/wurfl_devicedetection/wurfl_engine_fallback_test.go new file mode 100644 index 00000000..de0334fe --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/wurfl_engine_fallback_test.go @@ -0,0 +1,14 @@ +//go:build !wurfl + +package wurfl_devicedetection + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewWurflEngineFallbackError(t *testing.T) { + _, err := newWurflEngine(config{}) + assert.EqualError(t, err, wurflBuildTagMissingError) +} diff --git a/modules/scientiamobile/wurfl_devicedetection/wurfl_enricher.go b/modules/scientiamobile/wurfl_devicedetection/wurfl_enricher.go new file mode 100644 index 00000000..a7753bac --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/wurfl_enricher.go @@ -0,0 +1,194 @@ +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" + isConsoleCapKey = "is_console" + 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" +) + +// WURFL form_factor values +const ( + formFactorDesktop = "Desktop" + formFactorSmartphone = "Smartphone" + formFactorFeaturePhone = "Feature Phone" + formFactorTablet = "Tablet" + formFactorSmartTV = "Smart-TV" + formFactorOtherNonMobile = "Other Non-Mobile" + formFactorOtherMobile = "Other Mobile" +) + +// 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 enrich 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(advertisedDeviceOSVersionCapKey); 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 { + glog.Warningf("could not create WURFL ext data: %s", err) + return + } + // merges the WURFL data in device.ext under the wurfl "namespace" + ext, err := sjson.SetRawBytes(device.Ext, ortb2WurflExtKey, wurflExtData) + if err != nil { + glog.Warningf("could not set WURFL ext data: %s", err) + 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) + + // Priority 1: Check is_ott + if isOTT, err := wd.Bool(isOTTCapKey); err == nil && isOTT { + return adcom1.DeviceSetTopBox + } + + // Priority 2: Check is_console + if isConsole, err := wd.Bool(isConsoleCapKey); err == nil && isConsole { + return adcom1.DeviceConnected + } + + // Priority 3: Check physical_form_factor for out_of_home_device + if physicalFormFactor, err := wd.String(physicalFormFactorCapKey); err == nil && physicalFormFactor == outOfHomeDevice { + return adcom1.DeviceOOH + } + + // Priority 4: Check if form_factor exists and switch on its value + formFactor, err := wd.String(formFactorCapKey) + if err != nil { + // form_factor not available, return unknown + return unknownDeviceType + } + + switch formFactor { + case formFactorDesktop: + return adcom1.DevicePC + case formFactorSmartphone, formFactorFeaturePhone: + return adcom1.DevicePhone + case formFactorTablet: + return adcom1.DeviceTablet + case formFactorSmartTV: + return adcom1.DeviceTV + case formFactorOtherNonMobile: + return adcom1.DeviceConnected + case formFactorOtherMobile: + return adcom1.DeviceMobile + default: + 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 00000000..5c340172 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/wurfl_enricher_test.go @@ -0,0 +1,213 @@ +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 - form_factor Other Mobile", + data: wurflData{ + "form_factor": "Other Mobile", + }, + expected: adcom1.DeviceMobile, + }, + { + name: "Smartphone device - form_factor Smartphone", + data: wurflData{ + "form_factor": "Smartphone", + }, + expected: adcom1.DevicePhone, + }, + { + name: "Feature Phone device - form_factor Feature Phone", + data: wurflData{ + "form_factor": "Feature Phone", + }, + expected: adcom1.DevicePhone, + }, + { + name: "Connected TV - form_factor Smart-TV", + data: wurflData{ + "form_factor": "Smart-TV", + }, + expected: adcom1.DeviceTV, + }, + { + name: "Full desktop - form_factor Desktop", + data: wurflData{ + "form_factor": "Desktop", + }, + expected: adcom1.DevicePC, + }, + { + name: "Tablet device - form_factor Tablet", + data: wurflData{ + "form_factor": "Tablet", + }, + expected: adcom1.DeviceTablet, + }, + { + name: "Connected device - form_factor Other Non-Mobile", + data: wurflData{ + "form_factor": "Other Non-Mobile", + }, + expected: adcom1.DeviceConnected, + }, + { + name: "Set-top box (OTT) - is_ott has priority", + data: wurflData{ + "is_ott": "true", + "form_factor": "Desktop", + }, + expected: adcom1.DeviceSetTopBox, + }, + { + name: "Console device - is_console has priority", + data: wurflData{ + "is_console": "true", + "form_factor": "Desktop", + }, + expected: adcom1.DeviceConnected, + }, + { + name: "Out-of-home device - physical_form_factor has priority", + data: wurflData{ + "physical_form_factor": "out_of_home_device", + "form_factor": "Desktop", + }, + expected: adcom1.DeviceOOH, + }, + { + name: "Unknown device type - no form_factor", + data: wurflData{}, + expected: adcom1.DeviceType(0), + }, + { + name: "Unknown device type - invalid form_factor", + data: wurflData{ + "form_factor": "Unknown", + }, + 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 00000000..163102b4 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/wurfl_headers.go @@ -0,0 +1,104 @@ +package wurfl_devicedetection + +import ( + "fmt" + "strings" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v4/util/iterutil" +) + +const ( + secCHUA = "Sec-CH-UA" + secCHUAPlatform = "Sec-CH-UA-Platform" + secCHUAPlatformVersion = "Sec-CH-UA-Platform-Version" + secCHUAMobile = "Sec-CH-UA-Mobile" + secCHUAArch = "Sec-CH-UA-Arch" + secCHUAModel = "Sec-CH-UA-Model" + secCHUAFullVersionList = "Sec-CH-UA-Full-Version-List" + userAgent = "User-Agent" +) + +// clientHintEscaper escapes special characters in Client Hint header values per RFC 9651. +// Only backslash and double-quote need escaping. Backslash must be escaped first. +var clientHintEscaper = strings.NewReplacer( + `\`, `\\`, // Must escape backslash FIRST + `"`, `\"`, // Then escape quotes +) + +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[userAgent] = ua + } + + if sua == nil { + return headers + } + + if sua.Browsers == nil { + return headers + } + + brandList := makeBrandList(sua.Browsers) + headers[secCHUA] = brandList + headers[secCHUAFullVersionList] = brandList + + if sua.Platform != nil { + headers[secCHUAPlatform] = quoteAndEscapeClientHintField(sua.Platform.Brand) + headers[secCHUAPlatformVersion] = quoteAndEscapeClientHintField(strings.Join(sua.Platform.Version, ".")) + } + + if sua.Model != "" { + headers[secCHUAModel] = quoteAndEscapeClientHintField(sua.Model) + } + + if sua.Architecture != "" { + headers[secCHUAArch] = quoteAndEscapeClientHintField(sua.Architecture) + } + + if sua.Mobile != nil { + headers[secCHUAMobile] = fmt.Sprintf("?%d", *sua.Mobile) + } + + return headers +} + +func makeBrandList(brandVersions []openrtb2.BrandVersion) string { + var builder strings.Builder + first := true + for version := range iterutil.SlicePointerValues(brandVersions) { + if version.Brand == "" { + continue + } + if !first { + builder.WriteString(", ") + } + first = false + + brandName := quoteAndEscapeClientHintField(version.Brand) + builder.WriteString(brandName) + builder.WriteString(`;v="`) + builder.WriteString(clientHintEscaper.Replace(strings.Join(version.Version, "."))) + builder.WriteString(`"`) + } + return builder.String() +} + +// quoteAndEscapeClientHintField escapes special characters per RFC 9651 and wraps +// the value in double quotes for use in HTTP Client Hint header values. +// Backslashes and double-quotes are escaped as required by the structured field specification. +func quoteAndEscapeClientHintField(value string) string { + return `"` + clientHintEscaper.Replace(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 00000000..36456954 --- /dev/null +++ b/modules/scientiamobile/wurfl_devicedetection/wurfl_headers_test.go @@ -0,0 +1,171 @@ +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"`, + }, + }, + { + name: "SUA with multiple browsers brand", + device: openrtb2.Device{ + SUA: &openrtb2.UserAgent{ + Browsers: []openrtb2.BrandVersion{ + {Brand: "Chromium", Version: []string{"114"}}, + {Brand: "Google Chrome", Version: []string{"114", "0", "5735"}}, + {Brand: " Not A;Brand", Version: []string{"99"}}, + }, + Platform: &openrtb2.BrandVersion{ + Brand: "Windows", + Version: []string{"10", "0", "0"}, + }, + }, + }, + rawHeaders: map[string]string{}, + expected: map[string]string{ + "Sec-CH-UA": `"Chromium";v="114", "Google Chrome";v="114.0.5735", " Not A;Brand";v="99"`, + "Sec-CH-UA-Full-Version-List": `"Chromium";v="114", "Google Chrome";v="114.0.5735", " Not A;Brand";v="99"`, + "Sec-CH-UA-Platform": `"Windows"`, + "Sec-CH-UA-Platform-Version": `"10.0.0"`, + }, + }, + { + name: "SUA with special characters in version strings (RFC 9651)", + device: openrtb2.Device{ + SUA: &openrtb2.UserAgent{ + Browsers: []openrtb2.BrandVersion{ + {Brand: "Chrome", Version: []string{`1", "Injected";v="99`}}, + {Brand: "Brand", Version: []string{`1\2`}}, + }, + }, + }, + rawHeaders: map[string]string{}, + expected: map[string]string{ + "Sec-CH-UA": `"Chrome";v="1\", \"Injected\";v=\"99", "Brand";v="1\\2"`, + "Sec-CH-UA-Full-Version-List": `"Chrome";v="1\", \"Injected\";v=\"99", "Brand";v="1\\2"`, + }, + }, + { + name: "SUA with special characters requiring escaping (RFC 9651)", + device: openrtb2.Device{ + SUA: &openrtb2.UserAgent{ + Browsers: []openrtb2.BrandVersion{ + {Brand: `Test"Brand`, Version: []string{"1", "0"}}, + {Brand: `Test\Brand`, Version: []string{"2", "0"}}, + }, + Platform: &openrtb2.BrandVersion{ + Brand: `OS"Name`, + Version: []string{"1"}, + }, + Model: `Device"Model\Name`, + Architecture: `arch\test`, + }, + }, + rawHeaders: map[string]string{}, + expected: map[string]string{ + "Sec-CH-UA": `"Test\"Brand";v="1.0", "Test\\Brand";v="2.0"`, + "Sec-CH-UA-Full-Version-List": `"Test\"Brand";v="1.0", "Test\\Brand";v="2.0"`, + "Sec-CH-UA-Platform": `"OS\"Name"`, + "Sec-CH-UA-Platform-Version": `"1"`, + "Sec-CH-UA-Model": `"Device\"Model\\Name"`, + "Sec-CH-UA-Arch": `"arch\\test"`, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := makeHeaders(test.device, test.rawHeaders) + assert.Equal(t, test.expected, result) + }) + } +}