diff --git a/extra/bundle/pom.xml b/extra/bundle/pom.xml index ae599096627..1ddcf0b71f6 100644 --- a/extra/bundle/pom.xml +++ b/extra/bundle/pom.xml @@ -60,6 +60,11 @@ optable-targeting ${project.version} + + org.prebid.server.hooks.modules + id5-user-id + ${project.version} + org.prebid.server.hooks.modules wurfl-devicedetection diff --git a/extra/modules/id5-user-id/README.md b/extra/modules/id5-user-id/README.md new file mode 100644 index 00000000000..ea7d976d6f5 --- /dev/null +++ b/extra/modules/id5-user-id/README.md @@ -0,0 +1,454 @@ +# ID5 User ID Module + +This module integrates ID5's universal identifier service into Prebid Server Java, enabling publishers to fetch and inject ID5 user IDs into bid requests sent to bidders. + +## Quick Navigation + +- **[Production Setup](#production-setup)** - For publishers and PBS operators +- **[Module Development](#module-development)** - For developers working on the module + +--- + +# Production Setup + +## Overview + +The ID5 User ID module fetches identity signals from ID5's API and automatically injects them into OpenRTB bid requests as Extended Identifiers (EIDs). This enhances user matching for participating bidders while respecting user privacy preferences. + +## Features + +- Fetches ID5 universal identifiers and automatically injects them into bid requests as Extended Identifiers (EIDs) +- Only adds ID5 when not already present in the publisher's bid request - preserves existing ID5 identifiers +- Privacy-compliant: respects GDPR, CCPA, COPPA, and GPP signals +- Flexible control: filter by account, country, bidder, or use sampling to gradually roll out + +## How It Works + +The module uses two hooks in the Prebid Server request lifecycle: + +1. **Fetch Hook** (`ProcessedAuctionRequestHook`): + - Triggered early in the auction request processing + - **First checks if ID5 EID already exists** - if present, skips fetching entirely + - Initiates an asynchronous call to ID5's API only when ID5 is not present + - Stores the Future result in module context for later use + - Applies fetch filters (account, country, sampling) + +2. **Inject Hook** (`BidderRequestHook`): + - Triggered before each bidder request + - **Checks again if ID5 EID is already present** - if so, skips injection + - Awaits the ID5 fetch result (with timeout awareness) + - Injects ID5 EIDs into `user.eids` field only when not already present + - Applies inject filters (bidder selection) + - Sets the `inserter` field to the EID if configured + +``` +┌─────────────────┐ +│ Auction Request │ +└────────┬────────┘ + │ + v +┌─────────────────────────┐ +│ ID5 Fetch Hook │ +│ Check: ID5 exists? │ +└────────┬────────────────┘ + │ + ├─ YES ──> Skip (no fetch needed) + │ + └─ NO ───> Async call to ID5 API + stores Future in context + │ + v +┌─────────────────────┐ +│ Bidder Requests │ +└────────┬────────────┘ + │ + v +┌─────────────────────────┐ +│ ID5 Inject Hook │ +│ Check: ID5 exists? │ +└────────┬────────────────┘ + │ + ├─ YES ──> Skip (preserve existing) + │ + └─ NO ───> Await Future, inject EIDs +``` +## What Data is Sent to ID5 + +The module sends the following information to ID5's API: + +**From Bid Request:** +- App bundle (`app.bundle`) +- Site domain (`site.domain`) +- Site referrer (`site.ref`) +- Device IFA/MAID (`device.ifa`) +- Device User Agent (`device.ua`) +- Device IP address (`device.ip`) +- ATT status (`device.ext.atts`) + +**Privacy Signals:** +- GDPR consent string +- GDPR applies flag +- US Privacy (CCPA) string +- COPPA flag +- GPP string and SID + +**Module Metadata:** +- Partner ID +- Timestamp +- PBS version +- Origin identifier +- Provider string + +## What data is added to the bidder request? + +The module's `ID5 Inject Hook` adds [EID](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/main/2.6.md#3227---object-eid-)s to the OpenRTB `user.eids` array. +The EID objects come from a response to the fetch request triggered by `ID5 Fetch Hook` called at earlier stage of the action. +The EID before insertion can be enriched with `inserter` field which is configurable by server host. + +Example EID added: + +```json +{ + "user": { + "eids": [ + { + "source": "id5-sync.com", + "uids": [ + { + "id": "ID5*YsvxY...", + "atype": 1, + "ext": { + "linkType": 2, + "pba": "jWwv+..." + } + } + ], + "inserter": "pbs-company.com" // this can be configured + } + ] + } +} +``` + +## Privacy & Compliance + +The module respects privacy signals: + +- **GDPR**: Passes consent string and GDPR applies flag to ID5 +- **CCPA**: Passes US Privacy string to ID5 +- **COPPA**: Passes COPPA flag to ID5 +- **GPP**: Passes GPP string and applicable sections to ID5 + +ID5's API will respect these signals when generating identifiers. Ensure your privacy policy covers the use of ID5's service and data sharing with ID5. + + +## Configuration + +### Required Properties + +| Property | Type | Description | +|----------|------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `enabled` | boolean | Must be `true` to activate the module | +| `providerName` | string | Provider identifier string sent to ID5 API. Identifies who is hosting/operating the Prebid Server instance (e.g., "my-company-pbs", "my-company-com"). | +| `partner` | long | ID5 Partner ID (minimum value: 1). Required only when using the default constant provider. Not needed if you provide a custom `Id5PartnerIdProvider` bean (see Custom Partner ID Configuration below) | + +### Custom Partner ID Configuration + +By default, the module uses a constant Partner ID from the `hooks.id5-user-id.partner` configuration property. This partner id is used for each id5id fetch request. + +In some configurations may be needed to pass different partner depending on channel, publisher where the auction request comes from, or anything else. +For such cases implement the `Id5PartnerIdProvider` interface and register it as a Spring bean. The module will automatically use your custom implementation. + +**Important:** If the provider returns an empty value, the ID5 fetch will be skipped for that request. + +### Optional Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `inserterName` | string | null | The canonical domain name of the entity that caused this EID to be added (e.g., "pbs-company.com", "ssp.example.com"). Should be the operational domain of the system running this module. See [OpenRTB EID specification](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/main/2.6.md#3227---object-eid-) for details. | +| `fetchEndpoint` | string | `https://api.id5-sync.com/gs/v2` | ID5 API endpoint URL | +| `fetchSamplingRate` | double | 1.0 | Percentage of requests to sample (0.0-1.0) | +| `bidderFilter` | ValuesFilter | null | Filter bidders that receive IDs | +| `accountFilter` | ValuesFilter | null | Filter accounts that trigger fetches | +| `countryFilter` | ValuesFilter | null | Filter countries that trigger fetches | + +### ValuesFilter Structure + +Each filter supports include/exclude semantics: + +```yaml +: + exclude: false # false = allowlist, true = blocklist + values: # list of values to include/exclude + - value1 + - value2 +``` + +**Allowlist mode** (`exclude: false`): +- Only requests matching the listed values will proceed +- Empty or null values list = allow all + +**Blocklist mode** (`exclude: true`): +- Requests matching the listed values will be rejected +- Empty or null values list = allow all + +### Filter Behavior + +Filters are evaluated in sequence. If any filter rejects the request, no ID5 fetch occurs: + +1. **Sampling Filter**: Random sampling based on `fetchSamplingRate` +2. **Account Filter**: Checks account ID against `accountFilter` +3. **Country Filter**: Checks country code against `countryFilter` +4. **Bidder Filter**: (Inject only) Checks bidder name against `bidderFilter` + +## Integration + +### Add module dependency +The module dependency must be added to your server application. +```xml + + org.prebid.server.hooks.modules + id5-user-id + ${PREBID_SERVER_VERSION} + +``` + +The module is included in the `extra/bundle` by default `extra/bundle/pom.xml` + +### Configure module + +To run a module you must +- enable module in config and add required properties +- configure an execution plan that registers the module's hooks. See [Configure an execution plan with module's hooks](#configure-an-execution-plan-with-modules-hooks) for details. + +#### Enabling and configuring module +##### Basic (minimal) Setup +Enable for all accounts and bidders: +```yaml +hooks: + id5-user-id: + enabled: true + provider-name: "my-pbs-host" # Required: identifies who operates this PBS instance + partner: 173 +``` + +##### Gradual Rollout with Sampling +```yaml +hooks: + id5-user-id: + enabled: true + provider-name: "my-pbs-host" # Required: identifies who operates this PBS instance + partner: 173 + fetch-sampling-rate: 0.1 # 10% of requests +``` + +##### With multiple filters +```yaml +hooks: + id5-user-id: + enabled: true + provider-name: "my-pbs-host" # Required: identifies who operates this PBS instance + partner: 173 + inserter-name: "pbs-company.com" # Canonical domain of the entity that added this EID + fetch-sampling-rate: 0.8 + account-filter: # for auctions from any account except "test-account" + exclude: true + values: [test-account] + country-filter: # only actions from listed countries + exclude: false + values: [US, GB, DE, FR] + bidder-filter: # id will be added to only listed bidder's requests + exclude: false + values: [rubicon, appnexus, pubmatic] +``` + +#### Configure an execution plan with module's hooks +Default or for a specific account. + +```yaml +accounts: + - id: "1001" + status: active + hooks: + execution-plan: + { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "processed-auction-request": { + "groups": [ + { + "hook-sequence": [ + { "module-code": "id5-user-id", "hook-impl-code": "id5-user-id-fetch-hook" } + ] + } + ] + }, + "bidder-request": { + "groups": [ + { + "hook-sequence": [ + { "module-code": "id5-user-id", "hook-impl-code": "id5-user-id-inject-hook" } + ] + } + ] + } + } + } + } + } +``` +## Troubleshooting + +### No EIDs appearing in bid requests + +**Check:** +1. Module dependency is added to your server application's class path +2. Module is enabled: `enabled: true` in configuration +3. Required properties are set (`partner` property or custom `PartnerIdProvider` spring bean, `provider-name` property) +4. Execution plan configured with module's hooks is configured +5. Enable DEBUG logging and verify fetch/inject occurred +6. Verify filters aren't excluding the request + +## Performance Considerations + +- ID5 fetch is asynchronous and non-blocking +- Fetch result is cached in module context for all bidders +- HTTP client uses connection pooling +- Timeout is respected from the auction context +- Failed fetches don't block the auction (returns empty) + +## Support + +For issues specific to: +- **Module implementation**: Contact ID5 support at support@id5.io +- **ID5 service/API**: Contact ID5 support at support@id5.io +- **Partner ID registration**: Contact your ID5 account manager + +--- + +# Module Development + +This section is for developers working on the ID5 User ID module itself. + +## Debugging + +Enable debug mode to see detailed hook execution messages: + +```yaml +logging: + level: + org.prebid.server.hooks.modules.id5: DEBUG +``` + +Debug logs will show when IDs are fetched, injected, or skipped (due to filters, existing IDs, or timeouts). + +## Local End-to-End Testing + +The repository includes complete sample configurations for local testing with WireMock mocks. This allows you to test the full ID5 module flow without connecting to the real ID5 API. + +### Prerequisites + +- Java 17+ +- Maven 3.8+ +- Docker Desktop (for WireMock) + +### Quick Start + +1. **Start WireMock**: + ```bash + cd sample/wiremock + docker compose -f docker-compose.wiremock.yml up -d + ``` + +2. **Build and run PBS with ID5 module**: + From the project root directory: + ```bash + cd extra + mvn clean package -pl bundle -am -DskipTests + cd .. + java -jar extra/bundle/target/prebid-server-bundle.jar --spring.config.additional-location=sample/configs/prebid-config-with-id5.yaml + ``` + +3. **Send test request**: + ```bash + curl -X POST http://localhost:8080/openrtb2/auction \ + -H "Content-Type: application/json" \ + -d @sample/requests/localdev-test-request.http + ``` + +4. **Verify**: Check logs for `id5-user-id-fetch: id5id fetched` and `id5-user-id-inject: updated user with id5 eids` + +### Configuration Files Reference + +| File | Purpose | +|------|---------| +| `sample/configs/prebid-config-with-id5.yaml` | Main PBS config with ID5 module enabled | +| `sample/configs/sample-app-settings-id5.yaml` | Account settings with hooks execution plan | +| `sample/wiremock/mappings/id5-fetch.json` | WireMock mapping for ID5 API | +| `sample/wiremock/__files/id5-fetch-response.json` | Mock ID5 API response | +| `sample/requests/localdev-test-request.http` | Sample auction request | + +### Testing Different Scenarios + +Test various behaviors by modifying the configuration or WireMock mappings: +- **Control test**: Change account ID to one without hooks configured - verify no EIDs added +- **Timeout behavior**: Add `fixedDelayMilliseconds` to WireMock response +- **Error handling**: Change WireMock to return HTTP 503 +- **Filter testing**: Add bidder/account/country filters to configuration + +## Unit Tests + +Run the comprehensive unit test suite: + +```bash +# From the module directory +cd extra/modules/id5-user-id + +# Run all unit tests (excludes *IT.java integration tests) +mvn test + +# Run specific test class +mvn test -Dtest=Id5IdFetchHookTest + +# Run with debug logging +mvn test -Dorg.slf4j.simpleLogger.defaultLogLevel=debug +``` + +## Integration Tests + +The module includes integration tests (`*IT.java`) that run a full Prebid Server instance with the ID5 module enabled. + +**Prerequisites**: +1. Build and install the main project with test-jar: + ```bash + cd /path/to/prebid-server-java + mvn clean install -DskipUnitTests=true -DskipITs=true + ``` + +**Run integration tests**: +```bash +cd extra/modules/id5-user-id + +# Run all integration tests +mvn verify + +# Run specific integration test +mvn verify -Dit.test=Id5UserIdModuleIT + +# Skip integration tests (run only unit tests) +mvn test +``` + +--- + +## Version History + +- **v1.0**: Initial implementation + - Fetch and inject hooks + - Configurable filtering (account, country, bidder, sampling) + - Privacy signal support (GDPR, CCPA, COPPA, GPP) + +## License + +This module is part of Prebid Server Java and follows the same license terms. diff --git a/extra/modules/id5-user-id/pom.xml b/extra/modules/id5-user-id/pom.xml new file mode 100644 index 00000000000..1195e22fe47 --- /dev/null +++ b/extra/modules/id5-user-id/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + + org.prebid.server.hooks.modules + all-modules + 3.38.0-SNAPSHOT + + + id5-user-id + + id5-user-id + ID5 User ID + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + 1 + false + + + + + integration-test + verify + + + + + + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.wiremock + wiremock-jetty12 + test + + + io.rest-assured + rest-assured + test + + + org.prebid + prebid-server + ${project.version} + test-jar + test + + + diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/config/Id5UserIdModuleConfiguration.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/config/Id5UserIdModuleConfiguration.java new file mode 100644 index 00000000000..3dd879a8364 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/config/Id5UserIdModuleConfiguration.java @@ -0,0 +1,104 @@ +package org.prebid.server.hooks.modules.id5.userid.config; + +import lombok.extern.slf4j.Slf4j; +import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdFetchHook; +import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdInjectHook; +import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdModule; +import org.prebid.server.hooks.modules.id5.userid.v1.config.Id5IdModuleProperties; +import org.prebid.server.hooks.modules.id5.userid.v1.fetch.HttpFetchClient; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.AccountFetchFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.CountryFetchFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.FetchActionFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.InjectActionFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.SamplingFetchFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.SelectedBidderFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.model.ConstantId5PartnerId; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5PartnerIdProvider; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.util.VersionInfo; +import org.prebid.server.vertx.httpclient.HttpClient; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; +import java.util.List; +import java.util.Random; + +@Configuration +@EnableConfigurationProperties(Id5IdModuleProperties.class) +@ConditionalOnProperty(prefix = "hooks." + Id5IdModule.CODE, name = "enabled", havingValue = "true") +@Slf4j +public class Id5UserIdModuleConfiguration { + + @Bean + @ConditionalOnProperty(prefix = "hooks." + Id5IdModule.CODE, name = "fetch-sampling-rate") + SamplingFetchFilter fetchSampler(Id5IdModuleProperties properties) { + log.debug("id5-user-id-fetch-sampling-rate enabled with rate {}", properties.getFetchSamplingRate()); + return new SamplingFetchFilter(new Random(), properties.getFetchSamplingRate()); + } + + @Bean + @ConditionalOnProperty(prefix = "hooks." + Id5IdModule.CODE, name = "bidder-filter.values") + SelectedBidderFilter selectedBidderFilter(Id5IdModuleProperties properties) { + log.debug("id5-user-id-bidder-filter enabled, {}", properties.getBidderFilter()); + return new SelectedBidderFilter(properties.getBidderFilter()); + } + + @Bean + @ConditionalOnProperty(prefix = "hooks." + Id5IdModule.CODE, name = "account-filter.values") + AccountFetchFilter accountFetchFilter(Id5IdModuleProperties properties) { + log.debug("id5-user-id-account-filter enabled, {}", properties.getAccountFilter()); + return new AccountFetchFilter(properties.getAccountFilter()); + } + + @Bean + @ConditionalOnProperty(prefix = "hooks." + Id5IdModule.CODE, name = "country-filter.values") + CountryFetchFilter countryFetchFilter(Id5IdModuleProperties properties) { + log.debug("id5-user-id-country-filter enabled, {}", properties.getCountryFilter()); + return new CountryFetchFilter(properties.getCountryFilter()); + } + + @Bean + @ConditionalOnMissingBean(Id5PartnerIdProvider.class) + Id5PartnerIdProvider constantId5PartnerIdProvider(Id5IdModuleProperties properties) { + final Long partnerId = properties.getPartner(); + if (partnerId == null || partnerId < 1) { + throw new IllegalArgumentException( + "hooks.id5-user-id.partner is required and must be >= 1 when using default partner ID provider"); + } + return new ConstantId5PartnerId(partnerId); + } + + @Bean + Id5IdFetchHook id5UserIdFetchHook(Id5IdModuleProperties properties, + VersionInfo versionInfo, + HttpClient httpClient, + JacksonMapper jacksonMapper, + List filters, + Id5PartnerIdProvider id5PartnerIdProvider) { + final HttpFetchClient client = new HttpFetchClient( + properties.getFetchEndpoint(), + httpClient, + jacksonMapper, + Clock.systemUTC(), + versionInfo, + properties); + log.debug("id5-user-id-fetch hook enabled, endpoint: {}", properties.getFetchEndpoint()); + return new Id5IdFetchHook(client, filters, id5PartnerIdProvider); + } + + @Bean + Id5IdInjectHook id5UserIdInjectHook(Id5IdModuleProperties properties, + List injectFilters) { + log.debug("id5-user-id-inject hook enabled"); + return new Id5IdInjectHook(properties.getInserterName(), injectFilters); + } + + @Bean + Id5IdModule id5UserIdModule(Id5IdFetchHook fetchHook, Id5IdInjectHook injectHook) { + return new Id5IdModule(List.of(fetchHook, injectHook)); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/BidRequestUtils.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/BidRequestUtils.java new file mode 100644 index 00000000000..16cf34f5751 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/BidRequestUtils.java @@ -0,0 +1,21 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.User; + +import java.util.Optional; + +public class BidRequestUtils { + + private BidRequestUtils() { } + + public static final String ID5_ID_SOURCE = "id5-sync.com"; + + public static boolean isId5IdPresent(BidRequest bidRequest) { + return Optional.ofNullable(bidRequest.getUser()) + .map(User::getEids) + .map(eids -> eids.stream().anyMatch(eid -> ID5_ID_SOURCE.equals(eid.getSource()))) + .orElse(false); + } + +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdFetchHook.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdFetchHook.java new file mode 100644 index 00000000000..403c2e53477 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdFetchHook.java @@ -0,0 +1,97 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import com.google.common.collect.ImmutableList; +import io.vertx.core.Future; +import lombok.extern.slf4j.Slf4j; +import org.prebid.server.hooks.execution.v1.InvocationResultImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.fetch.FetchClient; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.FetchActionFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.FilterResult; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5PartnerIdProvider; +import org.prebid.server.hooks.v1.InvocationAction; +import org.prebid.server.hooks.v1.InvocationResult; +import org.prebid.server.hooks.v1.InvocationStatus; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.hooks.v1.auction.ProcessedAuctionRequestHook; + +import java.util.List; +import java.util.Optional; + +@Slf4j +public class Id5IdFetchHook implements ProcessedAuctionRequestHook { + + public static final String CODE = "id5-user-id-fetch-hook"; + private final FetchClient fetchClient; + private final List filters; + private final Id5PartnerIdProvider partnerIdProvider; + + public Id5IdFetchHook(FetchClient fetchClient, + List filters, + Id5PartnerIdProvider partnerIdProvider) { + this.fetchClient = fetchClient; + this.filters = ImmutableList.builder() + .addAll(filters) + .build(); + this.partnerIdProvider = partnerIdProvider; + } + + @Override + public Future> call(AuctionRequestPayload payload, + AuctionInvocationContext invocationContext) { + try { + if (BidRequestUtils.isId5IdPresent(payload.bidRequest())) { + return noInvocation("id5id already present in bidRequest"); + } + final FilterResult filterResult = shouldInvoke(payload, invocationContext); + if (!filterResult.isAccepted()) { + return noInvocation(filterResult.reason()); + } + final Optional maybePartnerId = partnerIdProvider.getPartnerId(invocationContext.auctionContext()); + if (maybePartnerId.isEmpty()) { + return noInvocation("partner id not configured"); + } + final Future id5IdFuture = fetchClient.fetch(maybePartnerId.get(), payload, invocationContext); + return Future.succeededFuture( + InvocationResultImpl.builder() + .status(InvocationStatus.success) + .action(InvocationAction.no_action) + .moduleContext(new Id5IdModuleContext(id5IdFuture)) + .debugMessages(List.of("id5-user-id-fetch: id5id fetched")) + .build()); + } catch (Exception e) { + log.error("id5-user-id-fetch: failed to fetch id5id", e); + return Future.succeededFuture(InvocationResultImpl.builder() + .status(InvocationStatus.failure) + .action(InvocationAction.no_invocation) + .errors(List.of(e.getMessage())) + .build()); + } + } + + @Override + public String code() { + return CODE; + } + + private FilterResult shouldInvoke(AuctionRequestPayload payload, + AuctionInvocationContext invocationContext) { + for (FetchActionFilter filter : filters) { + final FilterResult result = filter.shouldInvoke(payload, invocationContext); + if (!result.isAccepted()) { + return result; + } + } + return FilterResult.accepted(); + } + + private static Future> noInvocation(String msg) { + log.debug("id5-user-id-fetch: skipped, {}", msg); + return Future.succeededFuture(InvocationResultImpl.builder() + .status(InvocationStatus.success) + .action(InvocationAction.no_invocation) + .debugMessages(List.of("id5-user-id-fetch: " + msg)) + .build()); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdInjectHook.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdInjectHook.java new file mode 100644 index 00000000000..3b74b33bd9d --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdInjectHook.java @@ -0,0 +1,138 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.User; +import io.vertx.core.Future; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.prebid.server.hooks.execution.v1.InvocationResultImpl; +import org.prebid.server.hooks.execution.v1.bidder.BidderRequestPayloadImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.v1.InvocationAction; +import org.prebid.server.hooks.v1.InvocationResult; +import org.prebid.server.hooks.v1.InvocationStatus; +import org.prebid.server.hooks.v1.bidder.BidderInvocationContext; +import org.prebid.server.hooks.v1.bidder.BidderRequestHook; +import org.prebid.server.hooks.v1.bidder.BidderRequestPayload; +import org.prebid.server.util.ListUtil; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.InjectActionFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.FilterResult; + +import java.util.List; +import java.util.Optional; + +@Slf4j +public class Id5IdInjectHook implements BidderRequestHook { + + public static final String CODE = "id5-user-id-inject-hook"; + private final String inserter; + private final List filters; + + public Id5IdInjectHook(String inserter) { + this(inserter, java.util.List.of()); + } + + public Id5IdInjectHook(String inserter, List filters) { + this.inserter = inserter; + this.filters = List.copyOf(filters); + } + + @Override + public Future> call(BidderRequestPayload payload, + BidderInvocationContext invocationContext) { + try { + if (BidRequestUtils.isId5IdPresent(payload.bidRequest())) { + return noInvocation("id5id already present in bidRequest", invocationContext); + } + + // evaluate inject filters + final FilterResult filterResult = shouldInvoke(payload, invocationContext); + if (!filterResult.isAccepted()) { + return noInvocation(filterResult.reason(), invocationContext); + } + + final long remainingMs = invocationContext.timeout().remaining(); + if (remainingMs <= 0) { + return noInvocation("no time left to resolve id5Id", invocationContext); + } + + final String bidder = invocationContext.bidder(); + log.debug("id5-user-id-inject: remaining time: {}ms for bidder {}", remainingMs, bidder); + final Future userIdFuture = Id5IdModuleContext.from(invocationContext).getId5UserIdFuture(); + return userIdFuture.map(id5UserId -> { + log.debug("id5-user-id-inject: resolved userId for bidder {}", bidder); + if (id5UserId == null || CollectionUtils.isEmpty(id5UserId.toEIDs())) { + return resultBuilder(invocationContext) + .status(InvocationStatus.success) + .action(InvocationAction.no_action) + .debugMessages(List.of("id5-user-id-inject: no ids to inject")) + .build(); + } + final User originalUser = payload.bidRequest().getUser(); + final List eIDs = id5UserId.toEIDs().stream() + .map(eid -> eid.toBuilder().inserter(inserter).build()) + .toList(); + + final User updatedUser = Optional.ofNullable(originalUser) + .map(user -> user.toBuilder().eids(ListUtil.union(user.getEids(), eIDs))) + .orElseGet(() -> User.builder().eids(eIDs)) + .build(); + final BidRequest updatedBidRequest = payload.bidRequest().toBuilder() + .user(updatedUser) + .build(); + log.debug("id5-user-id-inject: user updated with {} eid(s)", eIDs.size()); + return resultBuilder(invocationContext) + .status(InvocationStatus.success) + .action(InvocationAction.update) + .payloadUpdate(initial -> BidderRequestPayloadImpl.of(updatedBidRequest)) + .debugMessages(List.of( + "id5-user-id-inject: updated user with id5 eids")) + .build(); + }); + } catch (Exception e) { + log.error("id5-user-id-inject: failed to inject id5id", e); + return Future.succeededFuture(resultBuilder(invocationContext) + .status(InvocationStatus.failure) + .action(InvocationAction.no_invocation) + .errors(List.of(e.getMessage())) + .build()); + } + } + + @Override + public String code() { + return CODE; + } + + private FilterResult shouldInvoke(BidderRequestPayload payload, + BidderInvocationContext invocationContext) { + if (filters == null || filters.isEmpty()) { + return FilterResult.accepted(); + } + for (InjectActionFilter filter : filters) { + final FilterResult result = filter.shouldInvoke(payload, invocationContext); + if (!result.isAccepted()) { + return result; + } + } + return FilterResult.accepted(); + } + + private static InvocationResultImpl.InvocationResultImplBuilder resultBuilder( + BidderInvocationContext bidderInvocationContext) { + return InvocationResultImpl.builder() + // propagate moduleContext for another bidder requests hook calls + .moduleContext(bidderInvocationContext.moduleContext()); + } + + private static Future> noInvocation( + String reason, BidderInvocationContext invocationContext) { + log.debug("id5-user-id-inject: skipped, {}", reason); + return Future.succeededFuture(resultBuilder(invocationContext) + .status(InvocationStatus.success) + .action(InvocationAction.no_invocation) + .debugMessages(List.of("id5-user-id-inject: " + reason)) + .build()); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModule.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModule.java new file mode 100644 index 00000000000..809c475ae8a --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModule.java @@ -0,0 +1,28 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import org.prebid.server.hooks.v1.Hook; +import org.prebid.server.hooks.v1.InvocationContext; +import org.prebid.server.hooks.v1.Module; + +import java.util.Collection; + +public class Id5IdModule implements Module { + + public static final String CODE = "id5-user-id"; + + private final Collection> hooks; + + public Id5IdModule(Collection> hooks) { + this.hooks = hooks; + } + + @Override + public String code() { + return CODE; + } + + @Override + public Collection> hooks() { + return hooks; + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModuleContext.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModuleContext.java new file mode 100644 index 00000000000..3c49fa65112 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModuleContext.java @@ -0,0 +1,28 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import io.vertx.core.Future; +import lombok.Getter; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; + +import javax.validation.constraints.NotNull; + +@Getter +public class Id5IdModuleContext { + + private static final Id5IdModuleContext EMPTY = new Id5IdModuleContext(Future.succeededFuture()); + private final Future id5UserIdFuture; + + public Id5IdModuleContext(Future id5UserIdFuture) { + this.id5UserIdFuture = id5UserIdFuture; + } + + @NotNull + static Id5IdModuleContext from(AuctionInvocationContext invocationContext) { + final Object moduleContext = invocationContext.moduleContext(); + if (moduleContext instanceof Id5IdModuleContext) { + return (Id5IdModuleContext) moduleContext; + } + return EMPTY; + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/config/Id5IdModuleProperties.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/config/Id5IdModuleProperties.java new file mode 100644 index 00000000000..1c17806ea08 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/config/Id5IdModuleProperties.java @@ -0,0 +1,41 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.config; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdModule; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.DecimalMax; + +/** + * Configuration model for the ID5 ID module. + */ +@Data +@NoArgsConstructor +@ConfigurationProperties(prefix = "hooks." + Id5IdModule.CODE) +@Validated +public class Id5IdModuleProperties { + + private Long partner; + + @NotBlank + private String providerName; + + private String inserterName; + + @NotBlank + @Pattern(regexp = "https?://.+", message = "must be a valid http(s) URL") + private String fetchEndpoint = "https://api.id5-sync.com/gs/v2"; + + @PositiveOrZero + @DecimalMax(value = "1.0") + private double fetchSamplingRate; + + private ValuesFilter bidderFilter; + private ValuesFilter accountFilter; + private ValuesFilter countryFilter; +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/config/ValuesFilter.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/config/ValuesFilter.java new file mode 100644 index 00000000000..5d956861237 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/config/ValuesFilter.java @@ -0,0 +1,27 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.config; + +import lombok.Data; + +import java.util.Set; + +@Data +public class ValuesFilter { + + private boolean exclude = false; + private Set values; + + /** + * Determines whether a value is allowed based on include/exclude semantics. + * If the set of values is null or empty, no filtering is applied (always allowed). + * Null value is not allowed + */ + public boolean isValueAllowed(T value) { + if (values == null || values.isEmpty()) { + return true; + } + if (value == null) { + return false; + } + return exclude != values.contains(value); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/fetch/FetchClient.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/fetch/FetchClient.java new file mode 100644 index 00000000000..9cd2fed63f1 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/fetch/FetchClient.java @@ -0,0 +1,12 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.fetch; + +import io.vertx.core.Future; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; + +public interface FetchClient { + + Future fetch(long partnerId, AuctionRequestPayload payload, + AuctionInvocationContext invocationContext); +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/fetch/HttpFetchClient.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/fetch/HttpFetchClient.java new file mode 100644 index 00000000000..ed005f7c2a3 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/fetch/HttpFetchClient.java @@ -0,0 +1,171 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.fetch; + +import com.iab.openrtb.request.App; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Site; +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import lombok.extern.slf4j.Slf4j; +import org.prebid.server.hooks.modules.id5.userid.v1.config.Id5IdModuleProperties; +import org.prebid.server.hooks.modules.id5.userid.v1.model.FetchRequest; +import org.prebid.server.hooks.modules.id5.userid.v1.model.FetchRequest.PrebidServerMetadata; +import org.prebid.server.hooks.modules.id5.userid.v1.model.FetchRequest.PrebidServerMetadata.PrebidServerMetadataBuilder; +import org.prebid.server.hooks.modules.id5.userid.v1.model.FetchRequest.Publisher; +import org.prebid.server.hooks.modules.id5.userid.v1.model.FetchResponse; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.privacy.ccpa.Ccpa; +import org.prebid.server.privacy.model.Privacy; +import org.prebid.server.privacy.model.PrivacyContext; +import org.prebid.server.proto.openrtb.ext.request.ExtDevice; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebidData; +import org.prebid.server.util.HttpUtil; +import org.prebid.server.util.VersionInfo; +import org.prebid.server.vertx.httpclient.HttpClient; +import org.prebid.server.vertx.httpclient.model.HttpClientResponse; + +import java.time.Clock; +import java.util.Optional; +import java.util.stream.Collectors; + +@Slf4j +public class HttpFetchClient implements FetchClient { + + private final String fetchUrl; + private final HttpClient httpClient; + private final Clock clock; + private final JacksonMapper mapper; + private final VersionInfo versionInfo; + private final Id5IdModuleProperties id5IdModuleProperties; + + public HttpFetchClient(String endpoint, + HttpClient httpClient, + JacksonMapper mapper, + Clock clock, + VersionInfo versionInfo, + Id5IdModuleProperties id5IdModuleProperties) { + this.fetchUrl = endpoint; + this.httpClient = httpClient; + this.mapper = mapper; + this.clock = clock; + this.versionInfo = versionInfo; + this.id5IdModuleProperties = id5IdModuleProperties; + } + + @Override + public Future fetch(long partnerId, AuctionRequestPayload payload, + AuctionInvocationContext invocationContext) { + final FetchRequest fetchRequest = createFetchRequest(partnerId, payload, invocationContext); + try { + final String body = mapper.encodeToString(fetchRequest); + final MultiMap headers = HttpUtil.headers(); + final String url = String.format("%s/%s.json", fetchUrl, partnerId); + final long timeoutMs = invocationContext.timeout().remaining(); + log.debug("id5-user-id: fetching id5Id from endpoint {} with timeout {}. Headers {}, body {}", + url, timeoutMs, headers, body); + + return httpClient + .post(url, headers, body, timeoutMs) + .map(this::parseResponse) + .recover(this::handleError); + } catch (Exception e) { + return handleError(e); + } + } + + private FetchRequest createFetchRequest(long partnerId, + AuctionRequestPayload payload, + AuctionInvocationContext invocationContext) { + final BidRequest bidRequest = payload.bidRequest(); + final PrivacyContext privacyContext = invocationContext.auctionContext().getPrivacyContext(); + final Optional maybeDevice = Optional.ofNullable(bidRequest.getDevice()); + final FetchRequest.FetchRequestBuilder fetchRequestBuilder = FetchRequest.builder() + .trace(invocationContext.debugEnabled()) + .partnerId(partnerId) + .origin("pbs-java") + .version(versionInfo.getVersion()) + .timestamp(clock.instant().toString()) + .provider(id5IdModuleProperties.getProviderName()) + .providerMetadata(createProviderMetadata(bidRequest)) + .bundle(Optional.ofNullable(bidRequest.getApp()).map(App::getBundle).orElse(null)) + .domain(Optional.ofNullable(bidRequest.getSite()).map(Site::getDomain).orElse(null)) + .maid(maybeDevice.map(Device::getIfa).orElse(null)) + .userAgent(maybeDevice.map(Device::getUa).orElse(null)) + .ref(Optional.ofNullable(bidRequest.getSite()).map(Site::getRef).orElse(null)) + .ipv4(maybeDevice.map(Device::getIp).orElse(null)) + .ipv6(maybeDevice.map(Device::getIpv6).orElse(null)) + .att(maybeDevice + .map(Device::getExt) + .map(ExtDevice::getAtts) + .map(String::valueOf) + .orElse(null)); + + if (privacyContext != null && privacyContext.getPrivacy() != null) { + final Privacy privacy = privacyContext.getPrivacy(); + fetchRequestBuilder + .coppa(Optional.ofNullable(privacy.getCoppa()).map(String::valueOf).orElse(null)) + .usPrivacy(Optional.ofNullable(privacy.getCcpa()).map(Ccpa::getUsPrivacy).orElse(null)) + .gppString(privacy.getGpp()) + .gppSid(Optional.ofNullable(privacy.getGppSid()) + .filter(gppSid -> !gppSid.isEmpty()) + .map(gppSid -> gppSid.stream().map(String::valueOf) + .collect(Collectors.joining(","))) + .orElse(null)) + .gdpr(privacy.getGdpr()) + .gdprConsent(privacy.getConsentString()); + } + return fetchRequestBuilder.build(); + } + + private PrebidServerMetadata createProviderMetadata(BidRequest bidRequest) { + final PrebidServerMetadataBuilder builder = PrebidServerMetadata.builder() + .id5ModuleConfig(this.id5IdModuleProperties); + + Optional.ofNullable(bidRequest.getApp()).map(App::getPublisher) + .or(() -> Optional.ofNullable(bidRequest.getSite()).map(Site::getPublisher)) + .ifPresent(ortbPublisher -> builder.publisher(Publisher.builder() + .id(ortbPublisher.getId()) + .name(ortbPublisher.getName()) + .domain(ortbPublisher.getDomain()) + .build())); + + final Optional maybePrebidExt = Optional.ofNullable(bidRequest.getExt()) + .map(ExtRequest::getPrebid); + + maybePrebidExt + .map(ExtRequestPrebid::getChannel) + .ifPresent(channel -> builder + .channel(channel.getName()) + .channelVersion(channel.getVersion()) + ); + + maybePrebidExt + .map(ExtRequestPrebid::getData) + .map(ExtRequestPrebidData::getBidders) + .ifPresent(builder::bidders); + + return builder.build(); + } + + private Future handleError(Throwable exception) { + log.error("id5-user-id: failed to fetch id5Id from endpoint {}", fetchUrl, exception); + return Future.succeededFuture(Id5UserId.empty()); + } + + private Id5UserId parseResponse(HttpClientResponse response) { + final String body = response.getBody(); + final int statusCode = response.getStatusCode(); + if (response.getStatusCode() == 200) { + log.debug("id5-user-id: fetched id5Id succeeded, body {}", body); + return mapper.decodeValue(body, FetchResponse.class); + } else { + log.error("id5-user-id: fetched id5Id failed, status {}, body {}", statusCode, body); + return Id5UserId.empty(); + } + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/AccountFetchFilter.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/AccountFetchFilter.java new file mode 100644 index 00000000000..69f813fcb62 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/AccountFetchFilter.java @@ -0,0 +1,32 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import org.prebid.server.hooks.modules.id5.userid.v1.config.ValuesFilter; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.settings.model.Account; + +import java.util.Objects; + +/** + * Filters fetch invocation by account id using {@link ValuesFilter} configuration. + */ +public class AccountFetchFilter implements FetchActionFilter { + + private final ValuesFilter accountFilter; + + public AccountFetchFilter(ValuesFilter accountFilter) { + this.accountFilter = Objects.requireNonNull(accountFilter); + } + + @Override + public FilterResult shouldInvoke(AuctionRequestPayload payload, AuctionInvocationContext invocationContext) { + final Account account = invocationContext.auctionContext().getAccount(); + final String accountId = account != null ? account.getId() : null; + if (accountId == null || accountId.isBlank()) { + return FilterResult.rejected("missing account id"); + } + return accountFilter.isValueAllowed(accountId) + ? FilterResult.accepted() + : FilterResult.rejected("account " + accountId + " rejected by config"); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/CountryFetchFilter.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/CountryFetchFilter.java new file mode 100644 index 00000000000..56265cb957d --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/CountryFetchFilter.java @@ -0,0 +1,50 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Geo; +import org.prebid.server.geolocation.model.GeoInfo; +import org.prebid.server.hooks.modules.id5.userid.v1.config.ValuesFilter; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; + +import java.util.Objects; +import java.util.Optional; + +/** + * Filters fetch invocation by country code using {@link ValuesFilter} configuration. + * Country resolution order: + * 1) AuctionContext.geoInfo.country (resolved by PBS geo module) + * 2) bidRequest.device.geo.country + */ +public class CountryFetchFilter implements FetchActionFilter { + + private final ValuesFilter countryFilter; + + public CountryFetchFilter(ValuesFilter countryFilter) { + this.countryFilter = Objects.requireNonNull(countryFilter); + } + + @Override + public FilterResult shouldInvoke(AuctionRequestPayload payload, AuctionInvocationContext invocationContext) { + final String country = resolveCountry(payload.bidRequest(), invocationContext); + if (country == null || country.isBlank()) { + return FilterResult.rejected("missing country"); + } + return countryFilter.isValueAllowed(country) + ? FilterResult.accepted() + : FilterResult.rejected("country " + country + " rejected by config"); + } + + private static String resolveCountry(BidRequest bidRequest, AuctionInvocationContext invocationContext) { + final GeoInfo geoInfo = invocationContext.auctionContext().getGeoInfo(); + if (geoInfo != null && geoInfo.getCountry() != null && !geoInfo.getCountry().isBlank()) { + return geoInfo.getCountry(); + } + return Optional.ofNullable(bidRequest) + .map(BidRequest::getDevice) + .map(Device::getGeo) + .map(Geo::getCountry) + .orElse(null); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/FetchActionFilter.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/FetchActionFilter.java new file mode 100644 index 00000000000..739b15a4fed --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/FetchActionFilter.java @@ -0,0 +1,10 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; + +public interface FetchActionFilter { + + FilterResult shouldInvoke(AuctionRequestPayload payload, + AuctionInvocationContext invocationContext); +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/FilterResult.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/FilterResult.java new file mode 100644 index 00000000000..19cb663a453 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/FilterResult.java @@ -0,0 +1,13 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +public record FilterResult(boolean isAccepted, String reason) { + private static final FilterResult ACCEPTED = new FilterResult(true, ""); + + public static FilterResult rejected(String reason) { + return new FilterResult(false, reason); + } + + public static FilterResult accepted() { + return ACCEPTED; + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/InjectActionFilter.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/InjectActionFilter.java new file mode 100644 index 00000000000..d81551852d5 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/InjectActionFilter.java @@ -0,0 +1,10 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import org.prebid.server.hooks.v1.bidder.BidderInvocationContext; +import org.prebid.server.hooks.v1.bidder.BidderRequestPayload; + +public interface InjectActionFilter { + + FilterResult shouldInvoke(BidderRequestPayload payload, + BidderInvocationContext invocationContext); +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SamplingFetchFilter.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SamplingFetchFilter.java new file mode 100644 index 00000000000..dfe37e97b61 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SamplingFetchFilter.java @@ -0,0 +1,23 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; + +import java.util.Random; + +public class SamplingFetchFilter implements FetchActionFilter { + + private final double sampleRate; + private final Random random; + + public SamplingFetchFilter(Random random, double sampleRate) { + this.sampleRate = sampleRate; + this.random = random; + } + + @Override + public FilterResult shouldInvoke(AuctionRequestPayload payload, AuctionInvocationContext invocationContext) { + final boolean shouldInvoke = random.nextDouble() <= sampleRate; + return shouldInvoke ? FilterResult.accepted() : FilterResult.rejected("rejected by sampling"); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SelectedBidderFilter.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SelectedBidderFilter.java new file mode 100644 index 00000000000..aa6c39c4070 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SelectedBidderFilter.java @@ -0,0 +1,21 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import org.prebid.server.hooks.modules.id5.userid.v1.config.ValuesFilter; +import org.prebid.server.hooks.v1.bidder.BidderInvocationContext; +import org.prebid.server.hooks.v1.bidder.BidderRequestPayload; + +public class SelectedBidderFilter implements InjectActionFilter { + + private final ValuesFilter biddersFilter; + + public SelectedBidderFilter(ValuesFilter biddersFilter) { + this.biddersFilter = biddersFilter; + } + + @Override + public FilterResult shouldInvoke(BidderRequestPayload payload, BidderInvocationContext invocationContext) { + final String bidder = invocationContext.bidder(); + return biddersFilter.isValueAllowed(bidder) ? FilterResult.accepted() + : FilterResult.rejected("bidder " + bidder + " rejected by config"); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/ConstantId5PartnerId.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/ConstantId5PartnerId.java new file mode 100644 index 00000000000..07a610c602c --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/ConstantId5PartnerId.java @@ -0,0 +1,19 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.model; + +import org.prebid.server.auction.model.AuctionContext; + +import java.util.Optional; + +public class ConstantId5PartnerId implements Id5PartnerIdProvider { + + private final long partnerId; + + public ConstantId5PartnerId(long partnerId) { + this.partnerId = partnerId; + } + + @Override + public Optional getPartnerId(AuctionContext ignore) { + return Optional.of(partnerId); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/FetchRequest.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/FetchRequest.java new file mode 100644 index 00000000000..7459e76e6c6 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/FetchRequest.java @@ -0,0 +1,83 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.model; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Value; +import org.prebid.server.hooks.modules.id5.userid.v1.config.Id5IdModuleProperties; + +import java.util.List; + +@Builder +@Value +public class FetchRequest { + + @JsonProperty("partner") + long partnerId; + + @JsonProperty("ts") + String timestamp; + + @JsonAlias("ip") + String ipv4; + + String ipv6; + + @JsonProperty("ua") + String userAgent; + + String maid; + + String domain; + + String ref; + + String bundle; + + String att; + + String provider; + + PrebidServerMetadata providerMetadata; + + String origin; + + String version; + + @JsonProperty("us_privacy") + String usPrivacy; + + String gdpr; + + @JsonProperty("gdpr_consent") + String gdprConsent; + + @JsonProperty("gpp_string") + String gppString; + + @JsonProperty("gpp_sid") + String gppSid; + + String coppa; + + @JsonProperty("_trace") + boolean trace; + + @Builder + @Value + public static class PrebidServerMetadata { + String channel; + String channelVersion; + Id5IdModuleProperties id5ModuleConfig; + Publisher publisher; + List bidders; + } + + @Builder + @Value + public static class Publisher { + String id; + String name; + String domain; + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/FetchResponse.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/FetchResponse.java new file mode 100644 index 00000000000..e5b6b6ca966 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/FetchResponse.java @@ -0,0 +1,20 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.model; + +import com.iab.openrtb.request.Eid; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public record FetchResponse(Map ids) implements Id5UserId { + + public record UserId(Eid eid) { + } + + public List toEIDs() { + return Optional.ofNullable(ids) + .map(userIds -> userIds.values().stream().map(UserId::eid).toList()) + .orElse(Collections.emptyList()); + } +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5PartnerIdProvider.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5PartnerIdProvider.java new file mode 100644 index 00000000000..adefde62023 --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5PartnerIdProvider.java @@ -0,0 +1,30 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.model; + +import org.prebid.server.auction.model.AuctionContext; + +import javax.validation.constraints.NotNull; +import java.util.Optional; + +/** + * Provider for ID5 Partner ID that can return different values based on the auction context. + *

+ * Implement this interface to provide dynamic Partner IDs based on account ID, channel, or other criteria. + * Register your implementation as a Spring bean with @Component or in a @Configuration class. + *

+ * The default implementation {@link ConstantId5PartnerId} returns a constant value from configuration. + *

+ * If {@link #getPartnerId(AuctionContext)} returns {@link Optional#empty()}, the ID5 fetch will be skipped + * for that request. + */ +public interface Id5PartnerIdProvider { + + /** + * Returns the ID5 Partner ID for the given auction context. + * + * @param auctionContext the auction context containing account, request, and privacy information + * @return Optional containing the Partner ID, or empty to skip the ID5 fetch for this request + */ + @NotNull + Optional getPartnerId(AuctionContext auctionContext); + +} diff --git a/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5UserId.java b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5UserId.java new file mode 100644 index 00000000000..ed207a56bcb --- /dev/null +++ b/extra/modules/id5-user-id/src/main/java/org/prebid/server/hooks/modules/id5/userid/v1/model/Id5UserId.java @@ -0,0 +1,17 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.model; + +import com.iab.openrtb.request.Eid; + +import java.util.List; + +public interface Id5UserId { + + List toEIDs(); + + Id5UserId EMPTY = List::of; + + static Id5UserId empty() { + return EMPTY; + } + +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleAccountFilterIT.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleAccountFilterIT.java new file mode 100644 index 00000000000..707a834260c --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleAccountFilterIT.java @@ -0,0 +1,51 @@ +package org.prebid.server.hooks.modules.id5.userid; + +import io.restassured.response.Response; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; + +@DirtiesContext +@TestPropertySource(properties = { + // Override ID5 Module Configuration with account filter (blocklist) + "hooks.id5-user-id.enabled=true", + "hooks.id5-user-id.partner=173", + "hooks.id5-user-id.inserter-name=prebid-server", + "hooks.id5-user-id.fetch-endpoint=http://localhost:8090/id5-fetch", + "hooks.id5-user-id.account-filter.exclude=true", + "hooks.id5-user-id.account-filter.values=blocked-account", + // Settings Configuration + "settings.filesystem.settings-filename=src/test/resources/test-app-settings.yaml", + "settings.filesystem.stored-requests-dir=", + "settings.filesystem.stored-imps-dir=", + "settings.filesystem.profiles-dir=", + "settings.filesystem.stored-responses-dir=", + "settings.filesystem.categories-dir=" +}) +public class Id5UserIdModuleAccountFilterIT extends Id5UserIdModuleITBase { + + @Test + public void shouldFetchId5ForNonBlockedAccount() throws Exception { + // given: ID5 module enabled with account filter blocklist ("blocked-account" excluded) + // given: Request uses "test-account-id5" which is NOT blocked + final Response response = sendAuctionRequest(createAuctionRequestWithMultipleBidders()); + + Assertions.assertThat(response.statusCode()).isEqualTo(200); + + verifyId5FetchCalled(1); + verifyBidderReceivedRequestWithId5Eid(GENERIC_EXCHANGE_PATH, TEST_ID5_VALUE); + verifyBidderReceivedRequestWithId5Eid(APPNEXUS_EXCHANGE_PATH, TEST_ID5_VALUE); + } + + @Test + public void shouldSkipFetchForBlockedAccount() throws Exception { + // given: ID5 module enabled with account filter blocklist ("blocked-account" excluded) + // given: Request uses "blocked-account" which IS blocked + final Response response = sendAuctionRequest(createBlockedAccountRequest()); + + Assertions.assertThat(response.statusCode()).isEqualTo(200); + verifyId5FetchCalled(0); + verifyBidderReceivedRequestWithoutId5Eid(GENERIC_EXCHANGE_PATH); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleBidderFilterIT.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleBidderFilterIT.java new file mode 100644 index 00000000000..72cdd325a4d --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleBidderFilterIT.java @@ -0,0 +1,41 @@ +package org.prebid.server.hooks.modules.id5.userid; + +import io.restassured.response.Response; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; + +@DirtiesContext +@TestPropertySource(properties = { + // Override ID5 Module Configuration with bidder filter (allowlist) + "hooks.id5-user-id.enabled=true", + "hooks.id5-user-id.partner=173", + "hooks.id5-user-id.inserter-name=prebid-server", + "hooks.id5-user-id.fetch-endpoint=http://localhost:8090/id5-fetch", + "hooks.id5-user-id.bidder-filter.exclude=false", + "hooks.id5-user-id.bidder-filter.values=generic", + // Settings Configuration + "settings.filesystem.settings-filename=src/test/resources/test-app-settings.yaml", + "settings.filesystem.stored-requests-dir=", + "settings.filesystem.stored-imps-dir=", + "settings.filesystem.profiles-dir=", + "settings.filesystem.stored-responses-dir=", + "settings.filesystem.categories-dir=" +}) +public class Id5UserIdModuleBidderFilterIT extends Id5UserIdModuleITBase { + + @Test + public void shouldOnlyInjectEidsIntoAllowlistedBidder() throws Exception { + // given: ID5 module enabled with bidder filter allowlist (only "generic" bidder allowed) + final Response response = sendAuctionRequest(createAuctionRequestWithMultipleBidders()); + + Assertions.assertThat(response.statusCode()).isEqualTo(200); + Assertions.assertThat(response.jsonPath().getString("id")).isEqualTo("test-request-id"); + + verifyId5FetchCalled(1); + verifyBidderReceivedRequestWithId5Eid(GENERIC_EXCHANGE_PATH, TEST_ID5_VALUE); + verifyBidderReceivedRequestWithoutId5Eid(APPNEXUS_EXCHANGE_PATH); + } + +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleIT.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleIT.java new file mode 100644 index 00000000000..5cd1f2e7c76 --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleIT.java @@ -0,0 +1,31 @@ +package org.prebid.server.hooks.modules.id5.userid; + +import io.restassured.response.Response; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +public class Id5UserIdModuleIT extends Id5UserIdModuleITBase { + + @Test + public void shouldFetchId5AndInjectEIDsIntoAllBidderRequests() throws Exception { + // given: ID5 module enabled with no filters (all accounts and bidders allowed) + final Response response = sendAuctionRequest(createAuctionRequestWithMultipleBidders()); + + Assertions.assertThat(response.statusCode()).isEqualTo(200); + Assertions.assertThat(response.jsonPath().getString("id")).isEqualTo("test-request-id"); + + verifyId5FetchCalled(1); + verifyBidderReceivedRequestWithId5Eid(GENERIC_EXCHANGE_PATH, TEST_ID5_VALUE); + verifyBidderReceivedRequestWithId5Eid(APPNEXUS_EXCHANGE_PATH, TEST_ID5_VALUE); + } + + @Test + public void shouldSkipFetchWhenId5AlreadyPresent() throws Exception { + // given: ID5 module enabled, request already contains ID5 EID in user.eids + final Response response = sendAuctionRequest(createAuctionRequestWithExistingId5("existing-id5")); + + Assertions.assertThat(response.statusCode()).isEqualTo(200); + verifyId5FetchCalled(0); + verifyBidderReceivedRequestWithExistingId5(GENERIC_EXCHANGE_PATH, "existing-id5"); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleITBase.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleITBase.java new file mode 100644 index 00000000000..7fa87890399 --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/Id5UserIdModuleITBase.java @@ -0,0 +1,300 @@ +package org.prebid.server.hooks.modules.id5.userid; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.iab.openrtb.request.Banner; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.Format; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Publisher; +import com.iab.openrtb.request.Regs; +import com.iab.openrtb.request.Site; +import com.iab.openrtb.request.Uid; +import com.iab.openrtb.request.User; +import org.junit.jupiter.api.BeforeEach; +import org.prebid.server.it.IntegrationTest; +import org.prebid.server.proto.openrtb.ext.request.ExtRegs; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; +import org.springframework.test.context.TestPropertySource; + +import java.util.Collections; +import java.util.Map; + +/** + * Base class for ID5 User ID Module integration tests. + *

+ * Provides common WireMock setup and helper methods for testing the ID5 module + * in a full Prebid Server environment. + */ +@TestPropertySource(properties = { + // ID5 Module Configuration + "hooks.id5-user-id.enabled=true", + "hooks.id5-user-id.partner=173", + "hooks.id5-user-id.provider-name=prebid-server", + "hooks.id5-user-id.inserter-name=prebid-server", + "hooks.id5-user-id.fetch-endpoint=http://localhost:8090/id5-fetch", + // Settings Configuration - use custom test-app-settings.yaml for ID5 hooks + "settings.filesystem.settings-filename=src/test/resources/test-app-settings.yaml", + "settings.filesystem.stored-requests-dir=", + "settings.filesystem.stored-imps-dir=", + "settings.filesystem.profiles-dir=", + "settings.filesystem.stored-responses-dir=", + "settings.filesystem.categories-dir=" + // Note: Generic adapter is already configured in base test-application.properties +}) +public abstract class Id5UserIdModuleITBase extends IntegrationTest { + + protected static final String ID5_FETCH_PATH = "/id5-fetch/173.json"; + protected static final String GENERIC_EXCHANGE_PATH = "/generic-exchange"; + protected static final String APPNEXUS_EXCHANGE_PATH = "/appnexus-exchange"; + protected static final String TEST_ACCOUNT_ID = "test-account-id5"; + protected static final String TEST_ID5_VALUE = "ID5*test-e2e-id5-user-id"; + + @BeforeEach + public void setUpId5Mocks() { + // Mock successful ID5 API response + WIRE_MOCK_RULE.stubFor(WireMock.post(WireMock.urlPathEqualTo(ID5_FETCH_PATH)) + .willReturn(WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "ids": { + "id5": { + "eid": { + "source": "id5-sync.com", + "uids": [{ + "id": "ID5*test-e2e-id5-user-id", + "atype": 1 + }] + } + } + } + } + """))); + + // Mock generic bidder response + WIRE_MOCK_RULE.stubFor(WireMock.post(WireMock.urlPathEqualTo(GENERIC_EXCHANGE_PATH)) + .willReturn(WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "id": "test-request-id", + "seatbid": [{ + "seat": "generic", + "bid": [{ + "id": "bid-id-1", + "impid": "imp-id-1", + "price": 5.00, + "adm": "", + "crid": "creative-1", + "w": 300, + "h": 250 + }] + }], + "cur": "USD" + } + """))); + + // Mock appnexus bidder response + WIRE_MOCK_RULE.stubFor(WireMock.post(WireMock.urlPathEqualTo(APPNEXUS_EXCHANGE_PATH)) + .willReturn(WireMock.aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "id": "test-request-id", + "seatbid": [{ + "seat": "appnexus", + "bid": [{ + "id": "bid-id-2", + "impid": "imp-id-1", + "price": 3.50, + "adm": "", + "crid": "creative-2", + "w": 300, + "h": 250 + }] + }], + "cur": "USD" + } + """))); + } + + protected String createAuctionRequestWithMultipleBidders() throws JsonProcessingException { + final BidRequest bidRequest = BidRequest.builder() + .id("test-request-id") + .imp(Collections.singletonList(Imp.builder() + .id("imp-id-1") + .banner(Banner.builder() + .format(Collections.singletonList(Format.builder().w(300).h(250).build())) + .build()) + .ext(mapper.valueToTree(Map.of( + "prebid", Map.of( + "bidder", Map.of( + "generic", Map.of("exampleProperty", "test-value"), + "appnexus", Map.of("placementId", 12345) + ) + ) + ))) + .build())) + .site(Site.builder() + .page("http://example.com") + .publisher(Publisher.builder().id(TEST_ACCOUNT_ID).build()) + .build()) + .regs(Regs.builder() + .ext(ExtRegs.of(0, null, null, null)) + .build()) + .ext(ExtRequest.of(ExtRequestPrebid.builder() + .debug(1) + .build())) + .build(); + + return mapper.writeValueAsString(bidRequest); + } + + protected String createAuctionRequestWithExistingId5(String existingId5Value) throws JsonProcessingException { + final BidRequest bidRequest = BidRequest.builder() + .id("test-request-id") + .imp(Collections.singletonList(Imp.builder() + .id("imp-id-1") + .banner(Banner.builder() + .format(Collections.singletonList(Format.builder().w(300).h(250).build())) + .build()) + .ext(mapper.valueToTree(Map.of( + "prebid", Map.of( + "bidder", Map.of( + "generic", Map.of("exampleProperty", "test-value"), + "appnexus", Map.of("placementId", 12345) + ) + ) + ))) + .build())) + .site(Site.builder() + .page("http://example.com") + .publisher(Publisher.builder().id(TEST_ACCOUNT_ID).build()) + .build()) + .user(User.builder() + .eids(Collections.singletonList(Eid.builder() + .source("id5-sync.com") + .uids(Collections.singletonList(Uid.builder() + .id(existingId5Value) + .atype(1) + .build())) + .build())) + .build()) + .regs(Regs.builder() + .ext(ExtRegs.of(0, null, null, null)) + .build()) + .ext(ExtRequest.of(ExtRequestPrebid.builder() + .debug(1) + .build())) + .build(); + + return mapper.writeValueAsString(bidRequest); + } + + protected String createExpectedEidsJson(String id5Value) throws JsonProcessingException { + final Map expected = Map.of( + "user", Map.of( + "ext", Map.of( + "eids", Collections.singletonList(Eid.builder() + .source("id5-sync.com") + .uids(Collections.singletonList(Uid.builder() + .id(id5Value) + .atype(1) + .build())) + .inserter("prebid-server") + .build()) + ) + ) + ); + + return mapper.writeValueAsString(expected); + } + + protected String createExpectedEidsJsonWithoutInserter(String id5Value) throws JsonProcessingException { + final Map expected = Map.of( + "user", Map.of( + "ext", Map.of( + "eids", Collections.singletonList(Eid.builder() + .source("id5-sync.com") + .uids(Collections.singletonList(Uid.builder() + .id(id5Value) + .build())) + .build()) + ) + ) + ); + + return mapper.writeValueAsString(expected); + } + + protected String createBlockedAccountRequest() throws JsonProcessingException { + final BidRequest bidRequest = BidRequest.builder() + .id("test-request-id") + .imp(Collections.singletonList(Imp.builder() + .id("imp-id-1") + .banner(Banner.builder() + .format(Collections.singletonList(Format.builder().w(300).h(250).build())) + .build()) + .ext(mapper.valueToTree(Map.of( + "prebid", Map.of( + "bidder", Map.of( + "generic", Map.of("exampleProperty", "test-value"), + "appnexus", Map.of("placementId", 12345) + ) + ) + ))) + .build())) + .site(Site.builder() + .page("http://example.com") + .publisher(Publisher.builder().id("blocked-account").build()) + .build()) + .regs(Regs.builder() + .ext(ExtRegs.of(0, null, null, null)) + .build()) + .ext(ExtRequest.of(ExtRequestPrebid.builder() + .debug(1) + .build())) + .build(); + + return mapper.writeValueAsString(bidRequest); + } + + protected io.restassured.response.Response sendAuctionRequest(String requestBody) { + return io.restassured.RestAssured.given(SPEC) + .header("Content-Type", "application/json") + .body(requestBody) + .post("/openrtb2/auction"); + } + + protected void verifyId5FetchCalled(int expectedTimes) { + WIRE_MOCK_RULE.verify(expectedTimes, WireMock.postRequestedFor(WireMock.urlPathEqualTo(ID5_FETCH_PATH))); + } + + protected void verifyBidderReceivedRequestWithId5Eid(String exchangePath, String id5Value) + throws JsonProcessingException { + WIRE_MOCK_RULE.verify(1, WireMock.postRequestedFor(WireMock.urlPathEqualTo(exchangePath)) + .withRequestBody(WireMock.equalToJson(createExpectedEidsJson(id5Value), false, true))); + } + + protected void verifyBidderReceivedRequestWithoutId5Eid(String exchangePath) { + WIRE_MOCK_RULE.verify(1, WireMock.postRequestedFor(WireMock.urlPathEqualTo(exchangePath))); + final int callsWithId5 = WIRE_MOCK_RULE.findAll( + WireMock.postRequestedFor(WireMock.urlPathEqualTo(exchangePath)) + .withRequestBody(WireMock.matchingJsonPath("$.user.ext.eids[?(@.source == 'id5-sync.com')]")) + ).size(); + org.assertj.core.api.Assertions.assertThat(callsWithId5).isEqualTo(0); + } + + protected void verifyBidderReceivedRequestWithExistingId5(String exchangePath, String id5Value) + throws JsonProcessingException { + WIRE_MOCK_RULE.verify(1, WireMock.postRequestedFor(WireMock.urlPathEqualTo(exchangePath)) + .withRequestBody(WireMock.equalToJson( + createExpectedEidsJsonWithoutInserter(id5Value), false, true))); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/config/Id5UserIdModuleConfigurationTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/config/Id5UserIdModuleConfigurationTest.java new file mode 100644 index 00000000000..a615473827f --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/config/Id5UserIdModuleConfigurationTest.java @@ -0,0 +1,138 @@ +package org.prebid.server.hooks.modules.id5.userid.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdFetchHook; +import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdInjectHook; +import org.prebid.server.hooks.modules.id5.userid.v1.Id5IdModule; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.AccountFetchFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.CountryFetchFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.SamplingFetchFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.SelectedBidderFilter; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.util.VersionInfo; +import org.prebid.server.vertx.httpclient.HttpClient; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.time.Clock; + +import static org.assertj.core.api.Assertions.assertThat; + +class Id5UserIdModuleConfigurationTest { + + private ApplicationContextRunner contextRunner() { + final VersionInfo versionInfo = Mockito.mock(VersionInfo.class); + Mockito.when(versionInfo.getVersion()).thenReturn("1.2.3"); + + final HttpClient httpClient = Mockito.mock(HttpClient.class); + final JacksonMapper jacksonMapper = new JacksonMapper(new ObjectMapper()); + + return new ApplicationContextRunner() + .withBean(VersionInfo.class, () -> versionInfo) + .withBean(HttpClient.class, () -> httpClient) + .withBean(JacksonMapper.class, () -> jacksonMapper) + .withBean(Clock.class, Clock::systemUTC) + .withUserConfiguration(Id5UserIdModuleConfiguration.class); + } + + @Test + void shouldNotLoadConfigurationWhenModuleDisabled() { + contextRunner() + .withPropertyValues( + "hooks.id5-user-id.enabled=false", + "hooks.id5-user-id.partner=1", + "hooks.id5-user-id.provider-name=test-provider") + .run(context -> { + assertThat(context).doesNotHaveBean(Id5IdModule.class); + assertThat(context).doesNotHaveBean(Id5IdFetchHook.class); + assertThat(context).doesNotHaveBean(Id5IdInjectHook.class); + assertThat(context).doesNotHaveBean(SamplingFetchFilter.class); + assertThat(context).doesNotHaveBean(SelectedBidderFilter.class); + assertThat(context).doesNotHaveBean(AccountFetchFilter.class); + assertThat(context).doesNotHaveBean(CountryFetchFilter.class); + }); + } + + @Test + void shouldCreateMainBeansWhenEnabledWithoutFilters() { + contextRunner() + .withPropertyValues( + "hooks.id5-user-id.enabled=true", + "hooks.id5-user-id.partner=1", + "hooks.id5-user-id.provider-name=test-provider") + .run(context -> { + assertThat(context).hasSingleBean(Id5IdModule.class); + assertThat(context).hasSingleBean(Id5IdFetchHook.class); + assertThat(context).hasSingleBean(Id5IdInjectHook.class); + + assertThat(context).doesNotHaveBean(SamplingFetchFilter.class); + assertThat(context).doesNotHaveBean(SelectedBidderFilter.class); + assertThat(context).doesNotHaveBean(AccountFetchFilter.class); + assertThat(context).doesNotHaveBean(CountryFetchFilter.class); + }); + } + + @Test + void shouldCreateSamplingFetchFilterWhenSamplingRatePropertyPresent() { + contextRunner() + .withPropertyValues( + "hooks.id5-user-id.enabled=true", + "hooks.id5-user-id.partner=1", + "hooks.id5-user-id.provider-name=test-provider", + "hooks.id5-user-id.fetch-sampling-rate=0.5") + .run(context -> assertThat(context).hasSingleBean(SamplingFetchFilter.class)); + } + + @Test + void shouldCreateSelectedBidderFilterWhenBidderFilterValuesPresent() { + contextRunner() + .withPropertyValues( + "hooks.id5-user-id.enabled=true", + "hooks.id5-user-id.partner=1", + "hooks.id5-user-id.provider-name=test-provider", + "hooks.id5-user-id.bidder-filter.values=appnexus,rubicon") + .run(context -> assertThat(context).hasSingleBean(SelectedBidderFilter.class)); + } + + @Test + void shouldCreateAccountFetchFilterWhenAccountFilterValuesPresent() { + contextRunner() + .withPropertyValues( + "hooks.id5-user-id.enabled=true", + "hooks.id5-user-id.partner=1", + "hooks.id5-user-id.provider-name=test-provider", + "hooks.id5-user-id.account-filter.values=acc-1,acc-2") + .run(context -> assertThat(context).hasSingleBean(AccountFetchFilter.class)); + } + + @Test + void shouldCreateCountryFetchFilterWhenCountryFilterValuesPresent() { + contextRunner() + .withPropertyValues( + "hooks.id5-user-id.enabled=true", + "hooks.id5-user-id.partner=1", + "hooks.id5-user-id.provider-name=test-provider", + "hooks.id5-user-id.country-filter.values=US,PL") + .run(context -> assertThat(context).hasSingleBean(CountryFetchFilter.class)); + } + + @Test + void shouldCreateAllFiltersWhenAllPropertiesPresent() { + contextRunner() + .withPropertyValues( + "hooks.id5-user-id.enabled=true", + "hooks.id5-user-id.partner=1", + "hooks.id5-user-id.fetch-sampling-rate=1.0", + "hooks.id5-user-id.bidder-filter.values=appnexus", + "hooks.id5-user-id.account-filter.values=acc-1", + "hooks.id5-user-id.provider-name=test-provider", + "hooks.id5-user-id.country-filter.values=US") + .run(context -> { + assertThat(context).hasSingleBean(SamplingFetchFilter.class); + assertThat(context).hasSingleBean(SelectedBidderFilter.class); + assertThat(context).hasSingleBean(AccountFetchFilter.class); + assertThat(context).hasSingleBean(CountryFetchFilter.class); + }); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/fetch/HttpFetchClientTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/fetch/HttpFetchClientTest.java new file mode 100644 index 00000000000..dee2ab9b7ab --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/fetch/HttpFetchClientTest.java @@ -0,0 +1,435 @@ +package org.prebid.server.hooks.modules.id5.userid.fetch; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iab.openrtb.request.App; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.BidRequest.BidRequestBuilder; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.Publisher; +import com.iab.openrtb.request.Site; +import com.iab.openrtb.request.Uid; +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.execution.timeout.Timeout; +import org.prebid.server.execution.timeout.TimeoutFactory; +import org.prebid.server.hooks.execution.v1.InvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.config.Id5IdModuleProperties; +import org.prebid.server.hooks.modules.id5.userid.v1.fetch.HttpFetchClient; +import org.prebid.server.hooks.modules.id5.userid.v1.model.FetchResponse; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.privacy.ccpa.Ccpa; +import org.prebid.server.privacy.model.Privacy; +import org.prebid.server.privacy.model.PrivacyContext; +import org.prebid.server.proto.openrtb.ext.request.ExtDevice; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebidChannel; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebidData; +import org.prebid.server.settings.model.Account; +import org.prebid.server.util.VersionInfo; +import org.prebid.server.vertx.httpclient.HttpClient; +import org.prebid.server.vertx.httpclient.model.HttpClientResponse; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class HttpFetchClientTest { + + private static final String URL = "http://example.test/fetch"; + + private JacksonMapper mapper; + private HttpClient httpClient; + private VersionInfo versionInfo; + private Clock fixedClock; + private Id5IdModuleProperties props; + + @BeforeEach + void setUp() { + mapper = new JacksonMapper(new ObjectMapper()); + httpClient = Mockito.mock(HttpClient.class); + versionInfo = Mockito.mock(VersionInfo.class); + when(versionInfo.getVersion()).thenReturn("1.2.3"); + fixedClock = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC); + props = new Id5IdModuleProperties(); + props.setProviderName("pbs"); + } + + @Test + void shouldReturnEmptyOnNon200Response() { + // given + final long partnerId = 123L; + final String expectedUrl = URL + "/" + partnerId + ".json"; + when(httpClient.post(eq(expectedUrl), any(MultiMap.class), anyString(), anyLong())) + .thenReturn(Future.succeededFuture( + HttpClientResponse.of(503, MultiMap.caseInsensitiveMultiMap(), "oops")) + ); + + final HttpFetchClient client = new HttpFetchClient(URL, httpClient, mapper, fixedClock, versionInfo, props); + + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().id("r1").build()); + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext invocation = auctionInvocationContext(timeout, + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), false); + + // when + final Id5UserId result = client.fetch(partnerId, payload, invocation).result(); + + // then + assertThat(result.toEIDs()).isEmpty(); + } + + @Test + void shouldReturnEmptyOnException() { + // given + final long partnerId = 123L; + final String expectedUrl = URL + "/" + partnerId + ".json"; + when(httpClient.post(eq(expectedUrl), any(MultiMap.class), anyString(), anyLong())) + .thenReturn(Future.failedFuture(new RuntimeException("boom"))); + + final HttpFetchClient client = new HttpFetchClient(URL, httpClient, mapper, fixedClock, versionInfo, props); + + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().id("r1").build()); + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext invocation = auctionInvocationContext(timeout, + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), false); + + // when + final Id5UserId result = client.fetch(partnerId, payload, invocation).result(); + + // then + assertThat(result.toEIDs()).isEmpty(); + } + + @Test + void shouldParseSuccessfulResponse() { + // given + final Eid eid = Eid.builder() + .source("id5-sync.com") + .uids(List.of(Uid.builder().id("id5-xyz").build())) + .build(); + final FetchResponse response = new FetchResponse(java.util.Map.of("id5", new FetchResponse.UserId(eid))); + final String body = mapper.encodeToString(response); + final String expectedUrl123b = URL + "/123.json"; + when(httpClient.post(eq(expectedUrl123b), any(MultiMap.class), anyString(), anyLong())) + .thenReturn(Future.succeededFuture( + HttpClientResponse.of(200, MultiMap.caseInsensitiveMultiMap(), body)) + ); + + final HttpFetchClient client = new HttpFetchClient(URL, httpClient, mapper, + fixedClock, versionInfo, props); + + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(BidRequest.builder().id("r1").build()); + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext invocation = auctionInvocationContext(timeout, + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), false); + + // when + final Id5UserId result = client.fetch(123L, payload, invocation).result(); + + // then + assertThat(result.toEIDs()).hasSize(1); + assertThat(result.toEIDs().getFirst().getSource()).isEqualTo("id5-sync.com"); + } + + @Test + void shouldBuildRequestWithExpectedFieldsAndUseTimeout() { + // given + final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + final ArgumentCaptor headersCaptor = ArgumentCaptor.forClass(MultiMap.class); + final long remainingTime = 100L; + final Timeout timeout = mock(Timeout.class); + when(timeout.remaining()).thenReturn(remainingTime); + when(httpClient.post(anyString(), headersCaptor.capture(), bodyCaptor.capture(), anyLong())) + .thenReturn(Future.succeededFuture(HttpClientResponse.of(200, + MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null))))); + + final HttpFetchClient client = new HttpFetchClient(URL, httpClient, mapper, + fixedClock, versionInfo, props); + + final BidRequest bidRequest = BidRequest.builder() + .app(App.builder().bundle("com.example.app").build()) + .site(Site.builder().domain("example.com").ref("https://ref.example").build()) + .device(Device.builder() + .ifa("ifa-123") + .ua("UA/1.0") + .ip("203.0.113.10") + .ipv6("2001:0db8:85a3:0000:0000:8a2e:0370:7334") + .ext(ExtDevice.of(3, null)) + .build()) + .build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final Privacy privacy = Privacy.builder() + .gdpr("1") + .consentString("CONSENT_STRING") + .ccpa(Ccpa.of("1YNN")) + .coppa(1) + .gpp("GPP_STRING") + .gppSid(List.of(7, 8)) + .build(); + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder().id("acc-1").build()) + .privacyContext(PrivacyContext.of(privacy, org.prebid.server.privacy.gdpr.model.TcfContext.empty())) + .build(); + + final AuctionInvocationContext invocation = auctionInvocationContext( + timeout, + auctionContext, + false); + + // when + client.fetch(999L, payload, invocation).result(); + + // then + final MultiMap headers = headersCaptor.getValue(); + final String contentType = headers.get("Content-Type"); + assertThat(contentType).isEqualTo("application/json;charset=utf-8"); + + // then: request body + final String captured = bodyCaptor.getValue(); + final Map json = mapper.decodeValue(captured, new TypeReference<>() { + }); + assertThat(((Number) json.get("partner")).longValue()).isEqualTo(999L); + assertThat(json.get("version")).isEqualTo("1.2.3"); + assertThat(json.get("bundle")).isEqualTo("com.example.app"); + assertThat(json.get("domain")).isEqualTo("example.com"); + assertThat(json.get("maid")).isEqualTo("ifa-123"); + assertThat(json.get("ua")).isEqualTo("UA/1.0"); + assertThat(json.get("ref")).isEqualTo("https://ref.example"); + assertThat(json.get("ipv4")).isEqualTo("203.0.113.10"); + assertThat(json.get("ipv6")).isEqualTo("2001:0db8:85a3:0000:0000:8a2e:0370:7334"); + assertThat(json.get("att")).isEqualTo("3"); + assertThat(json.get("gdpr")).isEqualTo("1"); + assertThat(json.get("gdpr_consent")).isEqualTo("CONSENT_STRING"); + assertThat(json.get("us_privacy")).isEqualTo("1YNN"); + assertThat(json.get("coppa")).isEqualTo("1"); + assertThat(json.get("gpp_string")).isEqualTo("GPP_STRING"); + assertThat(json.get("gpp_sid")).isEqualTo("7,8"); + assertThat(json.get("origin")).isEqualTo("pbs-java"); + assertThat(json.get("provider")).isEqualTo("pbs"); + assertThat(json.get("ts")).isEqualTo("2025-01-01T00:00:00Z"); + assertThat(json.get("_trace")).isEqualTo(false); + + verify(httpClient, times(1)).post(eq(URL + "/999.json"), + any(MultiMap.class), anyString(), eq(remainingTime)); + } + + @Test + void shouldSetTraceWhenDebugEnabled() { + // given + final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + final Timeout timeout = mock(Timeout.class); + when(timeout.remaining()).thenReturn(100L); + when(httpClient.post(anyString(), any(MultiMap.class), bodyCaptor.capture(), anyLong())) + .thenReturn(Future.succeededFuture(HttpClientResponse.of(200, + MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null))))); + + final HttpFetchClient client = new HttpFetchClient(URL, httpClient, mapper, fixedClock, versionInfo, props); + + final AuctionInvocationContext invocation = auctionInvocationContext(timeout, + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), true); + + // when + client.fetch(999L, + AuctionRequestPayloadImpl.of(BidRequest.builder().id("r1").build()), invocation).result(); + + // then: __trace should be true + final Map json = mapper.decodeValue(bodyCaptor.getValue(), new TypeReference<>() { + }); + assertThat(json.get("_trace")).isEqualTo(true); + } + + @Test + void shouldHandleEmptyGppSidList() { + // given + final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + when(httpClient.post(anyString(), any(MultiMap.class), bodyCaptor.capture(), anyLong())) + .thenReturn(Future.succeededFuture(HttpClientResponse.of(200, + MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null))))); + + final HttpFetchClient client = new HttpFetchClient(URL, httpClient, mapper, + fixedClock, versionInfo, props); + + final Privacy privacy = Privacy.builder() + .gpp("GPP_STRING") + .gppSid(List.of()) // Empty list should result in null gpp_sid + .build(); + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder().id("acc-1").build()) + .privacyContext(PrivacyContext.of(privacy, org.prebid.server.privacy.gdpr.model.TcfContext.empty())) + .build(); + final AuctionInvocationContext invocation = auctionInvocationContext( + new TimeoutFactory(Clock.systemUTC()).create(1000), auctionContext, false); + + // when + client.fetch(999L, + AuctionRequestPayloadImpl.of(BidRequest.builder().id("r1").build()), invocation).result(); + + // then: gpp_sid should be null when list is empty (not empty string "") + final Map json = mapper.decodeValue(bodyCaptor.getValue(), new TypeReference<>() { + }); + assertThat(json.get("gpp_string")).isEqualTo("GPP_STRING"); + assertThat(json.get("gpp_sid")).isNull(); + } + + public static Stream publisherSources() { + + return Stream.of( + Arguments.of("site", + (BiConsumer) (rq, p) -> + rq.site(Site.builder() + .publisher(p) + .build())), + Arguments.of("app", + (BiConsumer) (rq1, p1) -> + rq1.app(App.builder() + .publisher(p1) + .build())) + ); + } + + @MethodSource("publisherSources") + @ParameterizedTest(name = "from {0}") + @SuppressWarnings("unchecked") + void shouldIncludePublisher(String ignore, BiConsumer publisherSetter) { + // given + final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + when(httpClient.post(anyString(), any(MultiMap.class), bodyCaptor.capture(), anyLong())) + .thenReturn(Future.succeededFuture(HttpClientResponse.of(200, + MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null))))); + + final HttpFetchClient client = new HttpFetchClient(URL, httpClient, mapper, + fixedClock, versionInfo, props); + + final BidRequestBuilder bidRequestBuilder = BidRequest.builder(); + publisherSetter.accept(bidRequestBuilder, Publisher.builder() + .id("pub-123") + .domain("pub.domain") + .name("Test Publisher") + .build()); + final BidRequest bidRequest = bidRequestBuilder.build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder().id("acc-1").build()) + .build(); + final AuctionInvocationContext invocation = auctionInvocationContext( + new TimeoutFactory(Clock.systemUTC()).create(1000), auctionContext, false); + + // when + client.fetch(999L, payload, invocation).result(); + + // then + final Map json = mapper.decodeValue(bodyCaptor.getValue(), new TypeReference<>() { + }); + final Map metadata = (Map) json.get("providerMetadata"); + + assertThat(metadata.get("publisher")).isNotNull(); + final Map publisher = (Map) metadata.get("publisher"); + assertThat(publisher.get("id")).isEqualTo("pub-123"); + assertThat(publisher.get("name")).isEqualTo("Test Publisher"); + assertThat(publisher.get("domain")).isEqualTo("pub.domain"); + } + + @Test + @SuppressWarnings("unchecked") + void shouldIncludeAllProviderMetadataFieldsWhenAllPresent() { + // given + final ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + when(httpClient.post(anyString(), any(MultiMap.class), bodyCaptor.capture(), anyLong())) + .thenReturn(Future.succeededFuture(HttpClientResponse.of(200, + MultiMap.caseInsensitiveMultiMap(), mapper.encodeToString(new FetchResponse(null))))); + + final Id5IdModuleProperties moduleProps = new Id5IdModuleProperties(); + moduleProps.setProviderName("comprehensive-provider"); + moduleProps.setPartner(789L); + + final HttpFetchClient client = new HttpFetchClient(URL, httpClient, mapper, + fixedClock, versionInfo, moduleProps); + + final BidRequest bidRequest = BidRequest.builder() + .app(App.builder() + .publisher(Publisher.builder() + .id("pub-789") + .name("Comprehensive Publisher") + .build()) + .build()) + .ext(ExtRequest.of(ExtRequestPrebid.builder() + .channel(ExtRequestPrebidChannel.of("mobile-app", "3.5")) + .data(ExtRequestPrebidData.of(List.of("bidder1", "bidder2"), null)) + .build())) + .build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder().id("acc-1").build()) + .build(); + final AuctionInvocationContext invocation = auctionInvocationContext( + new TimeoutFactory(Clock.systemUTC()).create(1000), auctionContext, false); + + // when + client.fetch(999L, payload, invocation).result(); + + // then + final Map json = mapper.decodeValue(bodyCaptor.getValue(), new TypeReference<>() { + }); + final Map metadata = (Map) json.get("providerMetadata"); + + // Verify all fields are present + assertThat(metadata.get("id5ModuleConfig")).isNotNull(); + assertThat(metadata.get("publisher")).isNotNull(); + assertThat(metadata.get("channel")).isEqualTo("mobile-app"); + assertThat(metadata.get("channelVersion")).isEqualTo("3.5"); + assertThat(metadata.get("bidders")).isNotNull(); + + final Map config = (Map) metadata.get("id5ModuleConfig"); + assertThat(config.get("providerName")).isEqualTo("comprehensive-provider"); + assertThat(((Number) config.get("partner")).longValue()).isEqualTo(789L); + + final Map publisher = (Map) metadata.get("publisher"); + assertThat(publisher.get("id")).isEqualTo("pub-789"); + + final List bidders = (List) metadata.get("bidders"); + assertThat(bidders).containsExactly("bidder1", "bidder2"); + } + + private static AuctionInvocationContextImpl auctionInvocationContext(Timeout timeout, + AuctionContext auctionContext, + boolean debugEnabled) { + return AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, + org.prebid.server.model.Endpoint.openrtb2_auction), + auctionContext, debugEnabled, null, null); + } + +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/BidRequestUtilsTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/BidRequestUtilsTest.java new file mode 100644 index 00000000000..33e619a8dfa --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/BidRequestUtilsTest.java @@ -0,0 +1,73 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.Uid; +import com.iab.openrtb.request.User; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class BidRequestUtilsTest { + + @Test + void shouldReturnFalseWhenUserIsNull() { + // given + final BidRequest bidRequest = BidRequest.builder().build(); + + // when & then + assertThat(BidRequestUtils.isId5IdPresent(bidRequest)).isFalse(); + } + + @Test + void shouldReturnFalseWhenEidsIsNull() { + // given + final User user = User.builder().build(); // eids null + final BidRequest bidRequest = BidRequest.builder().user(user).build(); + + // when & then + assertThat(BidRequestUtils.isId5IdPresent(bidRequest)).isFalse(); + } + + @Test + void shouldReturnFalseWhenOnlyOtherSourcesPresent() { + // given + final User user = User.builder() + .eids(List.of( + Eid.builder().source("other-source").uids(List.of(Uid.builder().id("x").build())).build())) + .build(); + final BidRequest bidRequest = BidRequest.builder().user(user).build(); + + // when & then + assertThat(BidRequestUtils.isId5IdPresent(bidRequest)).isFalse(); + } + + @Test + void shouldReturnTrueWhenId5SourcePresentAmongOthers() { + // given + final User user = User.builder() + .eids(List.of( + Eid.builder().source("other-source").uids(List.of(Uid.builder().id("x").build())).build(), + Eid.builder().source(BidRequestUtils.ID5_ID_SOURCE) + .uids(List.of(Uid.builder().id("id5-1").build())).build())) + .build(); + final BidRequest bidRequest = BidRequest.builder().user(user).build(); + + // when & then + assertThat(BidRequestUtils.isId5IdPresent(bidRequest)).isTrue(); + } + + @Test + void shouldReturnFalseWhenEidSourceIsNull() { + // given + final User user = User.builder() + .eids(List.of(Eid.builder().source(null).uids(List.of(Uid.builder().id("x").build())).build())) + .build(); + final BidRequest bidRequest = BidRequest.builder().user(user).build(); + + // when & then + assertThat(BidRequestUtils.isId5IdPresent(bidRequest)).isFalse(); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdFetchHookTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdFetchHookTest.java new file mode 100644 index 00000000000..9bcd6578f58 --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdFetchHookTest.java @@ -0,0 +1,225 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.Uid; +import com.iab.openrtb.request.User; +import io.vertx.core.Future; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.execution.timeout.Timeout; +import org.prebid.server.execution.timeout.TimeoutFactory; +import org.prebid.server.hooks.execution.v1.InvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.fetch.FetchClient; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.FetchActionFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.FilterResult; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5PartnerIdProvider; +import org.prebid.server.hooks.v1.InvocationAction; +import org.prebid.server.hooks.v1.InvocationResult; +import org.prebid.server.hooks.v1.InvocationStatus; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.model.Endpoint; +import org.prebid.server.settings.model.Account; + +import java.time.Clock; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; + +class Id5IdFetchHookTest { + + @Test + void shouldReturnNoInvocationAndSetModuleContextWithFutureWhenSampled() { + // given + final FetchClient fetchClient = Mockito.mock(FetchClient.class); + final FetchActionFilter filter = Mockito.mock(FetchActionFilter.class); + final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class); + final Future future = Future.succeededFuture(Id5UserId.empty()); + when(fetchClient.fetch(anyLong(), any(AuctionRequestPayload.class), any())).thenReturn(future); + when(filter.shouldInvoke(any(AuctionRequestPayload.class), any())).thenReturn(FilterResult.accepted()); + when(partnerIdProvider.getPartnerId(any())).thenReturn(Optional.of(123L)); + + final Id5IdFetchHook hook = new Id5IdFetchHook(fetchClient, List.of(filter), partnerIdProvider); + + final BidRequest bidRequest = BidRequest.builder().id("req-1").build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final AuctionInvocationContext invocation = createAuctionContext(); + + // when + final Future> resultFuture = hook.call(payload, invocation); + final InvocationResult result = resultFuture.result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_action); + assertThat(result.moduleContext()).isInstanceOf(Id5IdModuleContext.class); + final Id5IdModuleContext ctx = (Id5IdModuleContext) result.moduleContext(); + assertThat(ctx.getId5UserIdFuture()).isSameAs(future); + } + + @Test + void shouldReturnNoInvocationWhenId5IdAlreadyPresent() { + // given + final FetchClient fetchClient = Mockito.mock(FetchClient.class); + final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class); + final Id5IdFetchHook hook = new Id5IdFetchHook(fetchClient, List.of(), partnerIdProvider); + + final User userWithId5 = User.builder() + .eids(List.of(Eid.builder() + .source("id5-sync.com") + .uids(List.of(Uid.builder().id("id5-xyz").build())) + .build())) + .build(); + final BidRequest bidRequest = BidRequest.builder().user(userWithId5).build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final AuctionInvocationContext invocation = createAuctionContext(); + + // when + final InvocationResult result = hook.call(payload, invocation).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.moduleContext()).isNull(); + } + + @Test + void shouldReturnNoInvocationWhenSamplerRejects() { + // given + final FetchClient fetchClient = Mockito.mock(FetchClient.class); + final FetchActionFilter filter = Mockito.mock(FetchActionFilter.class); + final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class); + when(filter.shouldInvoke(any(AuctionRequestPayload.class), any())) + .thenReturn(FilterResult.rejected("rejected by sampling")); + + final Id5IdFetchHook hook = new Id5IdFetchHook(fetchClient, List.of(filter), partnerIdProvider); + + final BidRequest bidRequest = BidRequest.builder().id("req-2").build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final AuctionInvocationContext invocation = createAuctionContext(); + + // when + final InvocationResult result = hook.call(payload, invocation).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.moduleContext()).isNull(); + } + + @Test + void shouldReturnNoInvocationWhenAnyFetchFilterRejectsMultipleFilters() { + // given + final FetchClient fetchClient = Mockito.mock(FetchClient.class); + final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class); + final FetchActionFilter accept1 = Mockito.mock(FetchActionFilter.class); + final FetchActionFilter reject = Mockito.mock(FetchActionFilter.class); + final FetchActionFilter accept2 = Mockito.mock(FetchActionFilter.class); + + when(accept1.shouldInvoke(any(AuctionRequestPayload.class), any())) + .thenReturn(FilterResult.accepted()); + when(reject.shouldInvoke(any(AuctionRequestPayload.class), any())) + .thenReturn(FilterResult.rejected("block-by-second")); + when(accept2.shouldInvoke(any(AuctionRequestPayload.class), any())) + .thenReturn(FilterResult.accepted()); + + final Id5IdFetchHook hook = new Id5IdFetchHook( + fetchClient, List.of(accept1, reject, accept2), partnerIdProvider); + + final BidRequest bidRequest = BidRequest.builder().id("req-3").build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final AuctionInvocationContext invocation = createAuctionContext(); + + // when + final InvocationResult result = hook.call(payload, invocation).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.moduleContext()).isNull(); + // ensure fetch client was not called after filter rejection + Mockito.verifyNoInteractions(fetchClient); + // reason from rejecting filter should be present in debug messages + assertThat(result.debugMessages()).anyMatch(m -> m.contains("block-by-second")); + } + + @Test + void shouldReturnNoInvocationWhenPartnerIdNotConfigured() { + // given + final FetchClient fetchClient = Mockito.mock(FetchClient.class); + final FetchActionFilter filter = Mockito.mock(FetchActionFilter.class); + final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class); + when(filter.shouldInvoke(any(AuctionRequestPayload.class), any())).thenReturn(FilterResult.accepted()); + when(partnerIdProvider.getPartnerId(any())).thenReturn(Optional.empty()); + + final Id5IdFetchHook hook = new Id5IdFetchHook(fetchClient, List.of(filter), partnerIdProvider); + + final BidRequest bidRequest = BidRequest.builder().id("req-5").build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final AuctionInvocationContext invocation = createAuctionContext(); + + // when + final InvocationResult result = hook.call(payload, invocation).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.moduleContext()).isNull(); + assertThat(result.debugMessages()).anyMatch(m -> m.contains("partner id not configured")); + Mockito.verifyNoInteractions(fetchClient); + } + + @Test + void shouldReturnFailureWhenExceptionOccurs() { + // given + final FetchClient fetchClient = Mockito.mock(FetchClient.class); + final FetchActionFilter filter = Mockito.mock(FetchActionFilter.class); + final Id5PartnerIdProvider partnerIdProvider = Mockito.mock(Id5PartnerIdProvider.class); + + when(filter.shouldInvoke(any(AuctionRequestPayload.class), any())) + .thenReturn(FilterResult.accepted()); + when(partnerIdProvider.getPartnerId(any())).thenReturn(Optional.of(123L)); + when(fetchClient.fetch(anyLong(), any(AuctionRequestPayload.class), any())) + .thenThrow(new RuntimeException("Fetch client error")); + + final Id5IdFetchHook hook = new Id5IdFetchHook(fetchClient, List.of(filter), partnerIdProvider); + + final BidRequest bidRequest = BidRequest.builder().id("req-4").build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + final AuctionInvocationContext invocation = createAuctionContext(); + + // when + final InvocationResult result = hook.call(payload, invocation).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.failure); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.errors()).isNotEmpty(); + assertThat(result.errors().getFirst()).contains("Fetch client error"); + } + + private static AuctionInvocationContextImpl createAuctionContext() { + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + return AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + null + ); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdInjectHookTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdInjectHookTest.java new file mode 100644 index 00000000000..baa769c6731 --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdInjectHookTest.java @@ -0,0 +1,281 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.Uid; +import com.iab.openrtb.request.User; +import io.vertx.core.Future; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.execution.timeout.Timeout; +import org.prebid.server.execution.timeout.TimeoutFactory; +import org.prebid.server.hooks.execution.v1.InvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.bidder.BidderInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.bidder.BidderRequestPayloadImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.FilterResult; +import org.prebid.server.hooks.modules.id5.userid.v1.filter.InjectActionFilter; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.v1.InvocationAction; +import org.prebid.server.hooks.v1.InvocationResult; +import org.prebid.server.hooks.v1.InvocationStatus; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.bidder.BidderInvocationContext; +import org.prebid.server.hooks.v1.bidder.BidderRequestPayload; +import org.prebid.server.model.Endpoint; +import org.prebid.server.settings.model.Account; + +import java.time.Clock; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; + +class Id5IdInjectHookTest { + + private BidderInvocationContextImpl bidderCtxWithEmptyIds() { + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + // provide module context with empty Ids future to ensure fetch path would continue if not filtered + new Id5IdModuleContext(Future.succeededFuture(Id5UserId.empty()))); + return BidderInvocationContextImpl.of(auctionCtx, "appnexus"); + } + + @Test + void shouldSkipWhenId5EidAlreadyPresent() { + // given + final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX"); + + final User userWithId5 = User.builder() + .eids(List.of(Eid.builder() + .source("id5-sync.com") + .uids(List.of(Uid.builder().id("abc").build())) + .build())) + .build(); + final BidRequest bidRequest = BidRequest.builder().user(userWithId5).build(); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final Id5IdModuleContext expectedContext = new Id5IdModuleContext(Future.succeededFuture(Id5UserId.empty())); + final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + expectedContext); + final BidderInvocationContext bidderCtx = BidderInvocationContextImpl.of(auctionCtx, "appnexus"); + + // when + final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest), + bidderCtx).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.moduleContext()).isEqualTo(expectedContext); + } + + @Test + void shouldSkipWhenNoTimeLeft() { + // given + final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX"); + + final BidRequest bidRequest = BidRequest.builder().user(User.builder().build()).build(); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1).minus(10_000); + final Id5IdModuleContext expectedContext = new Id5IdModuleContext(Future.succeededFuture(Id5UserId.empty())); + final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + expectedContext); + final BidderInvocationContext bidderCtx = BidderInvocationContextImpl.of(auctionCtx, "appnexus"); + + // when + final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest), + bidderCtx).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.moduleContext()).isEqualTo(expectedContext); + } + + @Test + void shouldSkipWhenFetcherReturnsEmpty() { + // given + final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX"); + + final BidRequest bidRequest = BidRequest.builder().user(User.builder().build()).build(); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final Id5IdModuleContext expectedContext = new Id5IdModuleContext(Future.succeededFuture(Id5UserId.empty())); + final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + expectedContext); + final BidderInvocationContext bidderCtx = BidderInvocationContextImpl.of(auctionCtx, "appnexus"); + + // when + final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest), + bidderCtx).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_action); + assertThat(result.moduleContext()).isEqualTo(expectedContext); + } + + @Test + void shouldInjectEidsWhenFetcherReturnsIds() { + // given + final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX"); + + final BidRequest bidRequest = BidRequest.builder().user(User.builder().eids(List.of()).build()).build(); + + final Id5UserId id5 = () -> List.of( + Eid.builder() + .source("id5-sync.com") + .uids(List.of(Uid.builder().id("id5-123").build())) + .build()); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final Id5IdModuleContext expectedContext = new Id5IdModuleContext(Future.succeededFuture(id5)); + final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + expectedContext); + final BidderInvocationContext bidderCtx = BidderInvocationContextImpl.of(auctionCtx, "appnexus"); + + // when + final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest), + bidderCtx) + .toCompletionStage().toCompletableFuture().join(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.update); + assertThat(result.moduleContext()).isEqualTo(expectedContext); + + final BidderRequestPayload updated = result.payloadUpdate().apply(BidderRequestPayloadImpl.of(bidRequest)); + assertThat(updated.bidRequest().getUser().getEids()).hasSize(1); + final Eid eid = updated.bidRequest().getUser().getEids().getFirst(); + assertThat(eid.getSource()).isEqualTo("id5-sync.com"); + assertThat(eid.getInserter()).isEqualTo("inserterX"); + } + + @Test + void shouldReturnNoActionWhenNoModuleContextPresent() { + // given + final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX"); + + final BidRequest bidRequest = BidRequest.builder().user(User.builder().build()).build(); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + null // no Id5IdModuleContext provided + ); + final BidderInvocationContext bidderCtx = BidderInvocationContextImpl.of(auctionCtx, "appnexus"); + + // when + final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest), + bidderCtx).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_action); + assertThat(result.payloadUpdate()).isNull(); + assertThat(result.moduleContext()).isNull(); // nothing to propagate + } + + @Test + void shouldReturnNoInvocationWhenInjectFilterRejectsSingleFilter() { + // given + final InjectActionFilter filter = Mockito.mock(InjectActionFilter.class); + Mockito.when(filter.shouldInvoke(any(), any())).thenReturn(FilterResult.rejected("reject-by-filter")); + + final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX", List.of(filter)); + + final BidRequest bidRequest = BidRequest.builder().build(); + final BidderInvocationContext bidderCtx = bidderCtxWithEmptyIds(); + + // when + final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest), + bidderCtx).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.payloadUpdate()).isNull(); + assertThat(result.debugMessages()).anyMatch(m -> m.contains("reject-by-filter")); + assertThat(result.moduleContext()).isNotNull(); + assertThat(result.moduleContext()).isInstanceOf(Id5IdModuleContext.class); + assertThat(result.moduleContext()).isEqualTo(bidderCtx.moduleContext()); + } + + @Test + void shouldReturnNoInvocationWhenAnyInjectFilterRejectsMultipleFilters() { + // given + final InjectActionFilter accept1 = Mockito.mock(InjectActionFilter.class); + final InjectActionFilter reject = Mockito.mock(InjectActionFilter.class); + final InjectActionFilter accept2 = Mockito.mock(InjectActionFilter.class); + Mockito.when(accept1.shouldInvoke(any(), any())).thenReturn(FilterResult.accepted()); + Mockito.when(reject.shouldInvoke(any(), any())).thenReturn(FilterResult.rejected("block-by-second")); + Mockito.when(accept2.shouldInvoke(any(), any())).thenReturn(FilterResult.accepted()); + + final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX", List.of(accept1, reject, accept2)); + + final BidRequest bidRequest = BidRequest.builder().build(); + final BidderInvocationContext bidderCtx = bidderCtxWithEmptyIds(); + + // when + final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest), + bidderCtx).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.success); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.payloadUpdate()).isNull(); + assertThat(result.debugMessages()).anyMatch(m -> m.contains("block-by-second")); + assertThat(result.moduleContext()).isNotNull(); + assertThat(result.moduleContext()).isInstanceOf(Id5IdModuleContext.class); + assertThat(result.moduleContext()).isEqualTo(bidderCtx.moduleContext()); + } + + @Test + void shouldReturnFailureWhenExceptionOccurs() { + // given + final InjectActionFilter filter = Mockito.mock(InjectActionFilter.class); + Mockito.when(filter.shouldInvoke(any(), any())) + .thenThrow(new RuntimeException("Filter processing error")); + + final Id5IdInjectHook hook = new Id5IdInjectHook("inserterX", List.of(filter)); + + final BidRequest bidRequest = BidRequest.builder().build(); + final BidderInvocationContextImpl invocationContext = bidderCtxWithEmptyIds(); + + // when + final InvocationResult result = hook.call(BidderRequestPayloadImpl.of(bidRequest), + invocationContext).result(); + + // then + assertThat(result.status()).isEqualTo(InvocationStatus.failure); + assertThat(result.action()).isEqualTo(InvocationAction.no_invocation); + assertThat(result.moduleContext()).isEqualTo(invocationContext.moduleContext()); + assertThat(result.errors()).isNotEmpty(); + assertThat(result.errors().getFirst()).contains("Filter processing error"); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModuleContextTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModuleContextTest.java new file mode 100644 index 00000000000..207b57624ea --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/Id5IdModuleContextTest.java @@ -0,0 +1,63 @@ +package org.prebid.server.hooks.modules.id5.userid.v1; + +import io.vertx.core.Future; +import org.junit.jupiter.api.Test; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.execution.timeout.Timeout; +import org.prebid.server.execution.timeout.TimeoutFactory; +import org.prebid.server.hooks.execution.v1.InvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.model.Id5UserId; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.model.Endpoint; +import org.prebid.server.settings.model.Account; + +import java.time.Clock; + +import static org.assertj.core.api.Assertions.assertThat; + +class Id5IdModuleContextTest { + + @Test + void shouldReturnProvidedModuleContextWhenPresent() { + // given + final Future future = Future.succeededFuture(); + final Id5IdModuleContext moduleContext = new Id5IdModuleContext(future); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + moduleContext); + + // when + final Id5IdModuleContext result = Id5IdModuleContext.from(invocation); + + // then + assertThat(result).isSameAs(moduleContext); + assertThat(result.getId5UserIdFuture()).isSameAs(future); + } + + @Test + void shouldReturnEmptyModuleContextWhenAbsent() { + // given + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + null); + + // when + final Id5IdModuleContext result = Id5IdModuleContext.from(invocation); + + // then + assertThat(result).isNotNull(); + assertThat(result.getId5UserIdFuture()).isNotNull(); + assertThat(result.getId5UserIdFuture().succeeded()).isTrue(); + assertThat(result.getId5UserIdFuture().result()).isNull(); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/config/Id5IdModulePropertiesTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/config/Id5IdModulePropertiesTest.java new file mode 100644 index 00000000000..d679e50b2a7 --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/config/Id5IdModulePropertiesTest.java @@ -0,0 +1,95 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +class Id5IdModulePropertiesTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withUserConfiguration(TestPropsConfig.class); + + @EnableConfigurationProperties(Id5IdModuleProperties.class) + static class TestPropsConfig { } + + @Test + void shouldHaveDefaultFetchEndpoint() { + contextRunner + .withPropertyValues( + "hooks.id5-user-id.partner=123", + "hooks.id5-user-id.provider-name=test-provider") + .run(ctx -> { + final Id5IdModuleProperties props = ctx.getBean(Id5IdModuleProperties.class); + assertThat(props.getFetchEndpoint()).isEqualTo("https://api.id5-sync.com/gs/v2"); + }); + } + + @Test + void shouldBindBidderFilterValuesWithDefaultExcludeFalse() { + contextRunner + .withPropertyValues( + "hooks.id5-user-id.partner=123", + "hooks.id5-user-id.provider-name=test-provider", + "hooks.id5-user-id.bidder-filter.values=appnexus,rubicon") + .run(ctx -> { + final Id5IdModuleProperties props = ctx.getBean(Id5IdModuleProperties.class); + final ValuesFilter filter = props.getBidderFilter(); + + assertThat(filter).isNotNull(); + assertThat(filter.isExclude()).isFalse(); // default + assertThat(filter.getValues()).containsExactlyInAnyOrder("appnexus", "rubicon"); + }); + } + + @Test + void shouldBindAccountFilterWithExcludeTrue() { + contextRunner + .withPropertyValues( + "hooks.id5-user-id.partner=123", + "hooks.id5-user-id.provider-name=test-provider", + "hooks.id5-user-id.account-filter.exclude=true", + "hooks.id5-user-id.account-filter.values=acc-1,acc-2") + .run(ctx -> { + final Id5IdModuleProperties props = ctx.getBean(Id5IdModuleProperties.class); + final ValuesFilter filter = props.getAccountFilter(); + + assertThat(filter).isNotNull(); + assertThat(filter.isExclude()).isTrue(); + assertThat(filter.getValues()).containsExactlyInAnyOrder("acc-1", "acc-2"); + }); + } + + @Test + void shouldBindCountryFilterWhenOnlyExcludeProvidedValuesRemainNull() { + contextRunner + .withPropertyValues( + "hooks.id5-user-id.partner=123", + "hooks.id5-user-id.provider-name=test-provider", + "hooks.id5-user-id.country-filter.exclude=true") + .run(ctx -> { + final Id5IdModuleProperties props = ctx.getBean(Id5IdModuleProperties.class); + final ValuesFilter filter = props.getCountryFilter(); + + assertThat(filter).isNotNull(); + assertThat(filter.isExclude()).isTrue(); + assertThat(filter.getValues()).isNull(); + }); + } + + @Test + void shouldNotCreateFiltersWhenNotProvided() { + contextRunner + .withPropertyValues( + "hooks.id5-user-id.partner=123", + "hooks.id5-user-id.provider-name=test-provider") + .run(ctx -> { + final Id5IdModuleProperties props = ctx.getBean(Id5IdModuleProperties.class); + + assertThat(props.getBidderFilter()).isNull(); + assertThat(props.getAccountFilter()).isNull(); + assertThat(props.getCountryFilter()).isNull(); + }); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/config/ValuesFilterTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/config/ValuesFilterTest.java new file mode 100644 index 00000000000..c2fdf5f07bd --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/config/ValuesFilterTest.java @@ -0,0 +1,62 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.config; + +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class ValuesFilterTest { + + @Test + void shouldAllowAllWhenValuesNull() { + // given + final ValuesFilter filter = new ValuesFilter<>(); + filter.setValues(null); + filter.setExclude(false); // default include mode + + // expect + assertThat(filter.isValueAllowed("anything")).isTrue(); + assertThat(filter.isValueAllowed(null)).isTrue(); + } + + @Test + void shouldAllowAllWhenValuesEmpty() { + // given + final ValuesFilter filter = new ValuesFilter<>(); + filter.setValues(Set.of()); + filter.setExclude(false); + + // expect + assertThat(filter.isValueAllowed("x")).isTrue(); + assertThat(filter.isValueAllowed("y")).isTrue(); + } + + @Test + void shouldWhitelistAllowOnlyListedWhenExcludeFalse() { + // given + final ValuesFilter filter = new ValuesFilter<>(); + filter.setExclude(false); // allowlist semantics + filter.setValues(Set.of("a", "b")); + + // expect + assertThat(filter.isValueAllowed("a")).isTrue(); + assertThat(filter.isValueAllowed("b")).isTrue(); + assertThat(filter.isValueAllowed("c")).isFalse(); + assertThat(filter.isValueAllowed(null)).isFalse(); + } + + @Test + void shouldBlacklistRejectListedWhenExcludeTrue() { + // given + final ValuesFilter filter = new ValuesFilter<>(); + filter.setExclude(true); // blocklist semantics + filter.setValues(Set.of("blocked", "forbidden")); + + // expect + assertThat(filter.isValueAllowed("blocked")).isFalse(); + assertThat(filter.isValueAllowed("forbidden")).isFalse(); + assertThat(filter.isValueAllowed("other")).isTrue(); + assertThat(filter.isValueAllowed(null)).isFalse(); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/AccountFetchFilterTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/AccountFetchFilterTest.java new file mode 100644 index 00000000000..22455bcf7e6 --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/AccountFetchFilterTest.java @@ -0,0 +1,83 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.execution.timeout.Timeout; +import org.prebid.server.hooks.execution.v1.InvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.config.ValuesFilter; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.model.Endpoint; +import org.prebid.server.settings.model.Account; +import org.prebid.server.execution.timeout.TimeoutFactory; + +import java.time.Clock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +class AccountFetchFilterTest { + + @Test + void shouldAcceptWhenAccountAllowed() { + final ValuesFilter valuesFilter = Mockito.mock(ValuesFilter.class); + when(valuesFilter.isValueAllowed(eq("acc-2"))).thenReturn(true); + final AccountFetchFilter filter = new AccountFetchFilter(valuesFilter); + + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(null); + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext ctx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc-2").build()).build(), + false, + null, + null); + + final FilterResult result = filter.shouldInvoke(payload, ctx); + assertThat(result.isAccepted()).isTrue(); + } + + @Test + void shouldRejectWhenAccountNotAllowed() { + final ValuesFilter valuesFilter = Mockito.mock(ValuesFilter.class); + when(valuesFilter.isValueAllowed(eq("acc-3"))).thenReturn(false); + final AccountFetchFilter filter = new AccountFetchFilter(valuesFilter); + + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(null); + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext ctx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc-3").build()).build(), + false, + null, + null); + + final FilterResult result = filter.shouldInvoke(payload, ctx); + assertThat(result.isAccepted()).isFalse(); + assertThat(result.reason()).contains("account acc-3 rejected"); + } + + @Test + void shouldRejectWhenAccountMissing() { + final ValuesFilter valuesFilter = Mockito.mock(ValuesFilter.class); + final AccountFetchFilter filter = new AccountFetchFilter(valuesFilter); + + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(null); + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext ctx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().build(), + false, + null, + null); + + final FilterResult result = filter.shouldInvoke(payload, ctx); + assertThat(result.isAccepted()).isFalse(); + assertThat(result.reason()).contains("missing account id"); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/CountryFetchFilterTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/CountryFetchFilterTest.java new file mode 100644 index 00000000000..1f756d2bb3f --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/CountryFetchFilterTest.java @@ -0,0 +1,132 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Geo; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.execution.timeout.Timeout; +import org.prebid.server.geolocation.model.GeoInfo; +import org.prebid.server.hooks.execution.v1.InvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.config.ValuesFilter; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.model.Endpoint; +import org.prebid.server.settings.model.Account; +import org.prebid.server.execution.timeout.TimeoutFactory; + +import java.time.Clock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +class CountryFetchFilterTest { + + @Test + void shouldUseGeoInfoCountryFirst() { + final ValuesFilter vf = Mockito.mock(ValuesFilter.class); + when(vf.isValueAllowed("PL")).thenReturn(true); + final CountryFetchFilter filter = new CountryFetchFilter(vf); + + final BidRequest bidRequest = BidRequest.builder() + .device(Device.builder().geo(Geo.builder().country("US").build()).build()) + .build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder().id("acc").build()) + .geoInfo(GeoInfo.builder().vendor("test").country("PL").build()) + .build(); + final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + auctionContext, + false, + null, + null); + + final FilterResult result = filter.shouldInvoke(payload, invocation); + assertThat(result.isAccepted()).isTrue(); + } + + @Test + void shouldFallbackToDeviceGeoCountry() { + final ValuesFilter vf = Mockito.mock(ValuesFilter.class); + when(vf.isValueAllowed("US")).thenReturn(true); + final CountryFetchFilter filter = new CountryFetchFilter(vf); + + final BidRequest bidRequest = BidRequest.builder() + .device(Device.builder().geo(Geo.builder().country("US").build()).build()) + .build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder().id("acc").build()) + .build(); + final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + auctionContext, + false, + null, + null); + + final FilterResult result = filter.shouldInvoke(payload, invocation); + assertThat(result.isAccepted()).isTrue(); + } + + @Test + void shouldRejectWhenCountryMissing() { + final ValuesFilter vf = Mockito.mock(ValuesFilter.class); + final CountryFetchFilter filter = new CountryFetchFilter(vf); + + final BidRequest bidRequest = BidRequest.builder().build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder().id("acc").build()) + .build(); + final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + auctionContext, + false, + null, + null); + + final FilterResult result = filter.shouldInvoke(payload, invocation); + assertThat(result.isAccepted()).isFalse(); + assertThat(result.reason()).contains("missing country"); + } + + @Test + void shouldRejectWhenCountryNotAllowed() { + final ValuesFilter vf = Mockito.mock(ValuesFilter.class); + when(vf.isValueAllowed("US")).thenReturn(false); + final CountryFetchFilter filter = new CountryFetchFilter(vf); + + final BidRequest bidRequest = BidRequest.builder() + .device(Device.builder().geo(Geo.builder().country("US").build()).build()) + .build(); + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(bidRequest); + + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionContext auctionContext = AuctionContext.builder() + .account(Account.builder().id("acc").build()) + .build(); + final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + auctionContext, + false, + null, + null); + + final FilterResult result = filter.shouldInvoke(payload, invocation); + assertThat(result.isAccepted()).isFalse(); + assertThat(result.reason()).contains("country US rejected"); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SamplingFetchFilterTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SamplingFetchFilterTest.java new file mode 100644 index 00000000000..e739f81ef3a --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SamplingFetchFilterTest.java @@ -0,0 +1,76 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import org.junit.jupiter.api.Test; +import org.prebid.server.execution.timeout.Timeout; +import org.prebid.server.hooks.execution.v1.InvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionRequestPayloadImpl; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.execution.timeout.TimeoutFactory; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.hooks.v1.auction.AuctionRequestPayload; +import org.prebid.server.model.Endpoint; +import org.prebid.server.settings.model.Account; + +import java.time.Clock; +import java.util.Random; + +import static org.assertj.core.api.Assertions.assertThat; + +class SamplingFetchFilterTest { + + @Test + void shouldAcceptWhenRandomBelowRate() { + // given + final Random random = new Random() { + @Override + public double nextDouble() { + return 0.1d; + } + }; + final SamplingFetchFilter filter = new SamplingFetchFilter(random, 0.25d); + + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(null); + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + null); + + // when + final FilterResult result = filter.shouldInvoke(payload, invocation); + + // then + assertThat(result.isAccepted()).isTrue(); + } + + @Test + void shouldRejectWhenRandomAboveRate() { + // given + final Random random = new Random() { + @Override + public double nextDouble() { + return 0.9d; + } + }; + final SamplingFetchFilter filter = new SamplingFetchFilter(random, 0.5d); + + final AuctionRequestPayload payload = AuctionRequestPayloadImpl.of(null); + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext invocation = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + null); + + // when + final FilterResult result = filter.shouldInvoke(payload, invocation); + + // then + assertThat(result.isAccepted()).isFalse(); + assertThat(result.reason()).contains("sampling"); + } +} diff --git a/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SelectedBidderFilterTest.java b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SelectedBidderFilterTest.java new file mode 100644 index 00000000000..be922e50d33 --- /dev/null +++ b/extra/modules/id5-user-id/src/test/java/org/prebid/server/hooks/modules/id5/userid/v1/filter/SelectedBidderFilterTest.java @@ -0,0 +1,67 @@ +package org.prebid.server.hooks.modules.id5.userid.v1.filter; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.prebid.server.execution.timeout.Timeout; +import org.prebid.server.hooks.execution.v1.InvocationContextImpl; +import org.prebid.server.hooks.execution.v1.auction.AuctionInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.bidder.BidderInvocationContextImpl; +import org.prebid.server.hooks.execution.v1.bidder.BidderRequestPayloadImpl; +import org.prebid.server.hooks.modules.id5.userid.v1.config.ValuesFilter; +import org.prebid.server.hooks.v1.auction.AuctionInvocationContext; +import org.prebid.server.model.Endpoint; +import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.settings.model.Account; +import org.prebid.server.execution.timeout.TimeoutFactory; + +import java.time.Clock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@SuppressWarnings("unchecked") +class SelectedBidderFilterTest { + + private BidderInvocationContextImpl bidderCtx(String bidder) { + final Timeout timeout = new TimeoutFactory(Clock.systemUTC()).create(1000); + final AuctionInvocationContext auctionCtx = AuctionInvocationContextImpl.of( + InvocationContextImpl.of(timeout, Endpoint.openrtb2_auction), + AuctionContext.builder().account(Account.builder().id("acc").build()).build(), + false, + null, + null); + return BidderInvocationContextImpl.of(auctionCtx, bidder); + } + + @Test + void shouldAcceptWhenBidderAllowed() { + final ValuesFilter vf = Mockito.mock(ValuesFilter.class); + when(vf.isValueAllowed(any())).thenReturn(true); + final SelectedBidderFilter filter = new SelectedBidderFilter(vf); + + final FilterResult result = filter.shouldInvoke(BidderRequestPayloadImpl.of(null), bidderCtx("rubicon")); + assertThat(result.isAccepted()).isTrue(); + } + + @Test + void shouldRejectWhenBidderNotAllowed() { + final ValuesFilter vf = Mockito.mock(ValuesFilter.class); + when(vf.isValueAllowed(any())).thenReturn(false); + final SelectedBidderFilter filter = new SelectedBidderFilter(vf); + + final FilterResult result = filter.shouldInvoke(BidderRequestPayloadImpl.of(null), bidderCtx("pubmatic")); + assertThat(result.isAccepted()).isFalse(); + assertThat(result.reason()).contains("bidder pubmatic rejected"); + } + + @Test + void shouldDelegateDecisionToValuesFilter() { + final ValuesFilter vf = Mockito.mock(ValuesFilter.class); + when(vf.isValueAllowed("anything")).thenReturn(true); + final SelectedBidderFilter filter = new SelectedBidderFilter(vf); + + final FilterResult result = filter.shouldInvoke(BidderRequestPayloadImpl.of(null), bidderCtx("anything")); + assertThat(result.isAccepted()).isTrue(); + } +} diff --git a/extra/modules/id5-user-id/src/test/resources/test-app-settings.yaml b/extra/modules/id5-user-id/src/test/resources/test-app-settings.yaml new file mode 100644 index 00000000000..f65476d9af5 --- /dev/null +++ b/extra/modules/id5-user-id/src/test/resources/test-app-settings.yaml @@ -0,0 +1,21 @@ +# Minimal app settings for ID5 E2E test +--- +accounts: + - id: test-account-id5 + hooks: + execution-plan: + endpoints: + /openrtb2/auction: + stages: + processed-auction-request: + groups: + - timeout: 5000 + hook-sequence: + - module-code: id5-user-id + hook-impl-code: id5-user-id-fetch-hook + bidder-request: + groups: + - timeout: 5000 + hook-sequence: + - module-code: id5-user-id + hook-impl-code: id5-user-id-inject-hook diff --git a/extra/modules/pom.xml b/extra/modules/pom.xml index 6c4e70d62ab..380b6511d33 100644 --- a/extra/modules/pom.xml +++ b/extra/modules/pom.xml @@ -28,6 +28,7 @@ wurfl-devicedetection live-intent-omni-channel-identity pb-rule-engine + id5-user-id diff --git a/pom.xml b/pom.xml index 0c79bae025d..84c11847f75 100644 --- a/pom.xml +++ b/pom.xml @@ -667,6 +667,18 @@ ${java.version} + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + test-jar + + + + diff --git a/sample/configs/localdev-config-wiremock.yaml b/sample/configs/localdev-config-wiremock.yaml new file mode 100644 index 00000000000..6bef32cc64f --- /dev/null +++ b/sample/configs/localdev-config-wiremock.yaml @@ -0,0 +1,26 @@ +status-response: "ok" +adapters: + generic: + enabled: true + endpoint: http://localhost:8091/generic-exchange +cache: + scheme: http + host: localhost + path: /cache + query: uuid= +settings: + enforce-valid-account: false + filesystem: + settings-filename: sample/configs/sample-app-settings.yaml + stored-requests-dir: sample/stored + stored-imps-dir: sample/stored + profiles-dir: sample/profiles + stored-responses-dir: sample/stored + categories-dir: +gdpr: + default-value: 1 + vendorlist: + v2: + cache-dir: /var/tmp/vendor2 + v3: + cache-dir: /var/tmp/vendor3 diff --git a/sample/configs/prebid-config-with-id5.yaml b/sample/configs/prebid-config-with-id5.yaml new file mode 100644 index 00000000000..2737fd1362f --- /dev/null +++ b/sample/configs/prebid-config-with-id5.yaml @@ -0,0 +1,45 @@ +status-response: "ok" +adapters: + generic: + enabled: true + endpoint: http://localhost:8091/generic-exchange +cache: + scheme: http + host: localhost + path: /cache + query: uuid= +settings: + enforce-valid-account: false + generate-storedrequest-bidrequest-id: true + filesystem: + settings-filename: sample/configs/sample-app-settings-id5.yaml + stored-requests-dir: sample/stored + stored-imps-dir: sample/stored + profiles-dir: sample/profiles + stored-responses-dir: sample/stored + categories-dir: +gdpr: + default-value: 1 + vendorlist: + v2: + cache-dir: /var/tmp/vendor2 + v3: + cache-dir: /var/tmp/vendor3 +hooks: + id5-user-id: + enabled: true + partner: 173 + fetchEndpoint: http://localhost:8091/id5-fetch + inserterName: local-pbs.example.com + providerName: local-pbs + +logging: + level: + org.prebid.server.hooks.modules.id5: DEBUG + +admin-endpoints: + logging-changelevel: + enabled: true + path: /logging/changelevel + on-application-port: true + protected: false diff --git a/sample/configs/sample-app-settings-id5.yaml b/sample/configs/sample-app-settings-id5.yaml new file mode 100644 index 00000000000..bfcd72a1698 --- /dev/null +++ b/sample/configs/sample-app-settings-id5.yaml @@ -0,0 +1,33 @@ +accounts: + - id: "1001" + status: active + hooks: + execution-plan: + { + "endpoints": { + "/openrtb2/auction": { + "stages": { + "processed-auction-request": { + "groups": [ + { + "timeout": 500, + "hook-sequence": [ + { "module-code": "id5-user-id", "hook-impl-code": "id5-user-id-fetch-hook" } + ] + } + ] + }, + "bidder-request": { + "groups": [ + { + "timeout": 500, + "hook-sequence": [ + { "module-code": "id5-user-id", "hook-impl-code": "id5-user-id-inject-hook" } + ] + } + ] + } + } + } + } + } diff --git a/sample/wiremock/README.md b/sample/wiremock/README.md new file mode 100644 index 00000000000..e01981ad960 --- /dev/null +++ b/sample/wiremock/README.md @@ -0,0 +1,91 @@ +# Sample WireMock for Prebid Server Java + +This directory contains a minimal WireMock setup you can use for local integration testing with Prebid Server (PBS-Java). + +Structure: +- `mappings/generic-exchange.json` — maps requests to the `/generic-exchange` endpoint. +- `__files/generic-bid.json` — sample OpenRTB BidResponse returned by the mapping. +- `docker-compose.wiremock.yml` — ready-to-use Docker Compose definition for running WireMock in a container. + +Target mock endpoint: `POST /generic-exchange`. + +--- + +## Run with Docker Compose +Requirements: Docker Desktop (or Docker Engine) + Docker Compose. + +1. Go to the sample directory: + ```bash + cd sample/wiremock + ``` +2. Start WireMock in the background: + ```bash + docker compose -f docker-compose.wiremock.yml up -d + ``` + - The container runs image `wiremock/wiremock:3.13.2`. + - WireMock listens inside the container on port `8080` and is mapped on the host as `http://localhost:8090`. + - A volume mounts the current directory as `/home/wiremock` (read-only), so any changes in `mappings`/`__files` are visible without rebuilding the image. + +3. Verify the endpoint works (example call): + ```bash + curl -s -X POST http://localhost:8090/generic-exchange -H 'Content-Type: application/json' -d '{}' + ``` + You should receive the content from `__files/generic-bid.json`. + +4. Tail logs: + ```bash + docker logs -f wiremock-prebid-server + ``` + +5. Stop and remove the container: + ```bash + docker compose -f docker-compose.wiremock.yml down + ``` + +Notes: +- If port `8090` is taken, change the port mapping in `docker-compose.wiremock.yml` (the line `ports: - "8090:8080"`) to something else, e.g., `9090:8080`, and remember to use the new port in your calls. + +--- + +## Run using the IntelliJ WireMock Plugin +Requirements: IntelliJ IDEA with the “WireMock” plugin installed. + +1. Install the plugin: + - File → Settings → Plugins → Marketplace → search for “WireMock” → Install → Restart IDE. + +2. Create a WireMock Run Configuration: + - Run → Edit Configurations… → `+` → select “WireMock”. + - Set the fields: + - Files root (or Root dir): point to the `sample/wiremock` directory in the repo. + - Port: set to `8090` (important: see the port note below). + - Optionally enable `Verbose` logs. + +3. Run the configuration and test: + - Click Run on the new configuration. + - Check the endpoint: + ```bash + curl -s -X POST http://localhost:8090/generic-exchange -H 'Content-Type: application/json' -d '{}' + ``` + +### Important: set the port in the IntelliJ Run Configuration +- Make sure the WireMock configuration uses port `8090` to stay consistent with this sample and with any local PBS config that points to `http://localhost:8090/...`. +- If needed, you can choose a different port (e.g., 9090), but then update the addresses in your testing tools and/or PBS configuration accordingly. + +--- + +## Integration with Prebid Server (locally) +- In your PBS adapter/bidder configuration, set the endpoint to the WireMock address, e.g., `http://localhost:8090/generic-exchange`. +- This sample does not enforce a specific request body — any `POST` to `/generic-exchange` returns the fixed response from `__files/generic-bid.json`. + +--- + +## Customizing mappings +- To change the response payload, edit `__files/generic-bid.json`. +- To refine match conditions (e.g., headers, body patterns), update `mappings/generic-exchange.json` according to WireMock 3.x documentation. + +--- + +## Troubleshooting +- Port in use: change the port in Docker Compose or in the IntelliJ Run Configuration. +- No response/404: ensure `Files root` points to `sample/wiremock` and that files under `mappings` and `__files` are visible. +- Changes not reflected in Docker: remember the volume is mounted as `:ro` (read-only) in the container — make edits on the host; the container reads them live. diff --git a/sample/wiremock/__files/generic-bid-response.json b/sample/wiremock/__files/generic-bid-response.json new file mode 100644 index 00000000000..21aaf45cabd --- /dev/null +++ b/sample/wiremock/__files/generic-bid-response.json @@ -0,0 +1,18 @@ +{ + "id": "test-bid-request-id", + "seatbid": [ + { + "bid": [ + { + "id": "bid1", + "impid": "test-imp-id", + "price": 1.23, + "crid": "crid001", + "adm": "adm001", + "w": 300, + "h": 250 + } + ] + } + ] +} diff --git a/sample/wiremock/__files/id5-fetch-response.json b/sample/wiremock/__files/id5-fetch-response.json new file mode 100644 index 00000000000..c0b8c115648 --- /dev/null +++ b/sample/wiremock/__files/id5-fetch-response.json @@ -0,0 +1,31 @@ +{ + "created_at": "2025-11-23T12:52:34+09:00", + "original_uid": "ID5*YsvxY", + "universal_uid": "ID5*YsvxY", + "privacy": { + "jurisdiction": "gdpr", + "id5_consent": true + }, + "signature": "signature", + "ext": { + "linkType": 0, + "pba": "pba-value" + }, + "ids": { + "id5id": { + "eid": { + "source": "id5-sync.com", + "uids": [ + { + "id": "ID5*YsvxY", + "atype": 1, + "ext": { + "linkType": 2, + "pba": "jWwv+" + } + } + ] + } + } + } +} diff --git a/sample/wiremock/docker-compose.wiremock.yml b/sample/wiremock/docker-compose.wiremock.yml new file mode 100644 index 00000000000..2844ba2315e --- /dev/null +++ b/sample/wiremock/docker-compose.wiremock.yml @@ -0,0 +1,10 @@ +services: + wiremock: + image: wiremock/wiremock:3.13.2 + container_name: wiremock-prebid-server + command: ["--port", "8080", "--root-dir", "/home/wiremock", "--verbose"] + ports: + - "8091:8080" + volumes: + - ./:/home/wiremock:ro + restart: unless-stopped diff --git a/sample/wiremock/mappings/generic-exchange.json b/sample/wiremock/mappings/generic-exchange.json new file mode 100644 index 00000000000..1211343e651 --- /dev/null +++ b/sample/wiremock/mappings/generic-exchange.json @@ -0,0 +1,13 @@ +{ + "request": { + "method": "POST", + "urlPath": "/generic-exchange" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "generic-bid-response.json" + } +} diff --git a/sample/wiremock/mappings/id5-fetch.json b/sample/wiremock/mappings/id5-fetch.json new file mode 100644 index 00000000000..b2233df45b7 --- /dev/null +++ b/sample/wiremock/mappings/id5-fetch.json @@ -0,0 +1,13 @@ +{ + "request": { + "method": "POST", + "urlPathPattern": "/id5-fetch/[0-9]+\\.json" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "bodyFileName": "id5-fetch-response.json" + } +}