From 8704aa560b6039c19616342d413ad59c44b46cec Mon Sep 17 00:00:00 2001 From: softcoder Date: Thu, 23 Jul 2026 22:07:21 +0200 Subject: [PATCH 01/25] optable-targeting: add hid (resolver hint) support --- .cursorrules | 262 ++++++++++++++++++ .gemini/settings.json | 15 + GEMINI.md | 9 + extra/.vscode_old/launch.json | 36 +++ extra/.vscode_old/settings.json | 18 ++ extra/.vscode_old/tasks.json | 33 +++ .../optable/targeting/model/Query.java | 4 +- .../config/OptableTargetingProperties.java | 3 + .../optable/targeting/v1/core/IdsMapper.java | 5 + .../targeting/v1/core/OptableTargeting.java | 2 +- .../targeting/v1/core/QueryBuilder.java | 41 ++- .../optable/targeting/v1/BaseOptableTest.java | 2 +- .../targeting/v1/core/IdsMapperTest.java | 14 +- .../targeting/v1/core/QueryBuilderTest.java | 92 +++++- .../configs/sample-app-settings-optable.yaml | 3 +- 15 files changed, 520 insertions(+), 19 deletions(-) create mode 100644 .cursorrules create mode 100644 .gemini/settings.json create mode 100644 GEMINI.md create mode 100644 extra/.vscode_old/launch.json create mode 100644 extra/.vscode_old/settings.json create mode 100644 extra/.vscode_old/tasks.json diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 00000000000..9d6f12e9da7 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,262 @@ +# Prebid Server Java Coding Rules + +You are an expert Java developer and Professor of Software Engineering, specializing in the Prebid Server Java codebase. You adhere to strict high-quality standards, favoring Modern Java (17+), Vert.x patterns, JUnit 5, Mockito (BDD style), and AssertJ. + +## 1. General Coding Philosophy +- **Non-Blocking I/O**: This is a Vert.x application. NEVER block the Event Loop. Use asynchronous patterns (`Future`, `Promise`) for I/O operations. +- **Immutability**: Prefer immutable data structures. + - Use `final` for all local variables, fields, and parameters. + - **Modern Java:** Use Java 17+ features like `record` and `sealed interface` where appropriate for data carriers and hierarchy restrictions. + - **Var Usage:** `var` is **STRICTLY PROHIBITED** in production code. Use explicit types. `var` is ALLOWED in tests. + - **Final Keyword:** Use `final` for all variables and parameters where possible to ensure immutability. + - **Switch Expressions:** Use modern `switch` expressions with arrow syntax `->`. + - **DTOs / POJOs**: **STRICTLY PROHIBITED** to write manual Getters, Setters, `toString`, `equals`, `hashCode`, or Constructors if Lombok can handle it. + - Use `@Value` + `staticConstructor="of"` for immutable value objects (default). + - Use `@Builder(toBuilder = true)` for complex objects (>4 fields) or when mutation-like copies are needed. + - Use `@AllArgsConstructor` / `@RequiredArgsConstructor` for simple dependency injection or wrappers. +- **Variable Types**: **DO NOT USE `var`**. Always write full variable types (Project Convention). +- **Null Safety**: + - Avoid returning `null`. Use `Optional` for return types. + - Avoid long chains of null checks; use `Optional` or `org.prebid.server.util.ObjectUtil`. +- **Privacy**: **STRICTLY PROHIBITED** to log private data (publishers, exchanges, usage analytics). +- **JSON Handling**: + - **FORBIDDEN**: `io.vertx.core.json.Json` (static Vert.x mapper). + - **ALLOWED**: Inject and use `JacksonMapper` or the project's configured `ObjectMapper`. + +## 3. Libraries & Utilities +- **Apache Commons**: Prefer using existing helpers from `commons-lang3` and `commons-collections4` over writing custom utility methods. + - Used: `StringUtils`, `ObjectUtils`, `CollectionUtils`, `MapUtils`. + - Avoid creating new `*Util` classes if an Apache Commons equivalent exists. + - Example: Use `StringUtils.isBlank(str)` instead of `str == null || str.trim().isEmpty()`. +- **Project Utilities**: + - **Project Utilities:** Use `org.prebid.server.util.ObjectUtil.getIfNotNull()` for concise, null-safe access chains (alternative to `Optional` in hot paths). + - **Apache Commons:** Heavily prefer `StringUtils`, `ObjectUtils`, `CollectionUtils`, `ListUtils`, `MapUtils` for null-safe operations. + - **Validation:** Use `java.util.Objects.requireNonNull(arg)` in constructors to enforce non-null dependencies. + - **Collection Utilities:** Use `java.util.Collections.emptyList()`, `Map.of()`, `Set.of()` etc. for better readability and immutability. + + +## 2. Code Style & Standards (Oracle & Checkstyle) +- **Base Standard**: Adhere to [Oracle Java Code Conventions](https://www.oracle.com/docs/tech/java/codeconventions.pdf) unless overridden by project-specific Checkstyle rules. +- **Indentation**: + - **4 spaces** for class members, methods, and blocks. + - **8 spaces** for line continuations (wrapping). +- **Line Length**: Max 120 characters (overrides Oracle's 80). +- **Class Structure** (Oracle Convention): + 1. Class/Instance Variables (Standard Order: public, protected, package, private). + 2. Constructors. + 3. Methods (Grouped by functionality, logic flow, or readability). +- **Declarations**: + - One variable declaration per line. + - Initialize variables at declaration where possible. + - Place declarations at the beginning of blocks (legacy Oracle) OR near first usage (Modern/Clean Code) -> **Prefer near first usage**. +- **Statements**: + - Always use braces `{}` for `if`, `else`, `for`, `do`, `while` (K&R style). + - No parentheses in `return` statements. + +- **Imports**: + - **FORBIDDEN**: Wildcard imports (`import java.util.*;`). + - Order: Third-party libraries first, then `java.*`/`jakarta.*` at the bottom (separated by blank line). + - No `static` imports in production code (except standard utilities if really needed), but allowed in Tests. + - **Illegal Imports**: `org.junit.Test` (JUnit 4), `org.apache.commons.lang` (use `lang3`), `io.vertx.core.json.Json`. +- **Naming**: + - Constants: `UPPER_CASE_WITH_UNDERSCORES`. + - Methods/Fields: `camelCase`. + - **Maps**: Use `keyToValue` convention (e.g., `impToExt`, `accountIdToBidder`). + - **Self-Explanatory**: Avoid single-letter variables. `resolvedParam` > `s`. +- **Collections**: + - Use literals/factories: `List.of()`, `Collections.emptyList()`, `Collections.singletonList()`. +- **Formatting**: + - Parenthesis on expression end. + - Ternary operators: Long ones on separate lines, short ones on one line. + - Boolean logic: Explicit parenthesis for precedence `(a && b) || c`. + - **Method Ordering**: Call order (Interface method -> private methods it calls -> next Interface method). +- **Dependencies**: Do not call methods from transitive dependencies; declare them explicitly in `pom.xml`. +- **Lombok**: + - Use `@Value` + `staticConstructor="of"`. + - Use `@Builder` for constructors with > 4 arguments. + - `toBuilder = true` for updates. + +## 4. Architecture, SOLID & Design Patterns + +### 4.1 SOLID Principles +- **SRP (Single Responsibility)**: + - Classes must have one clearly defined purpose. + - **Refactor**: Split large "God Classes" into smaller delegates or services. +- **OCP (Open/Closed)**: + - Design for extension. Use Interfaces and Strategy patterns so new behavior can be added without changing existing code. +- **LSP (Liskov Substitution)**: + - Subclasses/Implementations must behave consistently. Do not throw `UnsupportedOperationException` unexpectedly. +- **ISP (Interface Segregation)**: + - Prefer focused interfaces (e.g., `Reader`, `Writer`) over large broad ones. +- **DIP (Dependency Inversion)**: + - Depend on abstractions (Interfaces). + - **Framework**: Use **Spring Framework** for Dependency Injection. + - **Injection Style**: **Constructor Injection** is REQUIRED. Field injection (`@Autowired` on fields) is **STRICTLY PROHIBITED**. + +### 4.2 Clean Code & Best Practices +- **Readability**: Code is read more often than written. Optimize for reading. +- **Naming**: + - **Intent-Revealing**: `daysSinceCreation` > `d`. + - **No Encodings**: No Hungarian notation or type prefixes. +- **Functions**: + - **Small**: Methods should be small and do one thing. + - **Guard Clauses**: Use strict guard clauses/early returns to flatten nesting. + - **Pure Functions**: Prefer `private static` methods for stateless logic. +- **Comments**: + - **Why, not What**: Comments should explain the business decision, not the syntax. + - **No Code Comments**: Do not comment out code; delete it. Git history remembers. + +### 4.3 Architecture & Reactive Vert.x Patterns +- **Reactive Philosophy**: + - **Everything is a Stream/Future**: Treat business logic as a pipeline of transformation steps. + - **Chaining**: Build flows by chaining operator methods (`map`, `compose`, `onContentType`, `recover`). + - **No Callbacks**: "Callback Hell" is strictly prohibited. Use functional composition. +- **Future Composition (The Glue)**: + - **Strict Transformation Pipeline:** Treat business logic as a stream of data transformations. + - **Chaining:** Use `.map()` for synchronous transformations and `.compose()` for asynchronous operations. + - **Parallelism:** Use `Future.all()`, `CompositeFuture.join()`, or `CompositeFuture.all()` for parallel execution. + - **Side Effects:** Use `.onSuccess()`, `.onFailure()`, and `.onComplete()` **ONLY** for side effects (logging, metrics). **NEVER** use them for control flow or chaining logic. + - **Error Handling:** Propagate errors down the chain. Use `.recover()` for falling back or transforming errors. + - **Avoid Callbacks:** **STRICTLY PROHIBIT** nested callbacks or "Callback Hell". logic must be flat. + - **Thread Safety:** Always assume the code runs on the Event Loop. **NEVER** block the thread. + - **Context Awareness:** Pass `RoutingContext` or `AuctionContext` as a carrier of state through the chain. + + ### 4.4 Object-Oriented Patterns + - **Value Objects:** Use Lombok `@Value(staticConstructor = "of")` for immutable data carriers. + - **Factory Methods:** Prefer static factory methods named `of(...)` or `create(...)` over public constructors for complex object creation. + - **NoOp Pattern:** For interfaces that may have empty implementations (e.g., hooks, empty services), create a `NoOpExtension` or `static NoOp` implementation within the interface or as a separate class. + - **Sealed Hierarchies:** Use `sealed interface` and `record` (Java 17+) for restricted class hierarchies (e.g., specialized result types). + + ### 4.5 JSON & Serialization + - **JacksonMapper:** Always use the project's `JacksonMapper` wrapper instead of raw `ObjectMapper` where possible. + - **Dynamic JSON:** Use `ObjectNode` and `ArrayNode` for handling dynamic or unstructured data (especially `ext` fields) rather than untyped `Map`. + - **JsonPointer:** Use `JsonPointer` for safe and readable deep traversal of JSON trees (`node.at("/path/to/field")`). +- **Concurrency (Vert.x Core Rule)**: + - **Single Threaded Event Loop**: The application runs on the Event Loop. + - **PROHIBITED**: `synchronized`, `wait()`, `notify()`, `Thread.sleep()`, `BlockingQueue`, or any blocking Java concurrency primitive. + - **Blocking Code**: If you MUST run blocking code (e.g. legacy JDBC), use `vertx.executeBlocking(...)` but prefer non-blocking clients. + - **ALLOWED**: `Future`, `Promise`, `vertx.setTimer()`. +- **Error Handling**: + - **Async**: In async chains, errors propagate automatically. Do not break the chain. + - **Return Failed Future**: In async methods, return `Future.failedFuture(e)` immediately rather than throwing runtime exceptions. + - **Granularity**: Catch specific exceptions in `.recover()`. Avoid broad catches unless it's a top-level handler. +- **Context**: Be aware of the Vert.x `Context`. Ensure context is preserved when switching threads. +- **Logging**: + - Use standard SLF4J usages: `private static final Logger logger = LoggerFactory.getLogger(MyClass.class);` + - Do not use `System.out.println` or `e.printStackTrace()`. + - Log at `DEBUG` for high-volume messages, `INFO` for lifecycle events, `WARN/ERROR` for unexpected conditions. + +## 5. Testing Rules +- **Frameworks**: + - **JUnit 5**: `org.junit.jupiter.api.*`. + - **Behavior Driven Development (BDD)**: Use `given(mock.method()).willReturn(...)` instead of `when(...)`. + - **Strictness**: Use `@Mock(strictness = Mock.Strictness.LENIENT)` for infrastructure mocks (Metrics, Services) defined in `setUp` but not used in every test to avoid `UnnecessaryStubbingException`. + - **Async Testing**: For `Future`-based tests, use `VertxTestContext`: + ```java + @Test + void shouldSucceed(VertxTestContext context) { + future.onComplete(context.succeeding(result -> { + assertThat(result).isNotNull(); + context.completeNow(); + })); + } + ``` + - **Time Testing**: Mock `java.time.Clock` ensures deterministic time tests. + - **Static Imports**: ALWAYS statically import: + - `org.assertj.core.api.Assertions.*` (assertThat, tuple, entry) + - `org.mockito.BDDMockito.*` (given) + - `org.mockito.Mockito.*` (verify, never, etc.) + - `org.mockito.ArgumentMatchers.*` (any, eq, etc.) + - **Assertions**: + - Use `extracting()` for nested properties. + - Use `containsExactlyInAnyOrder` for lists. + - Use `containsOnly` for verifying single-element logical containment. + - **Structure**: + - Use `target` as the name for the class under test instance. + - Use `setUp()` method annotated with `@BeforeEach`. + - **Vert.x Data**: Use `MultiMap.caseInsensitiveMultiMap()` for testing headers/params. + - Fields: SUT (System Under Test) named `target`. +- **Granularity**: + - 1 Test = 1 Logic Path. Avoid `testFooAndBar`. Split into `testFoo` and `testBar`. + - No `ParameterizedTest` preference; explicitly write separate tests for meaningful scenarios (per docs). +- **Data Placement**: + - **Inline Data**: Place test data INSIDE the test method (local variables). + - **No Constants**: Avoid class-level constants for test data. + - **Fake Data**: Do not use real URLs/IDs (use `test.com`, `id`). +- **Mocking**: + - Annotate test class with `@ExtendWith(MockitoExtension.class)`. + - Use `@Mock` for dependencies. + - Use strict mocking (default). Only use `lenientness` if absolutely necessary for shared setup. +- **Naming**: `methodNameShouldReturnExpectedBehaviorWhenCondition`. + - Example: `processDataShouldReturnResultWhenInputIsData`. +- **BDD Style**: + ```java + // given + given(dependency.call()).willReturn(futureResult); + final var input = "test-input"; // 'var' is acceptable in tests only if brevity aids readability, but prefer explicit types per project rule. + + // when + Future result = target.execute(input); + + // then + assertThat(result.succeeded()).isTrue(); + ``` + +## 6. Implementation Workflow (Agent Instructions) +1. **Analyze**: Read existing code and `pom.xml` to understand dependencies. +2. **Plan**: Propose changes before writing. +3. **Implement**: + - Write logic using functional style (`Stream` API). + - Ensure extensive logging for debuggability (at `debug` or `trace` level for high-volume paths). +4. **Test**: + - ALWAYS write or update unit tests for changed logic. + - Validate logic with `VertxTest` if async/JSON is involved. + +## 7. Example Test Template + +```java +package org.prebid.server.component; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.prebid.server.VertxTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ExampleTest { + + @Mock + private Dependency dependency; + @Mock(strictness = Mock.Strictness.LENIENT) // Common infra mock + private Metrics metrics; + + private Example target; + + @BeforeEach + void setUp() { + target = new Example(dependency, metrics); + } + + @Test + void shouldReturnExpectedValueWhenConditionMet() { + // given + given(dependency.getData()).willReturn("data"); + + // when + final var result = target.process(); + + // then + assertThat(result) + .extracting(Result::getValue) + .isEqualTo("data"); + verify(metrics).updateMetric(any()); + } +} +``` diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 00000000000..44d4be1fc97 --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "BeforeTool": [ + { + "matcher": "read_file|list_directory", + "hooks": [ + { + "type": "command", + "command": "python -c \"import sys,pathlib,json;e=pathlib.Path('graphify-out/graph.json').exists();d={'decision':'allow'};e and d.update({'additionalContext':'graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files.'});sys.stdout.write(json.dumps(d))\"" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 00000000000..f449b672b54 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,9 @@ +## graphify + +This project has a graphify knowledge graph at graphify-out/. + +Rules: +- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure +- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +- For cross-module "how does X relate to Y" questions, prefer `graphify query ""`, `graphify path "" ""`, or `graphify explain ""` over grep — these traverse the graph's EXTRACTED + INFERRED edges instead of scanning files +- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) diff --git a/extra/.vscode_old/launch.json b/extra/.vscode_old/launch.json new file mode 100644 index 00000000000..3641a02708c --- /dev/null +++ b/extra/.vscode_old/launch.json @@ -0,0 +1,36 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "java", + "name": "Debug Prebid Server (default config)", + "request": "launch", + "mainClass": "org.prebid.server.Application", + "projectName": "prebid-server-bundle", + "args": [ + "--spring.config.additional-location=sample/configs/prebid-config-with-optable.yaml" + ], + "vmArgs": "-Xmx2G -Dlogging.level.root=INFO" + }, + { + "type": "java", + "name": "Debug Prebid Server (custom YAML)", + "request": "launch", + "mainClass": "org.prebid.server.Application", + "projectName": "prebid-server", + "args": [ + "--spring.config.additional-location=config/my-prebid.yaml" + ], + "vmArgs": "-Xmx2G -Dlogging.level.root=DEBUG" + }, + { + "type": "java", + "name": "Debug Prebid Server (no args)", + "request": "launch", + "mainClass": "org.prebid.server.Application", + "projectName": "prebid-server", + "vmArgs": "-Xmx1G" + } + ] + } + \ No newline at end of file diff --git a/extra/.vscode_old/settings.json b/extra/.vscode_old/settings.json new file mode 100644 index 00000000000..6426ceea05a --- /dev/null +++ b/extra/.vscode_old/settings.json @@ -0,0 +1,18 @@ +{ + "java.configuration.maven.pomFile": "extra/pom.xml", + "java.import.maven.enabled": true, + "java.autobuild.enabled": true, + "java.configuration.updateBuildConfiguration": "automatic", + "java.errors.incompleteClasspath.severity": "ignore", + "files.watcherExclude": { + "**/target/**": true + }, + "java.completion.importOrder": [ + "#", + "java" + ], + "editor.codeActionsOnSave": { + "source.organizeImports": "never" + } + } + \ No newline at end of file diff --git a/extra/.vscode_old/tasks.json b/extra/.vscode_old/tasks.json new file mode 100644 index 00000000000..aa0aab32b13 --- /dev/null +++ b/extra/.vscode_old/tasks.json @@ -0,0 +1,33 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Maven: Fetch dependencies", + "type": "shell", + "command": "mvn -q dependency:go-offline", + "group": "build", + "problemMatcher": [] + }, + { + "label": "Maven: Build (full package)", + "type": "shell", + "command": "mvn -q clean package -DskipTests", + "group": "build", + "problemMatcher": "$maven" + }, + { + "label": "Maven: Test", + "type": "shell", + "command": "mvn -q test", + "group": "test", + "problemMatcher": "$maven" + }, + { + "label": "Format Java (Google Style)", + "type": "shell", + "command": "mvn -q spotless:apply", + "problemMatcher": [] + } + ] + } + \ No newline at end of file diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Query.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Query.java index 20862050f39..19b725ab666 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Query.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Query.java @@ -7,9 +7,11 @@ public class Query { String ids; + String hid; + String attributes; public String toQueryString() { - return ids + attributes; + return ids + hid + attributes; } } diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/config/OptableTargetingProperties.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/config/OptableTargetingProperties.java index 683ea1c8eae..e2d31503670 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/config/OptableTargetingProperties.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/config/OptableTargetingProperties.java @@ -32,6 +32,9 @@ public final class OptableTargetingProperties { @JsonProperty("id-prefix-order") String idPrefixOrder; + @JsonProperty("hid-prefixes") + String hidPrefixes; + @JsonProperty("optable-inserter-eids-merge") Set optableInserterEidsMerge = Set.of(); diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapper.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapper.java index 7214f6ce6a3..a9b4c9c7311 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapper.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapper.java @@ -81,6 +81,11 @@ private static void addDeviceIds(Map ids, Device device) { final String ifa = device != null ? device.getIfa() : null; final String os = device != null ? StringUtils.toRootLowerCase(device.getOs()) : null; final int lmt = Optional.ofNullable(device).map(Device::getLmt).orElse(0); + final String ip6 = device != null ? device.getIpv6() : null; + + if (ip6 != null) { + ids.put(Id.DEVICE_IP_V_6, ip6); + } if (ifa == null || StringUtils.isEmpty(os) || lmt == 1) { return; diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableTargeting.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableTargeting.java index 0cb16d9c456..b79ca2790f2 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableTargeting.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableTargeting.java @@ -29,7 +29,7 @@ public Future getTargeting(OptableTargetingProperties propertie Timeout timeout) { final List ids = idsMapper.toIds(bidRequest, properties.getPpidMapping()); - final Query query = QueryBuilder.build(ids, attributes, properties.getIdPrefixOrder()); + final Query query = QueryBuilder.build(ids, attributes, properties); if (query == null) { return Future.failedFuture("Can't get targeting"); } diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java index bee89818b2f..63f4990f14c 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java @@ -1,18 +1,22 @@ package org.prebid.server.hooks.modules.optable.targeting.v1.core; import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.StringUtils; import org.prebid.server.hooks.modules.optable.targeting.model.Id; import org.prebid.server.hooks.modules.optable.targeting.model.OptableAttributes; import org.prebid.server.hooks.modules.optable.targeting.model.Query; +import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.function.Function; import java.util.function.Predicate; @@ -26,12 +30,40 @@ public class QueryBuilder { private QueryBuilder() { } - public static Query build(List ids, OptableAttributes optableAttributes, String idPrefixOrder) { + public static Query build(List ids, OptableAttributes optableAttributes, + OptableTargetingProperties properties) { + if (CollectionUtils.isEmpty(ids) && CollectionUtils.isEmpty(optableAttributes.getIps())) { return null; } - return Query.of(buildIdsString(ids, idPrefixOrder), buildAttributesString(optableAttributes)); + final String idPrefixOrder = properties.getIdPrefixOrder(); + final String hidPrefixes = properties.getHidPrefixes(); + return Query.of( + buildIdsString(ids, idPrefixOrder), + buildHidString(ids, hidPrefixes), + buildAttributesString(optableAttributes)); + } + + private static String buildHidString(List ids, String hidPrefixesString) { + if (CollectionUtils.isEmpty(ids) || StringUtils.isEmpty(hidPrefixesString)) { + return StringUtils.EMPTY; + } + final Map prefixToIdValue = ids.stream().collect(Collectors.toMap(Id::getName, it -> it)); + if (MapUtils.isEmpty(prefixToIdValue)) { + return StringUtils.EMPTY; + } + + final String hidParameters = Arrays.stream(hidPrefixesString.split(",")) + .map(prefixToIdValue::get) + .filter(Objects::nonNull) + .map(it -> String.format("hid=%s:%s", it.getName(), it.getValue())) + .collect(Collectors.joining("&")); + if (StringUtils.isEmpty(hidParameters)) { + return StringUtils.EMPTY; + } + + return "&" + hidParameters; } private static String buildIdsString(List ids, String idPrefixOrder) { @@ -39,7 +71,10 @@ private static String buildIdsString(List ids, String idPrefixOrder) { return StringUtils.EMPTY; } - final List reorderedIds = reorderIds(ids, idPrefixOrder); + final List reorderedIds = reorderIds(ids, idPrefixOrder) + .stream() + .filter(id -> !Id.DEVICE_IP_V_6.equals(id.getName())) + .toList(); final StringBuilder sb = new StringBuilder(); for (Id id : reorderedIds) { diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/BaseOptableTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/BaseOptableTest.java index 52143ff9fef..4bfb1947b5f 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/BaseOptableTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/BaseOptableTest.java @@ -273,7 +273,7 @@ protected OptableTargetingProperties givenOptableTargetingProperties(String key, } protected Query givenQuery() { - return Query.of("?que", "ry"); + return Query.of("?que", "r", "y"); } protected ObjectNode givenAccountConfig(String key, String tenant, String origin, boolean cacheEnabled) { diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapperTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapperTest.java index 39693661629..714fc538a34 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapperTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapperTest.java @@ -56,7 +56,8 @@ public void shouldMapBidRequestToAllPossibleIds() { .doesNotContain(Id.of(Id.APPLE_IDFA, "ifa")) .contains(Id.of(Id.ID5, "id5_id")) .contains(Id.of(Id.UTIQ, "utiq_id")) - .contains(Id.of("c", "test_id")); + .contains(Id.of("c", "test_id")) + .contains(Id.of(Id.DEVICE_IP_V_6, "0:0:0:0:0:0:0:1")); } @Test @@ -71,6 +72,17 @@ public void shouldMapNothing() { assertThat(ids).isNotNull(); } + @Test + public void shouldMapIpv6WhenPresent() { + final BidRequest bidRequest = givenBidRequestWithEids(Map.of()); + + final List ids = target.toIds(bidRequest, Map.of()); + + assertThat(ids).isNotNull() + .contains(Id.of(Id.EMAIL, "email")) + .contains(Id.of(Id.DEVICE_IP_V_6, "0:0:0:0:0:0:0:1")); + } + private BidRequest givenBidRequestWithEids(Map eids) { final JsonNode extUserOptable = objectMapper.convertValue(givenOptable(), JsonNode.class); final ExtUser extUser = ExtUser.builder().build(); diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java index 16d5953ef71..9be6fc3a2b6 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java @@ -4,6 +4,7 @@ import org.prebid.server.hooks.modules.optable.targeting.model.Id; import org.prebid.server.hooks.modules.optable.targeting.model.OptableAttributes; import org.prebid.server.hooks.modules.optable.targeting.model.Query; +import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties; import java.util.List; import java.util.Set; @@ -16,16 +17,21 @@ public class QueryBuilderTest { private final String idPrefixOrder = "c,c1"; + private OptableTargetingProperties properties() { + return givenProperties(idPrefixOrder, null); + } + @Test public void shouldSeparateAttributesFromIds() { // given final List ids = List.of(Id.of(Id.EMAIL, "email"), Id.of(Id.PHONE, "123")); // when - final Query query = QueryBuilder.build(ids, optableAttributes, idPrefixOrder); + final Query query = QueryBuilder.build(ids, optableAttributes, properties()); // then assertThat(query.getIds()).isEqualTo("&id=e%3Aemail&id=p%3A123"); + assertThat(query.getHid()).isEqualTo(""); assertThat(query.getAttributes()).isEqualTo("&gdpr_consent=tcf&gdpr=1&timeout=100ms&osdk=prebid-server"); } @@ -35,10 +41,11 @@ public void shouldBuildFullQueryString() { final List ids = List.of(Id.of(Id.EMAIL, "email"), Id.of(Id.PHONE, "123")); // when - final Query query = QueryBuilder.build(ids, optableAttributes, idPrefixOrder); + final Query query = QueryBuilder.build(ids, optableAttributes, properties()); // then assertThat(query.getIds()).isEqualTo("&id=e%3Aemail&id=p%3A123"); + assertThat(query.getHid()).isEqualTo(""); assertThat(query.getAttributes()).isEqualTo("&gdpr_consent=tcf&gdpr=1&timeout=100ms&osdk=prebid-server"); assertThat(query.toQueryString()) .isEqualTo("&id=e%3Aemail&id=p%3A123&gdpr_consent=tcf&gdpr=1&timeout=100ms&osdk=prebid-server"); @@ -50,7 +57,7 @@ public void shouldBuildQueryStringWhenHaveIds() { final List ids = List.of(Id.of(Id.EMAIL, "email"), Id.of(Id.PHONE, "123")); // when - final String query = QueryBuilder.build(ids, optableAttributes, idPrefixOrder).toQueryString(); + final String query = QueryBuilder.build(ids, optableAttributes, properties()).toQueryString(); // then assertThat(query).contains("e%3Aemail", "p%3A123"); @@ -62,7 +69,7 @@ public void shouldBuildQueryStringWithExtraAttributes() { final List ids = List.of(Id.of(Id.EMAIL, "email"), Id.of(Id.PHONE, "123")); // when - final String query = QueryBuilder.build(ids, optableAttributes, idPrefixOrder).toQueryString(); + final String query = QueryBuilder.build(ids, optableAttributes, properties()).toQueryString(); // then assertThat(query).contains("&gdpr=1", "&gdpr_consent=tcf", "&timeout=100ms"); @@ -78,7 +85,7 @@ public void shouldBuildQueryStringWithRightOrder() { Id.of("c", "234")); // when - final String query = QueryBuilder.build(ids, optableAttributes, idPrefixOrder).toQueryString(); + final String query = QueryBuilder.build(ids, optableAttributes, properties()).toQueryString(); // then assertThat(query).startsWith("&id=c%3A234&id=c1%3A123&id=id5%3AID5&id=e%3Aemail"); @@ -93,7 +100,7 @@ public void shouldBuildQueryStringWhenIdsListIsEmptyAndIpIsPresent() { .build(); // when - final Query query = QueryBuilder.build(ids, attributes, idPrefixOrder); + final Query query = QueryBuilder.build(ids, attributes, properties()); // then assertThat(query).isNotNull(); @@ -107,7 +114,7 @@ public void shouldNotBuildQueryStringWhenIdsListIsEmptyAndIpIsAbsent() { final OptableAttributes attributes = OptableAttributes.builder().build(); // when - final Query query = QueryBuilder.build(ids, attributes, idPrefixOrder); + final Query query = QueryBuilder.build(ids, attributes, properties()); // then assertThat(query).isNull(); @@ -124,7 +131,7 @@ public void shouldBuildQueryStringWithGppSid() { .build(); // when - final String query = QueryBuilder.build(ids, attributes, null).toQueryString(); + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); // then assertThat(query).contains("&gpp=DBABzw~1YNY~BVQqAAAAAgA"); @@ -144,7 +151,7 @@ public void shouldBuildQueryStringWithSingleGppSid() { .build(); // when - final String query = QueryBuilder.build(ids, attributes, null).toQueryString(); + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); // then assertThat(query).contains("&gpp_sid=7"); @@ -162,7 +169,7 @@ public void shouldLimitGppSidToTwoValues() { .build(); // when - final String query = QueryBuilder.build(ids, attributes, null).toQueryString(); + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); // then final String gppSidValue = query.split("gpp_sid=")[1].split("&")[0]; @@ -181,12 +188,68 @@ public void shouldNotIncludeGppSidWhenEmpty() { .build(); // when - final String query = QueryBuilder.build(ids, attributes, null).toQueryString(); + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); // then assertThat(query).doesNotContain("gpp_sid"); } + @Test + public void shouldBuildHidWhenHidPrefixesMatchIds() { + // given + final OptableTargetingProperties props = givenProperties(null, "c,i6"); + final List ids = List.of( + Id.of("c", "234"), + Id.of(Id.DEVICE_IP_V_6, "0:0:0:0:0:0:0:1")); + + // when + final Query query = QueryBuilder.build(ids, optableAttributes, props); + + // then + assertThat(query.getHid()).isEqualTo("&hid=c:234&hid=i6:0:0:0:0:0:0:0:1"); + } + + @Test + public void shouldExcludeDeviceIpV6FromIdsString() { + // given + final OptableTargetingProperties props = givenProperties(null, "i6"); + final List ids = List.of( + Id.of(Id.EMAIL, "email"), + Id.of(Id.DEVICE_IP_V_6, "0:0:0:0:0:0:0:1")); + + // when + final Query query = QueryBuilder.build(ids, optableAttributes, props); + + // then + assertThat(query.getIds()).doesNotContain(Id.DEVICE_IP_V_6); + assertThat(query.getHid()).isEqualTo("&hid=i6:0:0:0:0:0:0:0:1"); + } + + @Test + public void shouldNotBuildHidWhenNoMatch() { + // given + final OptableTargetingProperties props = givenProperties(null, "nonexistent"); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, optableAttributes, props); + + // then + assertThat(query.getHid()).isEqualTo(""); + } + + @Test + public void shouldNotBuildHidWhenHidPrefixesNotConfigured() { + // given + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, optableAttributes, properties()); + + // then + assertThat(query.getHid()).isEqualTo(""); + } + private OptableAttributes givenOptableAttributes() { return OptableAttributes.builder() .timeout(100L) @@ -194,4 +257,11 @@ private OptableAttributes givenOptableAttributes() { .gdprConsent("tcf") .build(); } + + private static OptableTargetingProperties givenProperties(String idPrefixOrder, String hidPrefixes) { + final OptableTargetingProperties properties = new OptableTargetingProperties(); + properties.setIdPrefixOrder(idPrefixOrder); + properties.setHidPrefixes(hidPrefixes); + return properties; + } } diff --git a/sample/configs/sample-app-settings-optable.yaml b/sample/configs/sample-app-settings-optable.yaml index 4413a05769d..7b4af19a7e2 100644 --- a/sample/configs/sample-app-settings-optable.yaml +++ b/sample/configs/sample-app-settings-optable.yaml @@ -30,7 +30,8 @@ accounts: enrich-web: true enrich-app: true id-prefix-order: "e,v,c" - ppid-mapping: { "pubcid.org": "c" } + hid-prefixes: "c,i6" + ppid-mapping: { "pubmatic.com": "c" } adserver-targeting: true cache: enabled: false From 223e166557cc6fb49cf8a004f46981b9e1a61659 Mon Sep 17 00:00:00 2001 From: softcoder Date: Thu, 23 Jul 2026 23:04:43 +0200 Subject: [PATCH 02/25] optable-targeting: add app bundle and ver to the Targeting API call --- .../modules/optable/targeting/model/App.java | 11 ++++ .../targeting/model/OptableAttributes.java | 2 + .../v1/core/OptableAttributesResolver.java | 9 ++- .../targeting/v1/core/QueryBuilder.java | 13 ++++ .../core/OptableAttributesResolverTest.java | 37 ++++++++++++ .../targeting/v1/core/QueryBuilderTest.java | 60 +++++++++++++++++++ 6 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/App.java diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/App.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/App.java new file mode 100644 index 00000000000..1b1cdb51c63 --- /dev/null +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/App.java @@ -0,0 +1,11 @@ +package org.prebid.server.hooks.modules.optable.targeting.model; + +import lombok.Value; + +@Value(staticConstructor = "of") +public class App { + + String bundle; + + String ver; +} diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/OptableAttributes.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/OptableAttributes.java index 9dacd6de322..eda2ca74071 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/OptableAttributes.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/OptableAttributes.java @@ -23,4 +23,6 @@ public class OptableAttributes { String userAgent; Long timeout; + + App app; } diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java index 8009a9f2ff5..928d2a9f808 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java @@ -8,6 +8,7 @@ import org.apache.commons.lang3.StringUtils; import org.prebid.server.auction.gpp.model.GppContext; import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.hooks.modules.optable.targeting.model.App; import org.prebid.server.hooks.modules.optable.targeting.model.OptableAttributes; import org.prebid.server.proto.openrtb.ext.request.ExtRegs; import org.prebid.server.proto.openrtb.ext.request.ExtUser; @@ -35,6 +36,7 @@ public static OptableAttributes resolveAttributes(AuctionContext auctionContext, final OptableAttributes.OptableAttributesBuilder builder = OptableAttributes.builder() .ips(resolveIp(auctionContext)) .userAgent(resolveUserAgent(auctionContext)) + .app(resolveApp(auctionContext)) .timeout(timeout); if (gdpr != null && gdpr > 0) { @@ -51,7 +53,7 @@ public static OptableAttributes resolveAttributes(AuctionContext auctionContext, } } - if (gppScope.getGppModel() != null) { + if (gppScope != null && gppScope.getGppModel() != null) { builder .gpp(gppScope.getGppModel().encode()) .gppSid(SetUtils.emptyIfNull(gppScope.getSectionsIds())); @@ -60,6 +62,11 @@ public static OptableAttributes resolveAttributes(AuctionContext auctionContext, return builder.build(); } + private static App resolveApp(AuctionContext auctionContext) { + final com.iab.openrtb.request.App app = auctionContext.getBidRequest().getApp(); + return app != null ? App.of(app.getBundle(), app.getVer()) : null; + } + public static String resolveUserAgent(AuctionContext auctionContext) { final Device device = auctionContext.getBidRequest().getDevice(); return device != null ? device.getUa() : null; diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java index 63f4990f14c..ecd62f7a939 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java @@ -121,6 +121,19 @@ private static String buildAttributesString(OptableAttributes optableAttributes) sb.append("&osdk=").append(REQUEST_SOURCE); + Optional.ofNullable(optableAttributes.getApp()) + .ifPresent(app -> { + final String bundle = app.getBundle(); + if (StringUtils.isNotEmpty(bundle)) { + sb.append("&bundle=").append(bundle); + + final String ver = app.getVer(); + if (StringUtils.isNotEmpty(ver)) { + sb.append("&ver=").append(ver); + } + } + }); + return sb.toString(); } } diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolverTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolverTest.java index 9621758cab0..38a1cb3520a 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolverTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolverTest.java @@ -11,6 +11,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.prebid.server.auction.gpp.model.GppContext; import org.prebid.server.auction.model.AuctionContext; +import org.prebid.server.hooks.modules.optable.targeting.model.App; import org.prebid.server.hooks.modules.optable.targeting.model.OptableAttributes; import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties; import org.prebid.server.hooks.modules.optable.targeting.v1.BaseOptableTest; @@ -154,6 +155,42 @@ public void shouldResolveGppAttributes() { .returns(Set.of(1), OptableAttributes::getGppSid); } + @Test + public void shouldResolveAppWhenAppIsPresent() { + // given + final com.iab.openrtb.request.App ortbApp = com.iab.openrtb.request.App.builder() + .bundle("com.example.app") + .ver("1.2.3") + .build(); + final BidRequest bidRequest = BidRequest.builder() + .app(ortbApp) + .build(); + final AuctionContext auctionContext = givenAuctionContext(bidRequest, tcfContext, gppContext); + + // when + final OptableAttributes result = OptableAttributesResolver.resolveAttributes( + auctionContext, properties.getTimeout()); + + // then + assertThat(result).isNotNull() + .returns(App.of("com.example.app", "1.2.3"), OptableAttributes::getApp); + } + + @Test + public void shouldNotResolveAppWhenAppIsAbsent() { + // given + final BidRequest bidRequest = BidRequest.builder().build(); + final AuctionContext auctionContext = givenAuctionContext(bidRequest, tcfContext, gppContext); + + // when + final OptableAttributes result = OptableAttributesResolver.resolveAttributes( + auctionContext, properties.getTimeout()); + + // then + assertThat(result).isNotNull() + .returns(null, OptableAttributes::getApp); + } + public AuctionContext givenAuctionContext(BidRequest bidRequest, TcfContext tcfContext, GppContext gppContext) { return AuctionContext.builder() .bidRequest(bidRequest) diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java index 9be6fc3a2b6..25d9992110e 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java @@ -1,6 +1,7 @@ package org.prebid.server.hooks.modules.optable.targeting.v1.core; import org.junit.jupiter.api.Test; +import org.prebid.server.hooks.modules.optable.targeting.model.App; import org.prebid.server.hooks.modules.optable.targeting.model.Id; import org.prebid.server.hooks.modules.optable.targeting.model.OptableAttributes; import org.prebid.server.hooks.modules.optable.targeting.model.Query; @@ -250,6 +251,65 @@ public void shouldNotBuildHidWhenHidPrefixesNotConfigured() { assertThat(query.getHid()).isEqualTo(""); } + @Test + public void shouldAppendBundleAndVerWhenAppHasBoth() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .app(App.of("com.example.app", "1.2.3")) + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + assertThat(query).contains("&bundle=com.example.app", "&ver=1.2.3"); + } + + @Test + public void shouldAppendBundleOnlyWhenVerIsEmpty() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .app(App.of("com.example.app", "")) + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + assertThat(query).contains("&bundle=com.example.app"); + assertThat(query).doesNotContain("&ver="); + } + + @Test + public void shouldNotAppendBundleAndVerWhenBundleIsEmpty() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .app(App.of("", "1.2.3")) + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + assertThat(query).doesNotContain("&bundle=", "&ver="); + } + + @Test + public void shouldNotAppendBundleAndVerWhenAppIsNull() { + // given + final OptableAttributes attributes = OptableAttributes.builder().build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + assertThat(query).doesNotContain("&bundle=", "&ver="); + } + private OptableAttributes givenOptableAttributes() { return OptableAttributes.builder() .timeout(100L) From 36e0334af8bd4ad9704eade830a5554658436992 Mon Sep 17 00:00:00 2001 From: softcoder Date: Sat, 25 Jul 2026 00:16:25 +0200 Subject: [PATCH 03/25] optable-targeting: Add id5_signature propagation from client to Targeting API --- .../targeting/model/OptableAttributes.java | 2 + .../model/openrtb/ExtUserOptable.java | 2 + .../targeting/v1/core/BidRequestCleaner.java | 2 +- .../v1/core/OptableAttributesResolver.java | 19 ++++ .../targeting/v1/core/QueryBuilder.java | 3 + .../v1/core/BidRequestCleanerTest.java | 4 +- .../core/OptableAttributesResolverTest.java | 102 +++++++++++++----- .../targeting/v1/core/QueryBuilderTest.java | 28 +++++ 8 files changed, 132 insertions(+), 30 deletions(-) diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/OptableAttributes.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/OptableAttributes.java index eda2ca74071..d53d1963795 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/OptableAttributes.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/OptableAttributes.java @@ -25,4 +25,6 @@ public class OptableAttributes { Long timeout; App app; + + String id5Signature; } diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/ExtUserOptable.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/ExtUserOptable.java index 787b3adbf1a..a0c8f974b69 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/ExtUserOptable.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/ExtUserOptable.java @@ -17,4 +17,6 @@ public class ExtUserOptable extends FlexibleExtension { String zip; String vid; + + String id5Signature; } diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestCleaner.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestCleaner.java index 30652383942..d467e6df750 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestCleaner.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestCleaner.java @@ -14,7 +14,7 @@ public class BidRequestCleaner implements PayloadUpdate { private static final String OPTABLE_FIELD = "optable"; - private static final List FIELDS_TO_REMOVE = List.of("email", "phone", "zip", "vid"); + private static final List FIELDS_TO_REMOVE = List.of("email", "phone", "zip", "vid", "id5_signature"); public static BidRequestCleaner instance() { return new BidRequestCleaner(); diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java index 928d2a9f808..0122fca451d 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java @@ -1,5 +1,7 @@ package org.prebid.server.hooks.modules.optable.targeting.v1.core; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.request.Device; import com.iab.openrtb.request.Regs; @@ -10,6 +12,8 @@ import org.prebid.server.auction.model.AuctionContext; import org.prebid.server.hooks.modules.optable.targeting.model.App; import org.prebid.server.hooks.modules.optable.targeting.model.OptableAttributes; +import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.ExtUserOptable; +import org.prebid.server.json.ObjectMapperProvider; import org.prebid.server.proto.openrtb.ext.request.ExtRegs; import org.prebid.server.proto.openrtb.ext.request.ExtUser; @@ -59,9 +63,24 @@ public static OptableAttributes resolveAttributes(AuctionContext auctionContext, .gppSid(SetUtils.emptyIfNull(gppScope.getSectionsIds())); } + final Optional extUserOptable = Optional.ofNullable(bidRequest.getUser()) + .map(User::getExt) + .map(ext -> ext.getProperty("optable")) + .map(OptableAttributesResolver::parseExtUserOptable); + + extUserOptable.map(ExtUserOptable::getId5Signature).ifPresent(builder::id5Signature); + return builder.build(); } + private static ExtUserOptable parseExtUserOptable(JsonNode node) { + try { + return ObjectMapperProvider.mapper().treeToValue(node, ExtUserOptable.class); + } catch (JsonProcessingException e) { + return null; + } + } + private static App resolveApp(AuctionContext auctionContext) { final com.iab.openrtb.request.App app = auctionContext.getBidRequest().getApp(); return app != null ? App.of(app.getBundle(), app.getVer()) : null; diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java index ecd62f7a939..00aca36e8f8 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java @@ -134,6 +134,9 @@ private static String buildAttributesString(OptableAttributes optableAttributes) } }); + Optional.ofNullable(optableAttributes.getId5Signature()) + .ifPresent(id5Signature -> sb.append("&id5_signature=").append(id5Signature)); + return sb.toString(); } } diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestCleanerTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestCleanerTest.java index 6aa4ebff3a8..606c89ee102 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestCleanerTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/BidRequestCleanerTest.java @@ -33,7 +33,8 @@ public void shouldKeepOtherUserExtOptableTags() { // given final User user = givenUser(); ((com.fasterxml.jackson.databind.node.ObjectNode) user.getExt().getProperty("optable")) - .put("other", "value"); + .put("other", "value") + .put("id5_signature", "signature"); final AuctionRequestPayload auctionRequestPayload = AuctionRequestPayloadImpl.of(givenBidRequest(bidRequest -> bidRequest.user(user))); @@ -50,6 +51,7 @@ public void shouldKeepOtherUserExtOptableTags() { .satisfies(optable -> { assertThat(optable.has("other")).isTrue(); assertThat(optable.has("email")).isFalse(); + assertThat(optable.has("id5_signature")).isFalse(); }); } } diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolverTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolverTest.java index 38a1cb3520a..276c3f30c88 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolverTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolverTest.java @@ -1,9 +1,12 @@ package org.prebid.server.hooks.modules.optable.targeting.v1.core; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; import com.iab.gpp.encoder.GppModel; import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.request.Regs; import com.iab.openrtb.request.User; +import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -84,34 +87,6 @@ public void shouldResolveGdprAttributesForORTB25WhenConsentIsValid() { .returns("consent", OptableAttributes::getGdprConsent); } - private BidRequest givenBidRequestWithGdprORTB26(boolean isGdprEnabled, String consent) { - final User user = User.builder() - .consent(consent) - .build(); - - return BidRequest.builder() - .user(user) - .regs(Regs.builder() - .gdpr(isGdprEnabled ? 1 : 0) - .build()) - .build(); - } - - private BidRequest givenBidRequestWithGdprORTB25(boolean isGdprEnabled, String consent) { - final User user = User.builder() - .ext(ExtUser.builder() - .consent(consent) - .build()) - .build(); - - return BidRequest.builder() - .user(user) - .regs(Regs.builder() - .ext(ExtRegs.of(isGdprEnabled ? 1 : 0, null, null, null)) - .build()) - .build(); - } - @Test public void shouldNotResolveTcfAttributesWhenConsentIsNotValid() { // given @@ -191,6 +166,77 @@ public void shouldNotResolveAppWhenAppIsAbsent() { .returns(null, OptableAttributes::getApp); } + @Test + public void shouldResolveId5SignatureWhenPresentInUserExtOptable() { + // given + final BidRequest bidRequest = givenBidRequestWithId5Signature("signature"); + final AuctionContext auctionContext = givenAuctionContext(bidRequest, tcfContext, gppContext); + + // when + final OptableAttributes result = OptableAttributesResolver.resolveAttributes( + auctionContext, properties.getTimeout()); + + // then + assertThat(result).isNotNull() + .returns("signature", OptableAttributes::getId5Signature); + } + + @Test + public void shouldNotResolveId5SignatureWhenAbsentInUserExtOptable() { + // given + final BidRequest bidRequest = givenBidRequestWithId5Signature(null); + final AuctionContext auctionContext = givenAuctionContext(bidRequest, tcfContext, gppContext); + + // when + final OptableAttributes result = OptableAttributesResolver.resolveAttributes( + auctionContext, properties.getTimeout()); + + // then + assertThat(result).isNotNull() + .returns(null, OptableAttributes::getId5Signature); + } + + private BidRequest givenBidRequestWithId5Signature(String signature) { + final ObjectNode optable = mapper.createObjectNode(); + if (StringUtils.isNotEmpty(signature)) { + optable.set("id5_signature", TextNode.valueOf(signature)); + } + + final ExtUser extUser = ExtUser.builder().build(); + extUser.addProperty("optable", optable); + final User user = User.builder().ext(extUser).build(); + + return BidRequest.builder().user(user).build(); + } + + private BidRequest givenBidRequestWithGdprORTB26(boolean isGdprEnabled, String consent) { + final User user = User.builder() + .consent(consent) + .build(); + + return BidRequest.builder() + .user(user) + .regs(Regs.builder() + .gdpr(isGdprEnabled ? 1 : 0) + .build()) + .build(); + } + + private BidRequest givenBidRequestWithGdprORTB25(boolean isGdprEnabled, String consent) { + final User user = User.builder() + .ext(ExtUser.builder() + .consent(consent) + .build()) + .build(); + + return BidRequest.builder() + .user(user) + .regs(Regs.builder() + .ext(ExtRegs.of(isGdprEnabled ? 1 : 0, null, null, null)) + .build()) + .build(); + } + public AuctionContext givenAuctionContext(BidRequest bidRequest, TcfContext tcfContext, GppContext gppContext) { return AuctionContext.builder() .bidRequest(bidRequest) diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java index 25d9992110e..64aff679be4 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java @@ -310,6 +310,34 @@ public void shouldNotAppendBundleAndVerWhenAppIsNull() { assertThat(query).doesNotContain("&bundle=", "&ver="); } + @Test + public void shouldAppendId5SignatureWhenPresent() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .id5Signature("signature") + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + assertThat(query).contains("&id5_signature=signature"); + } + + @Test + public void shouldNotAppendId5SignatureWhenNull() { + // given + final OptableAttributes attributes = OptableAttributes.builder().build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final String query = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + assertThat(query).doesNotContain("&id5_signature="); + } + private OptableAttributes givenOptableAttributes() { return OptableAttributes.builder() .timeout(100L) From cb1b0176ef90831c84da254f095c1bd01acc70e7 Mon Sep 17 00:00:00 2001 From: softcoder Date: Sun, 26 Jul 2026 12:35:44 +0200 Subject: [PATCH 04/25] optable-targeting: Add id5_signature propagation from Targeting API to client --- .../model/openrtb/TargetingResult.java | 3 + .../OptableTargetingAuctionResponseHook.java | 38 ++- ...eTargetingProcessedAuctionRequestHook.java | 2 +- .../targeting/v1/core/Id5Resolver.java | 52 +++++ .../core/Id5SignatureBidResponseEnricher.java | 83 +++++++ .../targeting/v1/net/CachedAPIClient.java | 2 +- .../optable/targeting/v1/BaseOptableTest.java | 32 ++- ...tableTargetingAuctionResponseHookTest.java | 136 ++++++++++- .../optable/targeting/v1/core/CacheTest.java | 3 +- .../targeting/v1/core/Id5ResolverTest.java | 217 ++++++++++++++++++ .../Id5SignatureBidResponseEnricherTest.java | 122 ++++++++++ .../configs/prebid-config-with-optable.yaml | 2 +- 12 files changed, 679 insertions(+), 13 deletions(-) create mode 100644 extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java create mode 100644 extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5SignatureBidResponseEnricher.java create mode 100644 extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java create mode 100644 extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5SignatureBidResponseEnricherTest.java diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/TargetingResult.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/TargetingResult.java index 826ce2698ed..5bc11a92bd3 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/TargetingResult.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/openrtb/TargetingResult.java @@ -1,5 +1,6 @@ package org.prebid.server.hooks.modules.optable.targeting.model.openrtb; +import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.Value; import java.util.List; @@ -10,4 +11,6 @@ public class TargetingResult { List audience; Ortb2 ortb2; + + ObjectNode refs; } diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java index 8edef6b00eb..a3f0467d7c9 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java @@ -9,10 +9,13 @@ import org.prebid.server.hooks.modules.optable.targeting.model.Status; import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties; import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Audience; +import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult; import org.prebid.server.hooks.modules.optable.targeting.v1.core.AnalyticTagsResolver; import org.prebid.server.hooks.modules.optable.targeting.v1.core.AuctionResponseValidator; import org.prebid.server.hooks.modules.optable.targeting.v1.core.BidResponseEnricher; import org.prebid.server.hooks.modules.optable.targeting.v1.core.ConfigResolver; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.Id5Resolver; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.Id5SignatureBidResponseEnricher; import org.prebid.server.hooks.v1.InvocationAction; import org.prebid.server.hooks.v1.InvocationResult; import org.prebid.server.hooks.v1.InvocationStatus; @@ -62,15 +65,42 @@ public Future> call(AuctionResponsePayl return validationStatus.getStatus() == Status.SUCCESS ? enrichedPayload(moduleContext) - : success(moduleContext); + : enrichedById5SignaturePayload(moduleContext); } private Future> enrichedPayload(ModuleContext moduleContext) { final List targeting = moduleContext.getTargeting(); - return CollectionUtils.isNotEmpty(targeting) - ? update(BidResponseEnricher.of(targeting, objectMapper, jsonMerger), moduleContext) - : success(moduleContext); + return moduleContext.getOptableTargetingCall() + .compose(targetingResult -> + CollectionUtils.isNotEmpty(targeting) + ? update(fullEnrichmentChain(moduleContext, targeting, targetingResult), moduleContext) + : update(id5SignatureEnrichmentChain(targetingResult), moduleContext)) + .recover(throwable -> success(moduleContext)); + } + + private Future> enrichedById5SignaturePayload( + ModuleContext moduleContext) { + + return moduleContext.getOptableTargetingCall() + .compose(targetingResult -> + update(id5SignatureEnrichmentChain(targetingResult), moduleContext)) + .recover(throwable -> success(moduleContext)); + } + + private PayloadUpdate fullEnrichmentChain(ModuleContext moduleContext, + final List targeting, + TargetingResult targetingResult) { + final String id5Signature = Id5Resolver.resolveId5Signature(targetingResult); + + return BidResponseEnricher.of(targeting, objectMapper, jsonMerger) + .andThen(Id5SignatureBidResponseEnricher.of(id5Signature, objectMapper, jsonMerger))::apply; + } + + private PayloadUpdate id5SignatureEnrichmentChain(TargetingResult targetingResult) { + final String id5Signature = Id5Resolver.resolveId5Signature(targetingResult); + + return Id5SignatureBidResponseEnricher.of(id5Signature, objectMapper, jsonMerger); } private Future> update( diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHook.java index dfa8520f157..384e5c21f1b 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHook.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHook.java @@ -11,8 +11,8 @@ import org.prebid.server.hooks.modules.optable.targeting.v1.core.BidRequestEnricher; import org.prebid.server.hooks.modules.optable.targeting.v1.core.CompositeHookExecutionPlan; import org.prebid.server.hooks.modules.optable.targeting.v1.core.ConfigResolver; -import org.prebid.server.hooks.modules.optable.targeting.v1.core.TargetingRequestExecutor; import org.prebid.server.hooks.modules.optable.targeting.v1.core.PropertiesValidator; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.TargetingRequestExecutor; import org.prebid.server.hooks.v1.InvocationAction; import org.prebid.server.hooks.v1.InvocationResult; import org.prebid.server.hooks.v1.InvocationStatus; diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java new file mode 100644 index 00000000000..d7a17c1365a --- /dev/null +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java @@ -0,0 +1,52 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +import com.fasterxml.jackson.databind.JsonNode; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.Uid; +import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Ortb2; +import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult; +import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.User; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public class Id5Resolver { + + private Id5Resolver() { + + } + + public static String resolveId5Signature(TargetingResult targetingResult) { + if (targetingResult == null) { + return null; + } + + final String ref = Optional.of(targetingResult) + .map(TargetingResult::getOrtb2) + .map(Ortb2::getUser) + .map(User::getEids) + .orElseGet(List::of) + .stream() + .filter(it -> "optable.co".equals(it.getInserter()) && "id5-sync.com".equals(it.getSource())) + .map(Eid::getUids) + .flatMap(Collection::stream) + .map(Uid::getExt) + .map(it -> it.at("/optable/ref")) + .filter(Objects::nonNull) + .map(JsonNode::asText) + .findFirst() + .orElse(null); + + if (ref == null) { + return null; + } + + return Optional.ofNullable(targetingResult.getRefs()) + .map(refs -> refs.get(ref)) + .map(it -> it.get("signature")) + .map(JsonNode::asText) + .orElse(null); + } +} diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5SignatureBidResponseEnricher.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5SignatureBidResponseEnricher.java new file mode 100644 index 00000000000..e94af71928d --- /dev/null +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5SignatureBidResponseEnricher.java @@ -0,0 +1,83 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.iab.openrtb.response.BidResponse; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.hooks.execution.v1.auction.AuctionResponsePayloadImpl; +import org.prebid.server.hooks.v1.PayloadUpdate; +import org.prebid.server.hooks.v1.auction.AuctionResponsePayload; +import org.prebid.server.json.JsonMerger; +import org.prebid.server.proto.openrtb.ext.response.ExtBidResponse; +import org.prebid.server.proto.openrtb.ext.response.ExtBidResponsePrebid; + +import java.util.Objects; + +public class Id5SignatureBidResponseEnricher implements PayloadUpdate { + + private final String id5Signature; + private final ObjectMapper mapper; + private final JsonMerger jsonMerger; + + private Id5SignatureBidResponseEnricher(String id5Signature, ObjectMapper mapper, JsonMerger jsonMerger) { + this.id5Signature = id5Signature; + this.mapper = Objects.requireNonNull(mapper); + this.jsonMerger = Objects.requireNonNull(jsonMerger); + } + + public static Id5SignatureBidResponseEnricher of(String id5Signature, ObjectMapper mapper, JsonMerger jsonMerger) { + return new Id5SignatureBidResponseEnricher(id5Signature, mapper, jsonMerger); + } + + @Override + public AuctionResponsePayload apply(AuctionResponsePayload payload) { + return AuctionResponsePayloadImpl.of(enrichBidResponse(payload.bidResponse(), id5Signature)); + } + + private BidResponse enrichBidResponse(BidResponse bidResponse, String id5Signature) { + if (StringUtils.isEmpty(id5Signature)) { + return bidResponse; + } + final ObjectNode passthroughNode = id5SignatureToObjectNode(id5Signature); + + final ExtBidResponse existingExt = bidResponse.getExt(); + final ExtBidResponsePrebid existingPrebid = existingExt != null ? existingExt.getPrebid() : null; + final JsonNode existingPassthrough = existingPrebid != null ? existingPrebid.getPassthrough() : null; + + final JsonNode mergedPassthrough = existingPassthrough != null + ? jsonMerger.merge(passthroughNode, existingPassthrough) + : passthroughNode; + + final ExtBidResponsePrebid modifiedPrebid = existingPrebid != null + ? existingPrebid.toBuilder() + .passthrough(mergedPassthrough) + .build() + : ExtBidResponsePrebid.builder() + .passthrough(mergedPassthrough) + .build(); + + final ExtBidResponse modifiedExt = existingExt != null + ? existingExt.toBuilder() + .prebid(modifiedPrebid) + .build() + : ExtBidResponse.builder() + .prebid(modifiedPrebid) + .build(); + + return bidResponse.toBuilder() + .ext(modifiedExt) + .build(); + } + + private ObjectNode id5SignatureToObjectNode(String id5Signature) { + final ObjectNode node = mapper.createObjectNode(); + node.set("id5_signature", TextNode.valueOf(id5Signature)); + + final ObjectNode optableNode = mapper.createObjectNode(); + optableNode.set("optable", node); + + return optableNode; + } +} diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClient.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClient.java index e7e8bc3e452..eabccfdbff0 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClient.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClient.java @@ -42,7 +42,7 @@ public Future getTargeting(OptableTargetingProperties propertie return cache.get(createCachingKey(tenant, origin, ips, query, true)) .recover(ignore -> apiClient.getTargeting(properties, query, ips, userAgent, timeout) .recover(throwable -> isCircuitBreakerEnabled - ? Future.succeededFuture(new TargetingResult(null, null)) + ? Future.succeededFuture(new TargetingResult(null, null, null)) : Future.failedFuture(throwable)) .compose(result -> cache.put( createCachingKey(tenant, origin, ips, query, false), diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/BaseOptableTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/BaseOptableTest.java index 4bfb1947b5f..2ebf65c7b6e 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/BaseOptableTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/BaseOptableTest.java @@ -17,6 +17,7 @@ import com.iab.openrtb.response.BidResponse; import com.iab.openrtb.response.SeatBid; import io.netty.handler.codec.http.HttpResponseStatus; +import io.vertx.core.Future; import io.vertx.core.MultiMap; import io.vertx.core.http.HttpHeaders; import org.apache.commons.io.IOUtils; @@ -73,6 +74,28 @@ protected ModuleContext givenModuleContext(List audiences) { return moduleContext; } + protected ModuleContext givenModuleContext(List audiences, Future optableTargetingCall) { + final ModuleContext moduleContext = givenModuleContext(audiences); + moduleContext.setOptableTargetingCall(optableTargetingCall); + return moduleContext; + } + + protected Eid givenId5Eid(String refValue) { + final ObjectNode uidExt = mapper.createObjectNode(); + uidExt.set("optable", mapper.createObjectNode().set("ref", TextNode.valueOf(refValue))); + return Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) + .build(); + } + + protected ObjectNode givenRefsObject(String refValue, String signature) { + final ObjectNode refs = mapper.createObjectNode(); + refs.set(refValue, mapper.createObjectNode().set("signature", TextNode.valueOf(signature))); + return refs; + } + protected AuctionContext givenAuctionContext(ActivityInfrastructure activityInfrastructure, Timeout timeout, Account account) { @@ -168,17 +191,22 @@ protected TargetingResult givenTargetingResult() { } protected TargetingResult givenTargetingResult(List eids, List data) { + return givenTargetingResult(eids, data, null); + } + + protected TargetingResult givenTargetingResult(List eids, List data, ObjectNode refs) { return new TargetingResult( List.of(new Audience( "provider", List.of(new AudienceId("id")), "keyspace", 1)), - new Ortb2(new org.prebid.server.hooks.modules.optable.targeting.model.openrtb.User(eids, data))); + new Ortb2(new org.prebid.server.hooks.modules.optable.targeting.model.openrtb.User(eids, data)), + refs); } protected TargetingResult givenEmptyTargetingResult() { - return new TargetingResult(Collections.emptyList(), new Ortb2(null)); + return new TargetingResult(Collections.emptyList(), new Ortb2(null), null); } protected User givenUser() { diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java index 7d1df51bc5e..29429910f8d 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java @@ -1,6 +1,8 @@ package org.prebid.server.hooks.modules.optable.targeting.v1; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.Eid; import com.iab.openrtb.response.BidResponse; import io.vertx.core.Future; import org.junit.jupiter.api.BeforeEach; @@ -12,6 +14,7 @@ import org.prebid.server.hooks.modules.optable.targeting.model.ModuleContext; import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Audience; import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.AudienceId; +import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult; import org.prebid.server.hooks.modules.optable.targeting.v1.core.ConfigResolver; import org.prebid.server.hooks.v1.InvocationAction; import org.prebid.server.hooks.v1.InvocationResult; @@ -60,7 +63,8 @@ public void shouldHaveCode() { @Test public void shouldReturnResultWithNoActionAndWithPBSAnalyticsTags() { // given - when(invocationContext.moduleContext()).thenReturn(givenModuleContext()); + when(invocationContext.moduleContext()).thenReturn( + givenModuleContext(null, Future.failedFuture(new RuntimeException("error")))); // when final Future> future = @@ -93,7 +97,8 @@ public void shouldReturnResultWithUpdateActionWhenAdvertiserTargetingOptionIsOn( "provider", List.of(new AudienceId("audienceId")), "keyspace", - 1)))); + 1)), + Future.succeededFuture(givenEmptyTargetingResult()))); when(auctionResponsePayload.bidResponse()).thenReturn(givenBidResponse()); // when @@ -126,6 +131,130 @@ public void shouldReturnResultWithUpdateActionWhenAdvertiserTargetingOptionIsOn( assertThat(targeting.get("keyspace").asText()).isEqualTo("audienceId"); } + @Test + public void shouldEnrichBidResponseWithBothTargetingKeywordsAndId5Signature() { + // given + final String refValue = "refValue"; + final String signature = "id5Signature"; + final Eid id5Eid = givenId5Eid(refValue); + final ObjectNode refs = givenRefsObject(refValue, signature); + final TargetingResult targetingResult = givenTargetingResult(List.of(id5Eid), null, refs); + + when(invocationContext.moduleContext()).thenReturn(givenModuleContext( + List.of(new Audience( + "provider", + List.of(new AudienceId("audienceId")), + "keyspace", + 1)), + Future.succeededFuture(targetingResult))); + when(auctionResponsePayload.bidResponse()).thenReturn(givenBidResponse()); + + // when + final Future> future = + target.call(auctionResponsePayload, invocationContext); + final InvocationResult result = future.result(); + final BidResponse bidResponse = result + .payloadUpdate() + .apply(AuctionResponsePayloadImpl.of(givenBidResponse())) + .bidResponse(); + final ObjectNode targeting = (ObjectNode) bidResponse.getSeatbid() + .getFirst() + .getBid() + .getFirst() + .getExt() + .get("prebid") + .get("targeting"); + final JsonNode passthrough = bidResponse.getExt().getPrebid().getPassthrough(); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action); + + assertThat(targeting.get("keyspace").asText()).isEqualTo("audienceId"); + assertThat(passthrough).isNotNull(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo(signature); + } + + @Test + public void shouldEnrichBidResponseWithId5SignatureOnlyWhenNoTargeting() { + // given + final String refValue = "refValue"; + final String signature = "id5Signature"; + final Eid id5Eid = givenId5Eid(refValue); + final ObjectNode refs = givenRefsObject(refValue, signature); + final TargetingResult targetingResult = givenTargetingResult(List.of(id5Eid), null, refs); + + when(invocationContext.moduleContext()).thenReturn( + givenModuleContext(null, Future.succeededFuture(targetingResult))); + when(auctionResponsePayload.bidResponse()).thenReturn(givenBidResponse()); + + // when + final Future> future = + target.call(auctionResponsePayload, invocationContext); + final InvocationResult result = future.result(); + final BidResponse bidResponse = result + .payloadUpdate() + .apply(AuctionResponsePayloadImpl.of(givenBidResponse())) + .bidResponse(); + final JsonNode passthrough = bidResponse.getExt().getPrebid().getPassthrough(); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action); + assertThat(passthrough).isNotNull(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo(signature); + } + + @Test + public void shouldReturnNoActionWhenOptableTargetingCallFailsAndTargetingIsPresent() { + // given + when(invocationContext.moduleContext()).thenReturn(givenModuleContext( + List.of(new Audience( + "provider", + List.of(new AudienceId("audienceId")), + "keyspace", + 1)), + Future.failedFuture(new RuntimeException("error")))); + when(auctionResponsePayload.bidResponse()).thenReturn(givenBidResponse()); + + // when + final Future> future = + target.call(auctionResponsePayload, invocationContext); + final InvocationResult result = future.result(); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + } + + @Test + public void shouldReturnNoActionWhenOptableTargetingCallFailsAndTargetingIsEmpty() { + // given + when(invocationContext.moduleContext()).thenReturn( + givenModuleContext(null, Future.failedFuture(new RuntimeException("error")))); + + // when + final Future> future = + target.call(auctionResponsePayload, invocationContext); + final InvocationResult result = future.result(); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + } + @Test public void shouldReturnResultWithNoActionWhenAdvertiserTargetingOptionIsOff() { // given @@ -134,7 +263,8 @@ public void shouldReturnResultWithNoActionWhenAdvertiserTargetingOptionIsOff() { "provider", List.of(new AudienceId("audienceId")), "keyspace", - 1)))); + 1)), + Future.failedFuture(new RuntimeException("error")))); // when final Future> future = diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/CacheTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/CacheTest.java index 1917fd6ca27..e0a723bbfa3 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/CacheTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/CacheTest.java @@ -109,6 +109,7 @@ private TargetingResult givenTargetingResult() { List.of(new AudienceId("1")), "keyspace", 0)), - new Ortb2(new User(null, null))); + new Ortb2(new User(null, null)), + null); } } diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java new file mode 100644 index 00000000000..b0b9be37b21 --- /dev/null +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java @@ -0,0 +1,217 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.iab.openrtb.request.Eid; +import com.iab.openrtb.request.Uid; +import org.junit.jupiter.api.Test; +import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Ortb2; +import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult; +import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.User; +import org.prebid.server.json.ObjectMapperProvider; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class Id5ResolverTest { + + @Test + public void shouldReturnNullWhenTargetingResultIsNull() { + // when + final String result = Id5Resolver.resolveId5Signature(null); + + // then + assertThat(result).isNull(); + } + + @Test + public void shouldReturnNullWhenOrtb2IsNull() { + // given + final TargetingResult targetingResult = new TargetingResult(List.of(), null, null); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isNull(); + } + + @Test + public void shouldReturnNullWhenUserHasNoEids() { + // given + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(null, null)), + null); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isNull(); + } + + @Test + public void shouldReturnNullWhenEidsDoNotMatchOptableAndId5Source() { + // given + final Eid eid = Eid.builder() + .source("other-source") + .inserter("other-inserter") + .uids(List.of(Uid.builder().id("id").build())) + .build(); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(eid), null)), + null); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isNull(); + } + + @Test + public void shouldReturnNullWhenMatchingEidButRefsAreAbsent() { + // given + final ObjectNode uidExt = ObjectMapperProvider.mapper().createObjectNode(); + uidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + .set("ref", TextNode.valueOf("refValue"))); + + final Eid eid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) + .build(); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(eid), null)), + null); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isNull(); + } + + @Test + public void shouldReturnNullWhenRefsDoNotContainResolvedRef() { + // given + final ObjectNode uidExt = ObjectMapperProvider.mapper().createObjectNode(); + uidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + .set("ref", TextNode.valueOf("refValue"))); + + final Eid eid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) + .build(); + final ObjectNode refs = ObjectMapperProvider.mapper().createObjectNode(); + refs.set("otherRef", ObjectMapperProvider.mapper().createObjectNode() + .set("signature", TextNode.valueOf("signatureValue"))); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(eid), null)), + refs); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isNull(); + } + + @Test + public void shouldReturnSignatureWhenAllConditionsAreMet() { + // given + final ObjectNode uidExt = ObjectMapperProvider.mapper().createObjectNode(); + uidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + .set("ref", TextNode.valueOf("refValue"))); + + final Eid eid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) + .build(); + final ObjectNode refs = ObjectMapperProvider.mapper().createObjectNode(); + refs.set("refValue", ObjectMapperProvider.mapper().createObjectNode() + .set("signature", TextNode.valueOf("signatureValue"))); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(eid), null)), + refs); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isEqualTo("signatureValue"); + } + + @Test + public void shouldReturnSignatureWhenMultipleMatchingEidsExist() { + // given + final ObjectNode firstUidExt = ObjectMapperProvider.mapper().createObjectNode(); + firstUidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + .set("ref", TextNode.valueOf("firstRef"))); + + final ObjectNode secondUidExt = ObjectMapperProvider.mapper().createObjectNode(); + secondUidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + .set("ref", TextNode.valueOf("secondRef"))); + + final Eid firstEid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id1").ext(firstUidExt).build())) + .build(); + final Eid secondEid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id2").ext(secondUidExt).build())) + .build(); + final ObjectNode refs = ObjectMapperProvider.mapper().createObjectNode(); + refs.set("firstRef", ObjectMapperProvider.mapper().createObjectNode() + .set("signature", TextNode.valueOf("firstSignature"))); + refs.set("secondRef", ObjectMapperProvider.mapper().createObjectNode() + .set("signature", TextNode.valueOf("secondSignature"))); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(firstEid, secondEid), null)), + refs); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isEqualTo("firstSignature"); + } + + @Test + public void shouldReturnNullWhenRefEntryHasNoSignature() { + // given + final ObjectNode uidExt = ObjectMapperProvider.mapper().createObjectNode(); + uidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + .set("ref", TextNode.valueOf("refValue"))); + + final Eid eid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) + .build(); + final ObjectNode refs = ObjectMapperProvider.mapper().createObjectNode(); + refs.set("refValue", ObjectMapperProvider.mapper().createObjectNode() + .set("other", TextNode.valueOf("value"))); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(eid), null)), + refs); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isNull(); + } +} diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5SignatureBidResponseEnricherTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5SignatureBidResponseEnricherTest.java new file mode 100644 index 00000000000..d9eb8bd1798 --- /dev/null +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5SignatureBidResponseEnricherTest.java @@ -0,0 +1,122 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.iab.openrtb.response.BidResponse; +import org.junit.jupiter.api.Test; +import org.prebid.server.hooks.execution.v1.auction.AuctionResponsePayloadImpl; +import org.prebid.server.hooks.v1.auction.AuctionResponsePayload; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.json.JsonMerger; +import org.prebid.server.json.ObjectMapperProvider; +import org.prebid.server.proto.openrtb.ext.response.ExtBidResponse; +import org.prebid.server.proto.openrtb.ext.response.ExtBidResponsePrebid; + +import static org.assertj.core.api.Assertions.assertThat; + +public class Id5SignatureBidResponseEnricherTest { + + private final JacksonMapper jacksonMapper = new JacksonMapper(ObjectMapperProvider.mapper()); + private final JsonMerger jsonMerger = new JsonMerger(jacksonMapper); + + @Test + public void shouldReturnOriginBidResponseWhenId5SignatureIsNull() { + // given + final BidResponse bidResponse = BidResponse.builder().build(); + final AuctionResponsePayload payload = AuctionResponsePayloadImpl.of(bidResponse); + final Id5SignatureBidResponseEnricher enricher = + Id5SignatureBidResponseEnricher.of(null, ObjectMapperProvider.mapper(), jsonMerger); + + // when + final AuctionResponsePayload result = enricher.apply(payload); + + // then + assertThat(result.bidResponse()).isSameAs(bidResponse); + } + + @Test + public void shouldReturnOriginBidResponseWhenId5SignatureIsEmpty() { + // given + final BidResponse bidResponse = BidResponse.builder().build(); + final AuctionResponsePayload payload = AuctionResponsePayloadImpl.of(bidResponse); + final Id5SignatureBidResponseEnricher enricher = + Id5SignatureBidResponseEnricher.of("", ObjectMapperProvider.mapper(), jsonMerger); + + // when + final AuctionResponsePayload result = enricher.apply(payload); + + // then + assertThat(result.bidResponse()).isSameAs(bidResponse); + } + + @Test + public void shouldAddId5SignatureToPassthroughWhenExtIsAbsent() { + // given + final BidResponse bidResponse = BidResponse.builder().build(); + final AuctionResponsePayload payload = AuctionResponsePayloadImpl.of(bidResponse); + final Id5SignatureBidResponseEnricher enricher = + Id5SignatureBidResponseEnricher.of("signature", ObjectMapperProvider.mapper(), jsonMerger); + + // when + final AuctionResponsePayload result = enricher.apply(payload); + + // then + final BidResponse enriched = result.bidResponse(); + assertThat(enriched.getExt()).isNotNull(); + assertThat(enriched.getExt().getPrebid()).isNotNull(); + final JsonNode passthrough = enriched.getExt().getPrebid().getPassthrough(); + assertThat(passthrough).isNotNull(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo("signature"); + } + + @Test + public void shouldMergeId5SignatureWithExistingPassthrough() { + // given + final ObjectNode existingPassthrough = ObjectMapperProvider.mapper().createObjectNode(); + existingPassthrough.set("other", ObjectMapperProvider.mapper().createObjectNode() + .set("value", TextNode.valueOf("otherValue"))); + existingPassthrough.set("optable", ObjectMapperProvider.mapper().createObjectNode() + .set("existing", TextNode.valueOf("preserved"))); + + final ExtBidResponse ext = ExtBidResponse.builder() + .prebid(ExtBidResponsePrebid.builder() + .passthrough(existingPassthrough) + .build()) + .build(); + final BidResponse bidResponse = BidResponse.builder().ext(ext).build(); + final AuctionResponsePayload payload = AuctionResponsePayloadImpl.of(bidResponse); + final Id5SignatureBidResponseEnricher enricher = + Id5SignatureBidResponseEnricher.of("signature", ObjectMapperProvider.mapper(), jsonMerger); + + // when + final AuctionResponsePayload result = enricher.apply(payload); + + // then + final JsonNode passthrough = result.bidResponse().getExt().getPrebid().getPassthrough(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo("signature"); + assertThat(passthrough.get("optable").get("existing").asText()).isEqualTo("preserved"); + assertThat(passthrough.get("other").get("value").asText()).isEqualTo("otherValue"); + } + + @Test + public void shouldPreserveExistingPrebidFieldsWhenAddingPassthrough() { + // given + final ExtBidResponse ext = ExtBidResponse.builder() + .prebid(ExtBidResponsePrebid.builder().build()) + .build(); + final BidResponse bidResponse = BidResponse.builder().ext(ext).build(); + final AuctionResponsePayload payload = AuctionResponsePayloadImpl.of(bidResponse); + final Id5SignatureBidResponseEnricher enricher = + Id5SignatureBidResponseEnricher.of("signature", ObjectMapperProvider.mapper(), jsonMerger); + + // when + final AuctionResponsePayload result = enricher.apply(payload); + + // then + final ExtBidResponsePrebid prebid = result.bidResponse().getExt().getPrebid(); + assertThat(prebid).isNotNull(); + assertThat(prebid.getPassthrough().get("optable").get("id5_signature").asText()) + .isEqualTo("signature"); + } +} diff --git a/sample/configs/prebid-config-with-optable.yaml b/sample/configs/prebid-config-with-optable.yaml index 0aa9851498e..4dd99c65dda 100644 --- a/sample/configs/prebid-config-with-optable.yaml +++ b/sample/configs/prebid-config-with-optable.yaml @@ -50,4 +50,4 @@ hooks: enabled: true modules: optable-targeting: - api-endpoint: https://ca.edge.optable.co/v2/targeting?t={TENANT}&o={ORIGIN} + api-endpoint: https://na.edge.optable.co/v2/targeting?t={TENANT}&o={ORIGIN} From b731a01bb91a0416e83758f2c8ad001b0fec3287 Mon Sep 17 00:00:00 2001 From: softcoder Date: Sun, 26 Jul 2026 16:40:37 +0200 Subject: [PATCH 05/25] optable-targeting: code cleanup --- .../optable/targeting/v1/core/Id5Resolver.java | 15 ++++++++++++--- .../v1/core/OptableAttributesResolver.java | 4 +++- .../optable/targeting/v1/core/QueryBuilder.java | 6 ++++-- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java index d7a17c1365a..e9ba337fb3b 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java @@ -14,6 +14,12 @@ public class Id5Resolver { + public static final String STR_OPTABLE_CO = "optable.co"; + public static final String STR_ID_5_SYNC_COM = "id5-sync.com"; + public static final String STR_OPTABLE = "optable"; + public static final String STR_REF = "ref"; + public static final String STR_SIGNATURE = "signature"; + private Id5Resolver() { } @@ -29,11 +35,14 @@ public static String resolveId5Signature(TargetingResult targetingResult) { .map(User::getEids) .orElseGet(List::of) .stream() - .filter(it -> "optable.co".equals(it.getInserter()) && "id5-sync.com".equals(it.getSource())) + .filter(it -> STR_OPTABLE_CO.equals(it.getInserter()) && STR_ID_5_SYNC_COM.equals(it.getSource())) .map(Eid::getUids) .flatMap(Collection::stream) .map(Uid::getExt) - .map(it -> it.at("/optable/ref")) + .filter(Objects::nonNull) + .map(it -> it.get(STR_OPTABLE)) + .filter(Objects::nonNull) + .map(it -> it.get(STR_REF)) .filter(Objects::nonNull) .map(JsonNode::asText) .findFirst() @@ -45,7 +54,7 @@ public static String resolveId5Signature(TargetingResult targetingResult) { return Optional.ofNullable(targetingResult.getRefs()) .map(refs -> refs.get(ref)) - .map(it -> it.get("signature")) + .map(it -> it.get(STR_SIGNATURE)) .map(JsonNode::asText) .orElse(null); } diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java index 0122fca451d..b655f04cb61 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java @@ -68,7 +68,9 @@ public static OptableAttributes resolveAttributes(AuctionContext auctionContext, .map(ext -> ext.getProperty("optable")) .map(OptableAttributesResolver::parseExtUserOptable); - extUserOptable.map(ExtUserOptable::getId5Signature).ifPresent(builder::id5Signature); + if (extUserOptable.isPresent()) { + extUserOptable.map(ExtUserOptable::getId5Signature).ifPresent(builder::id5Signature); + } return builder.build(); } diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java index 00aca36e8f8..9e6c0da251a 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java @@ -49,7 +49,8 @@ private static String buildHidString(List ids, String hidPrefixesString) { if (CollectionUtils.isEmpty(ids) || StringUtils.isEmpty(hidPrefixesString)) { return StringUtils.EMPTY; } - final Map prefixToIdValue = ids.stream().collect(Collectors.toMap(Id::getName, it -> it)); + final Map prefixToIdValue = ids.stream() + .collect(Collectors.toMap(Id::getName, it -> it, (a, b) -> b)); if (MapUtils.isEmpty(prefixToIdValue)) { return StringUtils.EMPTY; } @@ -135,7 +136,8 @@ private static String buildAttributesString(OptableAttributes optableAttributes) }); Optional.ofNullable(optableAttributes.getId5Signature()) - .ifPresent(id5Signature -> sb.append("&id5_signature=").append(id5Signature)); + .ifPresent(id5Signature -> sb.append("&id5_signature=") + .append(URLEncoder.encode(id5Signature, StandardCharsets.UTF_8))); return sb.toString(); } From c970cc6342ebafa7c060faaa13f36913fba27778 Mon Sep 17 00:00:00 2001 From: softcoder Date: Mon, 27 Jul 2026 19:37:18 +0200 Subject: [PATCH 06/25] optable-targeting: fix NPE when optableTargetingCall was never set at resolving id5Signature --- .../targeting/model/ModuleContext.java | 2 + .../v1/OptableBidderRequestHook.java | 2 + .../OptableTargetingAuctionResponseHook.java | 26 ++++------- ...eTargetingProcessedAuctionRequestHook.java | 2 + .../v1/OptableBidderRequestHookTest.java | 46 +++++++++++++++++++ ...tableTargetingAuctionResponseHookTest.java | 43 +++++++++-------- ...getingProcessedAuctionRequestHookTest.java | 44 ++++++++++++++++++ 7 files changed, 129 insertions(+), 36 deletions(-) diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/ModuleContext.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/ModuleContext.java index 5574a7a72d8..ac7e30eec8d 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/ModuleContext.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/ModuleContext.java @@ -35,6 +35,8 @@ public class ModuleContext { private boolean shouldSkipEnrichment; + private String id5Signature; + public static ModuleContext of(AuctionInvocationContext invocationContext) { final ModuleContext moduleContext = (ModuleContext) invocationContext.moduleContext(); return moduleContext != null ? moduleContext : new ModuleContext(); diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java index b5ce4cd7531..f96335cb91d 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java @@ -10,6 +10,7 @@ import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult; import org.prebid.server.hooks.modules.optable.targeting.v1.core.AnalyticTagsResolver; import org.prebid.server.hooks.modules.optable.targeting.v1.core.BidderRequestEnricher; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.Id5Resolver; import org.prebid.server.hooks.v1.InvocationAction; import org.prebid.server.hooks.v1.InvocationResult; import org.prebid.server.hooks.v1.InvocationStatus; @@ -58,6 +59,7 @@ private Future> enrichedPayload(Targeting if (hasData) { moduleContext.setTargeting(targetingResult.getAudience()); + moduleContext.setId5Signature(Id5Resolver.resolveId5Signature(targetingResult)); moduleContext.setEnrichRequestStatus(EnrichmentStatus.success()); } diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java index a3f0467d7c9..1f98c18cc0d 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java @@ -9,12 +9,10 @@ import org.prebid.server.hooks.modules.optable.targeting.model.Status; import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties; import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Audience; -import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult; import org.prebid.server.hooks.modules.optable.targeting.v1.core.AnalyticTagsResolver; import org.prebid.server.hooks.modules.optable.targeting.v1.core.AuctionResponseValidator; import org.prebid.server.hooks.modules.optable.targeting.v1.core.BidResponseEnricher; import org.prebid.server.hooks.modules.optable.targeting.v1.core.ConfigResolver; -import org.prebid.server.hooks.modules.optable.targeting.v1.core.Id5Resolver; import org.prebid.server.hooks.modules.optable.targeting.v1.core.Id5SignatureBidResponseEnricher; import org.prebid.server.hooks.v1.InvocationAction; import org.prebid.server.hooks.v1.InvocationResult; @@ -70,36 +68,32 @@ public Future> call(AuctionResponsePayl private Future> enrichedPayload(ModuleContext moduleContext) { final List targeting = moduleContext.getTargeting(); + final String id5Signature = moduleContext.getId5Signature(); - return moduleContext.getOptableTargetingCall() - .compose(targetingResult -> - CollectionUtils.isNotEmpty(targeting) - ? update(fullEnrichmentChain(moduleContext, targeting, targetingResult), moduleContext) - : update(id5SignatureEnrichmentChain(targetingResult), moduleContext)) - .recover(throwable -> success(moduleContext)); + return CollectionUtils.isNotEmpty(targeting) + ? update(fullEnrichmentChain(targeting, id5Signature), moduleContext) + : update(id5SignatureEnrichmentChain(id5Signature), moduleContext); } private Future> enrichedById5SignaturePayload( ModuleContext moduleContext) { + final String id5Signature = moduleContext.getId5Signature(); + return moduleContext.getOptableTargetingCall() .compose(targetingResult -> - update(id5SignatureEnrichmentChain(targetingResult), moduleContext)) + update(id5SignatureEnrichmentChain(id5Signature), moduleContext)) .recover(throwable -> success(moduleContext)); } - private PayloadUpdate fullEnrichmentChain(ModuleContext moduleContext, - final List targeting, - TargetingResult targetingResult) { - final String id5Signature = Id5Resolver.resolveId5Signature(targetingResult); + private PayloadUpdate fullEnrichmentChain(final List targeting, + final String id5Signature) { return BidResponseEnricher.of(targeting, objectMapper, jsonMerger) .andThen(Id5SignatureBidResponseEnricher.of(id5Signature, objectMapper, jsonMerger))::apply; } - private PayloadUpdate id5SignatureEnrichmentChain(TargetingResult targetingResult) { - final String id5Signature = Id5Resolver.resolveId5Signature(targetingResult); - + private PayloadUpdate id5SignatureEnrichmentChain(String id5Signature) { return Id5SignatureBidResponseEnricher.of(id5Signature, objectMapper, jsonMerger); } diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHook.java index 384e5c21f1b..fc22fdc354c 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHook.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHook.java @@ -11,6 +11,7 @@ import org.prebid.server.hooks.modules.optable.targeting.v1.core.BidRequestEnricher; import org.prebid.server.hooks.modules.optable.targeting.v1.core.CompositeHookExecutionPlan; import org.prebid.server.hooks.modules.optable.targeting.v1.core.ConfigResolver; +import org.prebid.server.hooks.modules.optable.targeting.v1.core.Id5Resolver; import org.prebid.server.hooks.modules.optable.targeting.v1.core.PropertiesValidator; import org.prebid.server.hooks.modules.optable.targeting.v1.core.TargetingRequestExecutor; import org.prebid.server.hooks.v1.InvocationAction; @@ -105,6 +106,7 @@ private Future> enrichPayload( OptableTargetingProperties properties) { moduleContext.setTargeting(targetingResult.getAudience()); + moduleContext.setId5Signature(Id5Resolver.resolveId5Signature(targetingResult)); moduleContext.setEnrichRequestStatus(EnrichmentStatus.success()); final PayloadUpdate payloadUpdate = diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java index 2bcce42ac94..4180f8645cc 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java @@ -1,7 +1,9 @@ package org.prebid.server.hooks.modules.optable.targeting.v1; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Eid; import io.vertx.core.Future; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -14,6 +16,7 @@ import org.prebid.server.hooks.modules.optable.targeting.model.EnrichmentStatus; import org.prebid.server.hooks.modules.optable.targeting.model.ModuleContext; import org.prebid.server.hooks.modules.optable.targeting.model.Status; +import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult; import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties; import org.prebid.server.hooks.v1.InvocationAction; import org.prebid.server.hooks.v1.InvocationResult; @@ -23,6 +26,7 @@ import org.prebid.server.hooks.v1.bidder.BidderRequestPayload; import java.util.Collections; +import java.util.List; import java.util.Set; import java.util.concurrent.TimeoutException; @@ -170,6 +174,48 @@ public void shouldUpdateModuleContextWithTargetingOnSuccess() { .isEqualTo("success"); } + @Test + public void shouldSetId5SignatureOnModuleContextWhenTargetingResultHasId5Signature() { + // given + final String refValue = "refValue"; + final String signature = "id5Signature"; + final Eid id5Eid = givenId5Eid(refValue); + final ObjectNode refs = givenRefsObject(refValue, signature); + final TargetingResult targetingResult = givenTargetingResult(List.of(id5Eid), null, refs); + + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenPropertiesWithPerBidderEnrichmentEnabled()); + moduleContext.setBiddersToEnrich(Set.of("bidder1")); + moduleContext.setCallTargetingAPITimestamp(System.currentTimeMillis() - 100); + moduleContext.setOptableTargetingCall(Future.succeededFuture(targetingResult)); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + when(invocationContext.bidder()).thenReturn("bidder1"); + + // when + target.call(bidderRequestPayload, invocationContext); + + // then + assertThat(moduleContext.getId5Signature()).isEqualTo(signature); + } + + @Test + public void shouldNotSetId5SignatureOnModuleContextWhenTargetingResultHasNoId5Signature() { + // given + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenPropertiesWithPerBidderEnrichmentEnabled()); + moduleContext.setBiddersToEnrich(Set.of("bidder1")); + moduleContext.setCallTargetingAPITimestamp(System.currentTimeMillis() - 100); + moduleContext.setOptableTargetingCall(Future.succeededFuture(givenTargetingResult())); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + when(invocationContext.bidder()).thenReturn("bidder1"); + + // when + target.call(bidderRequestPayload, invocationContext); + + // then + assertThat(moduleContext.getId5Signature()).isNull(); + } + @Test public void shouldReturnNoActionWithNoDataOutcomeWhenTargetingResultHasNoUser() { // given diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java index 29429910f8d..5b37eeec80f 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java @@ -2,7 +2,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.iab.openrtb.request.Eid; import com.iab.openrtb.response.BidResponse; import io.vertx.core.Future; import org.junit.jupiter.api.BeforeEach; @@ -14,7 +13,6 @@ import org.prebid.server.hooks.modules.optable.targeting.model.ModuleContext; import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Audience; import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.AudienceId; -import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult; import org.prebid.server.hooks.modules.optable.targeting.v1.core.ConfigResolver; import org.prebid.server.hooks.v1.InvocationAction; import org.prebid.server.hooks.v1.InvocationResult; @@ -134,19 +132,15 @@ public void shouldReturnResultWithUpdateActionWhenAdvertiserTargetingOptionIsOn( @Test public void shouldEnrichBidResponseWithBothTargetingKeywordsAndId5Signature() { // given - final String refValue = "refValue"; final String signature = "id5Signature"; - final Eid id5Eid = givenId5Eid(refValue); - final ObjectNode refs = givenRefsObject(refValue, signature); - final TargetingResult targetingResult = givenTargetingResult(List.of(id5Eid), null, refs); - - when(invocationContext.moduleContext()).thenReturn(givenModuleContext( - List.of(new Audience( + final ModuleContext moduleContext = givenModuleContext(List.of( + new Audience( "provider", List.of(new AudienceId("audienceId")), "keyspace", - 1)), - Future.succeededFuture(targetingResult))); + 1))); + moduleContext.setId5Signature(signature); + when(invocationContext.moduleContext()).thenReturn(moduleContext); when(auctionResponsePayload.bidResponse()).thenReturn(givenBidResponse()); // when @@ -181,14 +175,11 @@ public void shouldEnrichBidResponseWithBothTargetingKeywordsAndId5Signature() { @Test public void shouldEnrichBidResponseWithId5SignatureOnlyWhenNoTargeting() { // given - final String refValue = "refValue"; final String signature = "id5Signature"; - final Eid id5Eid = givenId5Eid(refValue); - final ObjectNode refs = givenRefsObject(refValue, signature); - final TargetingResult targetingResult = givenTargetingResult(List.of(id5Eid), null, refs); - - when(invocationContext.moduleContext()).thenReturn( - givenModuleContext(null, Future.succeededFuture(targetingResult))); + final ModuleContext moduleContext = + givenModuleContext(null, Future.succeededFuture(givenEmptyTargetingResult())); + moduleContext.setId5Signature(signature); + when(invocationContext.moduleContext()).thenReturn(moduleContext); when(auctionResponsePayload.bidResponse()).thenReturn(givenBidResponse()); // when @@ -212,7 +203,7 @@ public void shouldEnrichBidResponseWithId5SignatureOnlyWhenNoTargeting() { } @Test - public void shouldReturnNoActionWhenOptableTargetingCallFailsAndTargetingIsPresent() { + public void shouldReturnUpdateActionWhenTargetingIsPresentEvenIfOptableTargetingCallFails() { // given when(invocationContext.moduleContext()).thenReturn(givenModuleContext( List.of(new Audience( @@ -227,13 +218,25 @@ public void shouldReturnNoActionWhenOptableTargetingCallFailsAndTargetingIsPrese final Future> future = target.call(auctionResponsePayload, invocationContext); final InvocationResult result = future.result(); + final BidResponse bidResponse = result + .payloadUpdate() + .apply(AuctionResponsePayloadImpl.of(givenBidResponse())) + .bidResponse(); + final ObjectNode targeting = (ObjectNode) bidResponse.getSeatbid() + .getFirst() + .getBid() + .getFirst() + .getExt() + .get("prebid") + .get("targeting"); // then assertThat(future).isNotNull(); assertThat(future.succeeded()).isTrue(); assertThat(result).isNotNull() .returns(InvocationStatus.success, InvocationResult::status) - .returns(InvocationAction.no_action, InvocationResult::action); + .returns(InvocationAction.update, InvocationResult::action); + assertThat(targeting.get("keyspace").asText()).isEqualTo("audienceId"); } @Test diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHookTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHookTest.java index 7387e8f85d5..0e149e857b2 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHookTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHookTest.java @@ -156,6 +156,50 @@ void callShouldReturnResultWithUpdateActionWhenOptableTargetingReturnsTargeting( .containsExactly("id"); } + @Test + void callShouldSetId5SignatureOnModuleContextWhenTargetingResultContainsId5Signature() { + // given + final String refValue = "refValue"; + final String signature = "id5Signature"; + final Eid id5Eid = givenId5Eid(refValue); + final ObjectNode refs = givenRefsObject(refValue, signature); + when(auctionRequestPayload.bidRequest()).thenReturn(givenBidRequest()); + when(optableTargeting.getTargeting(any(), any(), any(), any())) + .thenReturn(Future.succeededFuture(givenTargetingResult(List.of(id5Eid), null, refs))); + + // when + final Future> future = target.call(auctionRequestPayload, + invocationContext); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat((ModuleContext) future.result().moduleContext()) + .isNotNull() + .extracting(ModuleContext::getId5Signature) + .isEqualTo(signature); + } + + @Test + void callShouldLeaveId5SignatureNullWhenTargetingResultHasNoId5Signature() { + // given + when(auctionRequestPayload.bidRequest()).thenReturn(givenBidRequest()); + when(optableTargeting.getTargeting(any(), any(), any(), any())) + .thenReturn(Future.succeededFuture(givenTargetingResult())); + + // when + final Future> future = target.call(auctionRequestPayload, + invocationContext); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat((ModuleContext) future.result().moduleContext()) + .isNotNull() + .extracting(ModuleContext::getId5Signature) + .isNull(); + } + @Test void callShouldReturnResultWithUpdateActionWhenEarlyOptableCallIsEnabled() { // given From fa4a6d29ced5e9a0098dac2dbf07616de74689e5 Mon Sep 17 00:00:00 2001 From: softcoder Date: Mon, 27 Jul 2026 20:04:55 +0200 Subject: [PATCH 07/25] optable-targeting: Encode Targeting API query params --- .../targeting/v1/core/QueryBuilder.java | 7 +++-- .../targeting/v1/core/QueryBuilderTest.java | 30 +++++++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java index 9e6c0da251a..ed3c5905222 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java @@ -58,7 +58,8 @@ private static String buildHidString(List ids, String hidPrefixesString) { final String hidParameters = Arrays.stream(hidPrefixesString.split(",")) .map(prefixToIdValue::get) .filter(Objects::nonNull) - .map(it -> String.format("hid=%s:%s", it.getName(), it.getValue())) + .map(it -> String.format( + "hid=%s:%s", it.getName(), URLEncoder.encode(it.getValue(), StandardCharsets.UTF_8))) .collect(Collectors.joining("&")); if (StringUtils.isEmpty(hidParameters)) { return StringUtils.EMPTY; @@ -126,11 +127,11 @@ private static String buildAttributesString(OptableAttributes optableAttributes) .ifPresent(app -> { final String bundle = app.getBundle(); if (StringUtils.isNotEmpty(bundle)) { - sb.append("&bundle=").append(bundle); + sb.append("&bundle=").append(URLEncoder.encode(bundle, StandardCharsets.UTF_8)); final String ver = app.getVer(); if (StringUtils.isNotEmpty(ver)) { - sb.append("&ver=").append(ver); + sb.append("&ver=").append(URLEncoder.encode(ver, StandardCharsets.UTF_8)); } } }); diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java index 64aff679be4..08f6ce82815 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java @@ -207,7 +207,7 @@ public void shouldBuildHidWhenHidPrefixesMatchIds() { final Query query = QueryBuilder.build(ids, optableAttributes, props); // then - assertThat(query.getHid()).isEqualTo("&hid=c:234&hid=i6:0:0:0:0:0:0:0:1"); + assertThat(query.getHid()).isEqualTo("&hid=c:234&hid=i6:0%3A0%3A0%3A0%3A0%3A0%3A0%3A1"); } @Test @@ -223,7 +223,33 @@ public void shouldExcludeDeviceIpV6FromIdsString() { // then assertThat(query.getIds()).doesNotContain(Id.DEVICE_IP_V_6); - assertThat(query.getHid()).isEqualTo("&hid=i6:0:0:0:0:0:0:0:1"); + assertThat(query.getHid()).isEqualTo("&hid=i6:0%3A0%3A0%3A0%3A0%3A0%3A0%3A1"); + } + + @Test + public void shouldUrlEncodeHidValueWithSpecialCharacters() { + // given + final OptableTargetingProperties props = givenProperties(null, "c"); + final List ids = List.of(Id.of("c", "a b&c=d+e/f")); + + // when + final Query query = QueryBuilder.build(ids, optableAttributes, props); + + // then + assertThat(query.getHid()).isEqualTo("&hid=c:a+b%26c%3Dd%2Be%2Ff"); + } + + @Test + public void shouldLeaveHidValueUnchangedForAlphanumericValue() { + // given + final OptableTargetingProperties props = givenProperties(null, "c"); + final List ids = List.of(Id.of("c", "abc123")); + + // when + final Query query = QueryBuilder.build(ids, optableAttributes, props); + + // then + assertThat(query.getHid()).isEqualTo("&hid=c:abc123"); } @Test From cd55d193efd50d997577cd025416429565fa7e6d Mon Sep 17 00:00:00 2001 From: softcoder Date: Mon, 27 Jul 2026 20:16:40 +0200 Subject: [PATCH 08/25] optable-targeting: improve hid prefixes parsing --- .../hooks/modules/optable/targeting/v1/core/QueryBuilder.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java index ed3c5905222..0385ff294f2 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java @@ -56,6 +56,8 @@ private static String buildHidString(List ids, String hidPrefixesString) { } final String hidParameters = Arrays.stream(hidPrefixesString.split(",")) + .map(String::trim) + .filter(StringUtils::isNotEmpty) .map(prefixToIdValue::get) .filter(Objects::nonNull) .map(it -> String.format( From 7928105a96c29d553ad3aace820af1bbf1eae825 Mon Sep 17 00:00:00 2001 From: softcoder Date: Mon, 27 Jul 2026 20:17:30 +0200 Subject: [PATCH 09/25] optable-targeting: Remove dead code --- .../hooks/modules/optable/targeting/v1/core/QueryBuilder.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java index 0385ff294f2..66391ee4634 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java @@ -51,9 +51,6 @@ private static String buildHidString(List ids, String hidPrefixesString) { } final Map prefixToIdValue = ids.stream() .collect(Collectors.toMap(Id::getName, it -> it, (a, b) -> b)); - if (MapUtils.isEmpty(prefixToIdValue)) { - return StringUtils.EMPTY; - } final String hidParameters = Arrays.stream(hidPrefixesString.split(",")) .map(String::trim) From af2e67246080bc3f3db4259407cebf66ecc5349e Mon Sep 17 00:00:00 2001 From: softcoder Date: Mon, 27 Jul 2026 20:48:09 +0200 Subject: [PATCH 10/25] optable-targeting: Improve id5 signature extraction from Targeting response --- .../targeting/v1/core/Id5Resolver.java | 25 ++++-- .../targeting/v1/core/QueryBuilder.java | 1 - .../targeting/v1/core/Id5ResolverTest.java | 90 +++++++++++++++++++ 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java index e9ba337fb3b..908520b5f6f 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java @@ -1,8 +1,11 @@ package org.prebid.server.hooks.modules.optable.targeting.v1.core; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.iab.openrtb.request.Eid; import com.iab.openrtb.request.Uid; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Ortb2; import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult; import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.User; @@ -29,7 +32,7 @@ public static String resolveId5Signature(TargetingResult targetingResult) { return null; } - final String ref = Optional.of(targetingResult) + final List refs = Optional.of(targetingResult) .map(TargetingResult::getOrtb2) .map(Ortb2::getUser) .map(User::getEids) @@ -37,6 +40,7 @@ public static String resolveId5Signature(TargetingResult targetingResult) { .stream() .filter(it -> STR_OPTABLE_CO.equals(it.getInserter()) && STR_ID_5_SYNC_COM.equals(it.getSource())) .map(Eid::getUids) + .filter(Objects::nonNull) .flatMap(Collection::stream) .map(Uid::getExt) .filter(Objects::nonNull) @@ -45,17 +49,26 @@ public static String resolveId5Signature(TargetingResult targetingResult) { .map(it -> it.get(STR_REF)) .filter(Objects::nonNull) .map(JsonNode::asText) - .findFirst() - .orElse(null); + .toList(); - if (ref == null) { + if (CollectionUtils.isEmpty(refs)) { return null; } - return Optional.ofNullable(targetingResult.getRefs()) - .map(refs -> refs.get(ref)) + final ObjectNode references = targetingResult.getRefs(); + if (references == null) { + return null; + } + + return refs.stream() + .map(references::get) + .filter(Objects::nonNull) .map(it -> it.get(STR_SIGNATURE)) + .filter(Objects::nonNull) + .filter(JsonNode::isValueNode) .map(JsonNode::asText) + .filter(StringUtils::isNotBlank) + .findFirst() .orElse(null); } } diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java index 66391ee4634..ad3ba1efefd 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java @@ -1,7 +1,6 @@ package org.prebid.server.hooks.modules.optable.targeting.v1.core; import org.apache.commons.collections4.CollectionUtils; -import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.StringUtils; import org.prebid.server.hooks.modules.optable.targeting.model.Id; import org.prebid.server.hooks.modules.optable.targeting.model.OptableAttributes; diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java index b0b9be37b21..c38a0821d49 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java @@ -188,6 +188,96 @@ public void shouldReturnSignatureWhenMultipleMatchingEidsExist() { assertThat(result).isEqualTo("firstSignature"); } + @Test + public void shouldReturnSignatureFromSecondMatchingEidWhenFirstRefNotInRefs() { + // given + final ObjectNode firstUidExt = ObjectMapperProvider.mapper().createObjectNode(); + firstUidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + .set("ref", TextNode.valueOf("firstRef"))); + + final ObjectNode secondUidExt = ObjectMapperProvider.mapper().createObjectNode(); + secondUidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + .set("ref", TextNode.valueOf("secondRef"))); + + final Eid firstEid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id1").ext(firstUidExt).build())) + .build(); + final Eid secondEid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id2").ext(secondUidExt).build())) + .build(); + final ObjectNode refs = ObjectMapperProvider.mapper().createObjectNode(); + refs.set("secondRef", ObjectMapperProvider.mapper().createObjectNode() + .set("signature", TextNode.valueOf("secondSignature"))); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(firstEid, secondEid), null)), + refs); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isEqualTo("secondSignature"); + } + + @Test + public void shouldReturnNullWhenRefEntrySignatureIsBlank() { + // given + final ObjectNode uidExt = ObjectMapperProvider.mapper().createObjectNode(); + uidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + .set("ref", TextNode.valueOf("refValue"))); + + final Eid eid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) + .build(); + final ObjectNode refs = ObjectMapperProvider.mapper().createObjectNode(); + refs.set("refValue", ObjectMapperProvider.mapper().createObjectNode() + .set("signature", TextNode.valueOf(" "))); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(eid), null)), + refs); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isNull(); + } + + @Test + public void shouldReturnNullWhenRefEntrySignatureIsContainerNode() { + // given + final ObjectNode uidExt = ObjectMapperProvider.mapper().createObjectNode(); + uidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + .set("ref", TextNode.valueOf("refValue"))); + + final Eid eid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) + .build(); + final ObjectNode refs = ObjectMapperProvider.mapper().createObjectNode(); + refs.set("refValue", ObjectMapperProvider.mapper().createObjectNode() + .set("signature", ObjectMapperProvider.mapper().createObjectNode())); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(eid), null)), + refs); + + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + + // then + assertThat(result).isNull(); + } + @Test public void shouldReturnNullWhenRefEntryHasNoSignature() { // given From 4ae39fc50edca54a09350688fdbe387bf2f95870 Mon Sep 17 00:00:00 2001 From: softcoder Date: Mon, 27 Jul 2026 22:08:49 +0200 Subject: [PATCH 11/25] optable-targeting: Code cleanup --- .../config/OptableTargetingConfig.java | 7 +- .../v1/core/ExtUserOptableResolver.java | 26 ++++++++ .../targeting/v1/core/Id5Resolver.java | 1 - .../optable/targeting/v1/core/IdsMapper.java | 18 +----- .../v1/core/OptableAttributesResolver.java | 26 +++----- .../v1/core/TargetingRequestExecutor.java | 10 ++- .../v1/OptableRawAuctionRequestHookTest.java | 3 +- ...getingProcessedAuctionRequestHookTest.java | 3 +- .../v1/core/ExtUserOptableResolverTest.java | 64 +++++++++++++++++++ .../core/OptableAttributesResolverTest.java | 16 ++--- .../configs/sample-app-settings-optable.yaml | 2 +- 11 files changed, 123 insertions(+), 53 deletions(-) create mode 100644 extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/ExtUserOptableResolver.java create mode 100644 extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/ExtUserOptableResolverTest.java diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/config/OptableTargetingConfig.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/config/OptableTargetingConfig.java index cc05cfe2a02..91ae6691bbf 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/config/OptableTargetingConfig.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/config/OptableTargetingConfig.java @@ -98,10 +98,11 @@ ConfigResolver configResolver(JsonMerger jsonMerger, OptableTargetingProperties @Bean TargetingRequestExecutor targetingRequestExecutor(OptableTargeting optableTargeting, - UserFpdActivityMask userFpdActivityMask, - TimeoutFactory timeoutFactory) { + UserFpdActivityMask userFpdActivityMask, + TimeoutFactory timeoutFactory, + @Value("${logging.sampling-rate:0.01}") double logSamplingRate) { - return new TargetingRequestExecutor(optableTargeting, userFpdActivityMask, timeoutFactory); + return new TargetingRequestExecutor(optableTargeting, userFpdActivityMask, timeoutFactory, logSamplingRate); } @Bean diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/ExtUserOptableResolver.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/ExtUserOptableResolver.java new file mode 100644 index 00000000000..1d7e081148e --- /dev/null +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/ExtUserOptableResolver.java @@ -0,0 +1,26 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.ExtUserOptable; +import org.prebid.server.json.ObjectMapperProvider; +import org.prebid.server.log.ConditionalLogger; +import org.prebid.server.log.LoggerFactory; + +public class ExtUserOptableResolver { + + private static final ConditionalLogger conditionalLogger = + new ConditionalLogger(LoggerFactory.getLogger(ExtUserOptableResolver.class)); + + private ExtUserOptableResolver() { + } + + public static ExtUserOptable resolveExtUserOptable(JsonNode node, double logSamplingRate) { + try { + return ObjectMapperProvider.mapper().treeToValue(node, ExtUserOptable.class); + } catch (JsonProcessingException e) { + conditionalLogger.warn("Can't parse $.ext.user.Optable tag", logSamplingRate); + return null; + } + } +} diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java index 908520b5f6f..564d2c4a142 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java @@ -24,7 +24,6 @@ public class Id5Resolver { public static final String STR_SIGNATURE = "signature"; private Id5Resolver() { - } public static String resolveId5Signature(TargetingResult targetingResult) { diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapper.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapper.java index a9b4c9c7311..ddb47141248 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapper.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapper.java @@ -1,7 +1,5 @@ package org.prebid.server.hooks.modules.optable.targeting.v1.core; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.request.Device; @@ -14,8 +12,6 @@ import org.prebid.server.hooks.modules.optable.targeting.model.Id; import org.prebid.server.hooks.modules.optable.targeting.model.OS; import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.ExtUserOptable; -import org.prebid.server.log.ConditionalLogger; -import org.prebid.server.log.LoggerFactory; import java.util.Collections; import java.util.HashMap; @@ -26,9 +22,6 @@ public class IdsMapper { - private static final ConditionalLogger conditionalLogger = - new ConditionalLogger(LoggerFactory.getLogger(IdsMapper.class)); - private static final Map STATIC_PPID_MAPPING = Map.of( "id5-sync.com", Id.ID5, "utiq.com", Id.UTIQ, @@ -60,7 +53,7 @@ private void addOptableIds(Map ids, User user) { final Optional extUserOptable = Optional.ofNullable(user) .map(User::getExt) .map(ext -> ext.getProperty("optable")) - .map(this::parseExtUserOptable); + .map(it -> ExtUserOptableResolver.resolveExtUserOptable(it, logSamplingRate)); extUserOptable.map(ExtUserOptable::getEmail).ifPresent(it -> ids.put(Id.EMAIL, it)); extUserOptable.map(ExtUserOptable::getPhone).ifPresent(it -> ids.put(Id.PHONE, it)); @@ -68,15 +61,6 @@ private void addOptableIds(Map ids, User user) { extUserOptable.map(ExtUserOptable::getVid).ifPresent(it -> ids.put(Id.OPTABLE_VID, it)); } - private ExtUserOptable parseExtUserOptable(JsonNode node) { - try { - return objectMapper.treeToValue(node, ExtUserOptable.class); - } catch (JsonProcessingException e) { - conditionalLogger.warn("Can't parse $.ext.user.Optable tag", logSamplingRate); - return null; - } - } - private static void addDeviceIds(Map ids, Device device) { final String ifa = device != null ? device.getIfa() : null; final String os = device != null ? StringUtils.toRootLowerCase(device.getOs()) : null; diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java index b655f04cb61..39783e9bf34 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java @@ -1,7 +1,5 @@ package org.prebid.server.hooks.modules.optable.targeting.v1.core; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.request.Device; import com.iab.openrtb.request.Regs; @@ -13,7 +11,6 @@ import org.prebid.server.hooks.modules.optable.targeting.model.App; import org.prebid.server.hooks.modules.optable.targeting.model.OptableAttributes; import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.ExtUserOptable; -import org.prebid.server.json.ObjectMapperProvider; import org.prebid.server.proto.openrtb.ext.request.ExtRegs; import org.prebid.server.proto.openrtb.ext.request.ExtUser; @@ -26,7 +23,10 @@ public class OptableAttributesResolver { private OptableAttributesResolver() { } - public static OptableAttributes resolveAttributes(AuctionContext auctionContext, Long timeout) { + public static OptableAttributes resolveAttributes(AuctionContext auctionContext, + Long timeout, + double logSamplingRate) { + final GppContext.Scope gppScope = auctionContext.getGppContext().scope(); final BidRequest bidRequest = auctionContext.getBidRequest(); @@ -63,26 +63,16 @@ public static OptableAttributes resolveAttributes(AuctionContext auctionContext, .gppSid(SetUtils.emptyIfNull(gppScope.getSectionsIds())); } - final Optional extUserOptable = Optional.ofNullable(bidRequest.getUser()) + Optional.ofNullable(bidRequest.getUser()) .map(User::getExt) .map(ext -> ext.getProperty("optable")) - .map(OptableAttributesResolver::parseExtUserOptable); - - if (extUserOptable.isPresent()) { - extUserOptable.map(ExtUserOptable::getId5Signature).ifPresent(builder::id5Signature); - } + .map(it -> ExtUserOptableResolver.resolveExtUserOptable(it, logSamplingRate)) + .map(ExtUserOptable::getId5Signature) + .ifPresent(builder::id5Signature); return builder.build(); } - private static ExtUserOptable parseExtUserOptable(JsonNode node) { - try { - return ObjectMapperProvider.mapper().treeToValue(node, ExtUserOptable.class); - } catch (JsonProcessingException e) { - return null; - } - } - private static App resolveApp(AuctionContext auctionContext) { final com.iab.openrtb.request.App app = auctionContext.getBidRequest().getApp(); return app != null ? App.of(app.getBundle(), app.getVer()) : null; diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/TargetingRequestExecutor.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/TargetingRequestExecutor.java index 36cd33c7724..709b18979f5 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/TargetingRequestExecutor.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/TargetingRequestExecutor.java @@ -29,14 +29,17 @@ public class TargetingRequestExecutor { private final OptableTargeting optableTargeting; private final UserFpdActivityMask userFpdActivityMask; private final TimeoutFactory timeoutFactory; + private double logSamplingRate; public TargetingRequestExecutor(OptableTargeting optableTargeting, - UserFpdActivityMask userFpdActivityMask, - TimeoutFactory timeoutFactory) { + UserFpdActivityMask userFpdActivityMask, + TimeoutFactory timeoutFactory, + double logSamplingRate) { this.optableTargeting = Objects.requireNonNull(optableTargeting); this.userFpdActivityMask = Objects.requireNonNull(userFpdActivityMask); this.timeoutFactory = ObjectUtils.requireNonEmpty(timeoutFactory); + this.logSamplingRate = logSamplingRate; } public Future makeRequest(AuctionRequestPayload payload, @@ -51,7 +54,8 @@ public Future makeRequest(AuctionRequestPayload payload, : timeoutFactory.create(getHookTimeout(invocationContext).remaining() + apiTimeout); final OptableAttributes attributes = OptableAttributesResolver.resolveAttributes( invocationContext.auctionContext(), - properties.getTimeout()); + properties.getTimeout(), + logSamplingRate); return optableTargeting.getTargeting(properties, bidRequest, attributes, timeout); } diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableRawAuctionRequestHookTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableRawAuctionRequestHookTest.java index 059db74ce0e..4e1a5b3e5f1 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableRawAuctionRequestHookTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableRawAuctionRequestHookTest.java @@ -63,7 +63,8 @@ public void setUp() { when(userFpdActivityMask.maskDevice(any(), anyBoolean(), anyBoolean())) .thenAnswer(answer -> answer.getArgument(0)); configResolver = new ConfigResolver(mapper, jsonMerger, givenOptableTargetingProperties(false)); - targetingRequestExecutor = new TargetingRequestExecutor(optableTargeting, userFpdActivityMask, timeoutFactory); + targetingRequestExecutor = new TargetingRequestExecutor( + optableTargeting, userFpdActivityMask, timeoutFactory, 0.01); target = new OptableRawAuctionRequestHook( configResolver, targetingRequestExecutor, bidderEnrichmentSampler, CompositeHookExecutionPlan.of(ExecutionPlan.empty()), 0.01); diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHookTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHookTest.java index 0e149e857b2..ada923f273a 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHookTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingProcessedAuctionRequestHookTest.java @@ -80,7 +80,8 @@ void setUp() { when(userFpdActivityMask.maskDevice(any(), anyBoolean(), anyBoolean())) .thenAnswer(answer -> answer.getArgument(0)); configResolver = new ConfigResolver(mapper, jsonMerger, givenOptableTargetingProperties(false)); - targetingRequestExecutor = new TargetingRequestExecutor(optableTargeting, userFpdActivityMask, timeoutFactory); + targetingRequestExecutor = new TargetingRequestExecutor( + optableTargeting, userFpdActivityMask, timeoutFactory, 0.01); target = new OptableTargetingProcessedAuctionRequestHook( configResolver, targetingRequestExecutor, CompositeHookExecutionPlan.of(ExecutionPlan.empty()), 0.01); diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/ExtUserOptableResolverTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/ExtUserOptableResolverTest.java new file mode 100644 index 00000000000..b3142c173f2 --- /dev/null +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/ExtUserOptableResolverTest.java @@ -0,0 +1,64 @@ +package org.prebid.server.hooks.modules.optable.targeting.v1.core; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import org.junit.jupiter.api.Test; +import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.ExtUserOptable; +import org.prebid.server.json.ObjectMapperProvider; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ExtUserOptableResolverTest { + + @Test + public void shouldResolveExtUserOptableWhenNodeIsValid() { + // given + final ObjectNode node = ObjectMapperProvider.mapper().createObjectNode(); + node.set("email", TextNode.valueOf("user@example.com")); + node.set("phone", TextNode.valueOf("123")); + node.set("zip", TextNode.valueOf("321")); + node.set("vid", TextNode.valueOf("vid")); + node.set("id5_signature", TextNode.valueOf("signature")); + + // when + final ExtUserOptable result = ExtUserOptableResolver.resolveExtUserOptable(node, 1.0); + + // then + assertThat(result).isNotNull() + .returns("user@example.com", ExtUserOptable::getEmail) + .returns("123", ExtUserOptable::getPhone) + .returns("321", ExtUserOptable::getZip) + .returns("vid", ExtUserOptable::getVid) + .returns("signature", ExtUserOptable::getId5Signature); + } + + @Test + public void shouldReturnNullWhenNodeIsNotParseable() { + // given + final ObjectNode node = ObjectMapperProvider.mapper().createObjectNode(); + node.set("email", ObjectMapperProvider.mapper().createArrayNode().add(1).add(2)); + + // when + final ExtUserOptable result = ExtUserOptableResolver.resolveExtUserOptable(node, 1.0); + + // then + assertThat(result).isNull(); + } + + @Test + public void shouldResolveExtUserOptableWhenNodeIsEmpty() { + // given + final ObjectNode node = ObjectMapperProvider.mapper().createObjectNode(); + + // when + final ExtUserOptable result = ExtUserOptableResolver.resolveExtUserOptable(node, 1.0); + + // then + assertThat(result).isNotNull() + .returns(null, ExtUserOptable::getEmail) + .returns(null, ExtUserOptable::getPhone) + .returns(null, ExtUserOptable::getZip) + .returns(null, ExtUserOptable::getVid) + .returns(null, ExtUserOptable::getId5Signature); + } +} diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolverTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolverTest.java index 276c3f30c88..51adfda9952 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolverTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolverTest.java @@ -60,7 +60,7 @@ public void shouldResolveGdprAttributesForORTB26WhenConsentIsValid() { // when final OptableAttributes result = OptableAttributesResolver.resolveAttributes( - auctionContext, properties.getTimeout()); + auctionContext, properties.getTimeout(), 0.01); // then assertThat(result).isNotNull() @@ -79,7 +79,7 @@ public void shouldResolveGdprAttributesForORTB25WhenConsentIsValid() { // when final OptableAttributes result = OptableAttributesResolver.resolveAttributes( - auctionContext, properties.getTimeout()); + auctionContext, properties.getTimeout(), 0.01); // then assertThat(result).isNotNull() @@ -100,7 +100,7 @@ public void shouldNotResolveTcfAttributesWhenConsentIsNotValid() { // when final OptableAttributes result = OptableAttributesResolver.resolveAttributes( - auctionContext, properties.getTimeout()); + auctionContext, properties.getTimeout(), 0.01); // then assertThat(result).isNotNull() @@ -121,7 +121,7 @@ public void shouldResolveGppAttributes() { // when final OptableAttributes result = OptableAttributesResolver.resolveAttributes( - auctionContext, properties.getTimeout()); + auctionContext, properties.getTimeout(), 0.01); // then assertThat(result).isNotNull() @@ -144,7 +144,7 @@ public void shouldResolveAppWhenAppIsPresent() { // when final OptableAttributes result = OptableAttributesResolver.resolveAttributes( - auctionContext, properties.getTimeout()); + auctionContext, properties.getTimeout(), 0.01); // then assertThat(result).isNotNull() @@ -159,7 +159,7 @@ public void shouldNotResolveAppWhenAppIsAbsent() { // when final OptableAttributes result = OptableAttributesResolver.resolveAttributes( - auctionContext, properties.getTimeout()); + auctionContext, properties.getTimeout(), 0.01); // then assertThat(result).isNotNull() @@ -174,7 +174,7 @@ public void shouldResolveId5SignatureWhenPresentInUserExtOptable() { // when final OptableAttributes result = OptableAttributesResolver.resolveAttributes( - auctionContext, properties.getTimeout()); + auctionContext, properties.getTimeout(), 0.01); // then assertThat(result).isNotNull() @@ -189,7 +189,7 @@ public void shouldNotResolveId5SignatureWhenAbsentInUserExtOptable() { // when final OptableAttributes result = OptableAttributesResolver.resolveAttributes( - auctionContext, properties.getTimeout()); + auctionContext, properties.getTimeout(), 0.01); // then assertThat(result).isNotNull() diff --git a/sample/configs/sample-app-settings-optable.yaml b/sample/configs/sample-app-settings-optable.yaml index 7b4af19a7e2..b05448d65ca 100644 --- a/sample/configs/sample-app-settings-optable.yaml +++ b/sample/configs/sample-app-settings-optable.yaml @@ -31,7 +31,7 @@ accounts: enrich-app: true id-prefix-order: "e,v,c" hid-prefixes: "c,i6" - ppid-mapping: { "pubmatic.com": "c" } + ppid-mapping: { "pubcid.org": "c" } adserver-targeting: true cache: enabled: false From 0dabf80b5ed06492ea3b6fbc107241be6ae91cb7 Mon Sep 17 00:00:00 2001 From: softcoder Date: Tue, 28 Jul 2026 09:08:09 +0200 Subject: [PATCH 12/25] optable-targeting: update README.md --- extra/modules/optable-targeting/README.md | 51 ++++++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/extra/modules/optable-targeting/README.md b/extra/modules/optable-targeting/README.md index 9e3b76c6446..901ad2fa40f 100644 --- a/extra/modules/optable-targeting/README.md +++ b/extra/modules/optable-targeting/README.md @@ -203,6 +203,7 @@ would result in this nesting in the JSON configuration: | adserver-targeting | no | boolean | false | If set to true - will add the Optable-specific adserver targeting keywords into the PBS response for every `seatbid[].bid[].ext.prebid.targeting` | | timeout | no | integer | none | A soft timeout (in ms) sent as a hint to the Targeting API endpoint to limit the request times to Optable's external tokenizer services | | id-prefix-order | no | string | none | An optional string of comma separated id prefixes that prioritizes and specifies the order in which ids are provided to Targeting API in a query string. F.e. "c,c1,id5" will guarantee that Targeting API will see id=c:...,c1:...,id5:... if these ids are provided. id-prefixes not mentioned in this list will be added in arbitrary order after the priority prefix ids. This affects Targeting API processing logic | +| hid-prefixes | no | string | none | An optional string of comma separated id prefixes that should additionally be sent to the Targeting API as resolver hints in `hid=prefix:value` query parameters. See the section on Resolver Hints (hid) below for more detail. | | enrichment-percentage | no | integer | 100 | Default percentage (0-100) of bid requests per bidder that will receive enrichment data. Set to 100 to enrich all requests, 0 to disable enrichment by default. | | bidder-enrichment-percentages | no | map | none | Per-bidder overrides for `enrichment-percentage`. Keys are bidder names, values are percentages (0-100). F.e. `{"appnexus": 75, "criteo": 0}` enriches 75% of appnexus requests and none for criteo. Bidders not listed in this map fall back to the default `enrichment-percentage` (100% unless overridden). | | enrich-web | no | boolean | true | Whether to enrich web traffic (requests with a `site` object). | @@ -239,8 +240,8 @@ on identifier types. Targeting API accepts multiple id parameters - and their or ### Optable input erasure -**Note**: `user.ext.optable.email`, `.phone`, `.zip`, `.vid` fields will be removed by the module from the original -OpenRTB request before being sent to bidders. +**Note**: `user.ext.optable.email`, `.phone`, `.zip`, `.vid` and `.id5_signature` fields will be removed by the module +from the original OpenRTB request before being sent to bidders. ### Publisher Provided IDs (PPID) Mapping @@ -263,6 +264,52 @@ ppid-mapping: {"id5-sync.com": "c1"} This will lead to id5 ID supplied as `id=c1:...` to the Targeting API. +### Resolver Hints (`hid`) + +In addition to the regular `id=prefix:value` parameters, the module can forward selected identifiers to the Targeting +API as resolver hints using the `hid=prefix:value` query parameter form. The set of prefixes that should be sent as +`hid` is configured via the `hid-prefixes` parameter, which accepts a comma-separated list of prefix names, f.e.: + +```yaml +hid-prefixes: "c, i6" +``` + +## Targeting API Query Attributes + +In addition to the identifier parameters, the module forwards the following attributes as query string parameters to +the Targeting API: + +| Attribute | Source | +|------------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| `gdpr` | `1` if GDPR applies to the request, `0` otherwise. | +| `gdpr_consent` | TCF consent string, sent when available and the consent is valid. | +| `gpp` | GPP string, sent when available in the resolved GPP context. | +| `gpp_sid` | Comma-separated list of active GPP section IDs (limited to the first two), sent when the set is non-empty. | +| `timeout` | Soft timeout hint (in ms, suffixed with `ms`), sent when the module-level `timeout` property is configured. | +| `osdk` | Always set to `prebid-server`, identifying the caller. | +| `bundle` | App bundle identifier, URL-encoded. Sent when the incoming request has an `app` object (`request.app.bundle`) and the bundle is non-empty. | +| `ver` | App version, URL-encoded. Sent only when `bundle` is non-empty and `request.app.ver` is present and non-empty. | +| `id5_signature` | ID5 signature, URL-encoded. Sent when the resolved `user.ext.optable.id5_signature` is present in the incoming request | + +### App bundle and version + +For app traffic (requests with an `app` object) the module forwards the application's bundle identifier as the +`bundle=` query parameter and, when available, its version as `ver=`. Both values are URL-encoded. The `ver` parameter +is only sent when `bundle` is present and non-empty — requests with a version but no bundle will not produce either +parameter. + +### ID5 signature + +The ID5 signature is propagated to the Targeting API in two ways: + +1. **Request-side signature** — when the incoming OpenRTB request carries `user.ext.optable.id5_signature`, that value + is sent to the Targeting API as the `id5_signature=` query parameter. +2. **Response-side signature** — when the Targeting API response contains refs with an ID5 signature, the module + resolves the signature through the matching optable-eid and propagates it back into the request context, where it + is later injected into the bid response (`ext.prebid.passthrough.optable.id5_signature`) for downstream use. + +The `id5_signature` field is also part of the Optable input erasure — see below. + ## Analytics Tags The following 2 analytics tags are written by the module: From de40bf6a77e8dcff735e942fd74af6eb0ef0888e Mon Sep 17 00:00:00 2001 From: softcoder Date: Tue, 28 Jul 2026 16:52:26 +0200 Subject: [PATCH 13/25] optable-targeting: Code cleanup --- extra/modules/optable-targeting/README.md | 2 +- .../v1/OptableBidderRequestHook.java | 23 +++++- .../OptableTargetingAuctionResponseHook.java | 14 +--- .../targeting/v1/core/Id5Resolver.java | 19 ++--- .../targeting/v1/core/QueryBuilder.java | 4 +- .../v1/core/TargetingRequestExecutor.java | 2 +- .../v1/OptableBidderRequestHookTest.java | 55 +++++++++++++ ...tableTargetingAuctionResponseHookTest.java | 25 ++++-- .../targeting/v1/core/Id5ResolverTest.java | 81 ++++++++++--------- 9 files changed, 155 insertions(+), 70 deletions(-) diff --git a/extra/modules/optable-targeting/README.md b/extra/modules/optable-targeting/README.md index 901ad2fa40f..1dae9829156 100644 --- a/extra/modules/optable-targeting/README.md +++ b/extra/modules/optable-targeting/README.md @@ -308,7 +308,7 @@ The ID5 signature is propagated to the Targeting API in two ways: resolves the signature through the matching optable-eid and propagates it back into the request context, where it is later injected into the bid response (`ext.prebid.passthrough.optable.id5_signature`) for downstream use. -The `id5_signature` field is also part of the Optable input erasure — see below. +The `id5_signature` field is also part of the Optable input erasure — see above. ## Analytics Tags diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java index f96335cb91d..d42dbfb3ec1 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java @@ -81,7 +81,7 @@ private Future> failedAction(ModuleContex final Tags analyticsTags = AnalyticTagsResolver.toBidderEnrichRequestAnalyticTags( bidder, outcome, executionTime); - return noAction(moduleContext, analyticsTags); + return noActionResponse(moduleContext, analyticsTags); } private static boolean hasEnrichmentData(TargetingResult targetingResult) { @@ -97,6 +97,27 @@ private static long calcExecutionTime(ModuleContext moduleContext) { } private Future> noAction(ModuleContext moduleContext, Tags analyticsTags) { + final Future targetingCall = moduleContext.getOptableTargetingCall(); + if (targetingCall != null) { + return targetingCall.compose(targetingResult -> { + moduleContext.setId5Signature(Id5Resolver.resolveId5Signature(targetingResult)); + + return Future.succeededFuture( + InvocationResultImpl.builder() + .status(InvocationStatus.success) + .action(InvocationAction.no_action) + .analyticsTags(analyticsTags) + .moduleContext(moduleContext) + .build()); + }); + } + + return noActionResponse(moduleContext, analyticsTags); + } + + private Future> noActionResponse(ModuleContext moduleContext, + Tags analyticsTags) { + return Future.succeededFuture( InvocationResultImpl.builder() .status(InvocationStatus.success) diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java index 1f98c18cc0d..8b0e233a003 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java @@ -80,10 +80,7 @@ private Future> enrichedById5SignatureP final String id5Signature = moduleContext.getId5Signature(); - return moduleContext.getOptableTargetingCall() - .compose(targetingResult -> - update(id5SignatureEnrichmentChain(id5Signature), moduleContext)) - .recover(throwable -> success(moduleContext)); + return update(id5SignatureEnrichmentChain(id5Signature), moduleContext); } private PayloadUpdate fullEnrichmentChain(final List targeting, @@ -112,13 +109,8 @@ private Future> update( } private Future> success(ModuleContext moduleContext) { - return Future.succeededFuture( - InvocationResultImpl.builder() - .status(InvocationStatus.success) - .action(InvocationAction.no_action) - .moduleContext(moduleContext) - .analyticsTags(AnalyticTagsResolver.toEnrichResponseAnalyticTags(moduleContext)) - .build()); + final String id5Signature = moduleContext.getId5Signature(); + return update(id5SignatureEnrichmentChain(id5Signature), moduleContext); } @Override diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java index 564d2c4a142..4349ad21240 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5Resolver.java @@ -17,11 +17,11 @@ public class Id5Resolver { - public static final String STR_OPTABLE_CO = "optable.co"; - public static final String STR_ID_5_SYNC_COM = "id5-sync.com"; - public static final String STR_OPTABLE = "optable"; - public static final String STR_REF = "ref"; - public static final String STR_SIGNATURE = "signature"; + public static final String OPTABLE_INSERTER = "optable.co"; + public static final String ID5_SOURCE = "id5-sync.com"; + public static final String OPTABLE = "optable"; + public static final String REF = "ref"; + public static final String SIGNATURE = "signature"; private Id5Resolver() { } @@ -37,15 +37,15 @@ public static String resolveId5Signature(TargetingResult targetingResult) { .map(User::getEids) .orElseGet(List::of) .stream() - .filter(it -> STR_OPTABLE_CO.equals(it.getInserter()) && STR_ID_5_SYNC_COM.equals(it.getSource())) + .filter(it -> OPTABLE_INSERTER.equals(it.getInserter()) && ID5_SOURCE.equals(it.getSource())) .map(Eid::getUids) .filter(Objects::nonNull) .flatMap(Collection::stream) .map(Uid::getExt) .filter(Objects::nonNull) - .map(it -> it.get(STR_OPTABLE)) + .map(it -> it.get(OPTABLE)) .filter(Objects::nonNull) - .map(it -> it.get(STR_REF)) + .map(it -> it.get(REF)) .filter(Objects::nonNull) .map(JsonNode::asText) .toList(); @@ -62,9 +62,10 @@ public static String resolveId5Signature(TargetingResult targetingResult) { return refs.stream() .map(references::get) .filter(Objects::nonNull) - .map(it -> it.get(STR_SIGNATURE)) + .map(it -> it.get(SIGNATURE)) .filter(Objects::nonNull) .filter(JsonNode::isValueNode) + .filter(it -> !it.isNull()) .map(JsonNode::asText) .filter(StringUtils::isNotBlank) .findFirst() diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java index ad3ba1efefd..0417855a6ff 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java @@ -79,9 +79,7 @@ private static String buildIdsString(List ids, String idPrefixOrder) { final StringBuilder sb = new StringBuilder(); for (Id id : reorderedIds) { sb.append("&id="); - sb.append(URLEncoder.encode( - "%s:%s".formatted(id.getName(), id.getValue()), - StandardCharsets.UTF_8)); + sb.append(URLEncoder.encode(String.format("%s:%s", id.getName(), id.getValue()), StandardCharsets.UTF_8)); } return sb.toString(); diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/TargetingRequestExecutor.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/TargetingRequestExecutor.java index 709b18979f5..8a93df9fbdf 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/TargetingRequestExecutor.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/TargetingRequestExecutor.java @@ -29,7 +29,7 @@ public class TargetingRequestExecutor { private final OptableTargeting optableTargeting; private final UserFpdActivityMask userFpdActivityMask; private final TimeoutFactory timeoutFactory; - private double logSamplingRate; + private final double logSamplingRate; public TargetingRequestExecutor(OptableTargeting optableTargeting, UserFpdActivityMask userFpdActivityMask, diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java index 4180f8645cc..9ae7f6e4d96 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java @@ -78,6 +78,61 @@ public void shouldReturnNoActionWhenPerBidderEnrichmentIsDisabled() { assertThat(result.moduleContext()).isSameAs(moduleContext); } + @Test + public void shouldSetId5SignatureOnModuleContextWhenPerBidderEnrichmentIsDisabledAndTargetingCallIsPresent() { + // given + final String refValue = "refValue"; + final String signature = "id5Signature"; + final Eid id5Eid = givenId5Eid(refValue); + final ObjectNode refs = givenRefsObject(refValue, signature); + final TargetingResult targetingResult = givenTargetingResult(List.of(id5Eid), null, refs); + + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenOptableTargetingProperties(false)); + moduleContext.setOptableTargetingCall(Future.succeededFuture(targetingResult)); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + + // when + final Future> future = + target.call(bidderRequestPayload, invocationContext); + + // then + assertThat(future.succeeded()).isTrue(); + final InvocationResult result = future.result(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + assertThat(moduleContext.getId5Signature()).isEqualTo(signature); + } + + @Test + public void shouldSetId5SignatureOnModuleContextWhenBidderNotInEnrichmentSetAndTargetingCallIsPresent() { + // given + final String refValue = "refValue"; + final String signature = "id5Signature"; + final Eid id5Eid = givenId5Eid(refValue); + final ObjectNode refs = givenRefsObject(refValue, signature); + final TargetingResult targetingResult = givenTargetingResult(List.of(id5Eid), null, refs); + + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenPropertiesWithPerBidderEnrichmentEnabled()); + moduleContext.setBiddersToEnrich(Set.of("otherBidder")); + moduleContext.setOptableTargetingCall(Future.succeededFuture(targetingResult)); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + + // when + final Future> future = + target.call(bidderRequestPayload, invocationContext); + + // then + assertThat(future.succeeded()).isTrue(); + final InvocationResult result = future.result(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + assertThat(moduleContext.getId5Signature()).isEqualTo(signature); + } + @Test public void shouldReturnNoActionWhenBiddersToEnrichIsEmpty() { // given diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java index 5b37eeec80f..19632668f63 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java @@ -59,7 +59,7 @@ public void shouldHaveCode() { } @Test - public void shouldReturnResultWithNoActionAndWithPBSAnalyticsTags() { + public void shouldReturnResultWithUpdateActionAndWithPBSAnalyticsTags() { // given when(invocationContext.moduleContext()).thenReturn( givenModuleContext(null, Future.failedFuture(new RuntimeException("error")))); @@ -75,7 +75,7 @@ public void shouldReturnResultWithNoActionAndWithPBSAnalyticsTags() { final InvocationResult result = future.result(); assertThat(result).isNotNull() .returns(InvocationStatus.success, InvocationResult::status) - .returns(InvocationAction.no_action, InvocationResult::action); + .returns(InvocationAction.update, InvocationResult::action); assertThat(result.analyticsTags()) .extracting(Tags::activities) .extracting(List::getFirst) @@ -240,7 +240,7 @@ public void shouldReturnUpdateActionWhenTargetingIsPresentEvenIfOptableTargeting } @Test - public void shouldReturnNoActionWhenOptableTargetingCallFailsAndTargetingIsEmpty() { + public void shouldReturnUpdateActionWhenOptableTargetingCallFailsAndTargetingIsEmpty() { // given when(invocationContext.moduleContext()).thenReturn( givenModuleContext(null, Future.failedFuture(new RuntimeException("error")))); @@ -255,11 +255,11 @@ public void shouldReturnNoActionWhenOptableTargetingCallFailsAndTargetingIsEmpty assertThat(future.succeeded()).isTrue(); assertThat(result).isNotNull() .returns(InvocationStatus.success, InvocationResult::status) - .returns(InvocationAction.no_action, InvocationResult::action); + .returns(InvocationAction.update, InvocationResult::action); } @Test - public void shouldReturnResultWithNoActionWhenAdvertiserTargetingOptionIsOff() { + public void shouldReturnResultWithUpdateActionWhenTargetingCallFailsAndNoBids() { // given when(invocationContext.moduleContext()).thenReturn(givenModuleContext(List.of( new Audience( @@ -279,27 +279,36 @@ public void shouldReturnResultWithNoActionWhenAdvertiserTargetingOptionIsOff() { assertThat(future.succeeded()).isTrue(); assertThat(result).isNotNull() .returns(InvocationStatus.success, InvocationResult::status) - .returns(InvocationAction.no_action, InvocationResult::action); + .returns(InvocationAction.update, InvocationResult::action); } @Test - public void shouldReturnSuccessWhenSkipEnrichmentIsTrue() { + public void shouldReturnUpdateActionWithId5SignatureWhenSkipEnrichmentIsTrue() { // given + final String signature = "id5Signature"; final ModuleContext moduleContext = givenModuleContext(); moduleContext.setShouldSkipEnrichment(true); + moduleContext.setId5Signature(signature); when(invocationContext.moduleContext()).thenReturn(moduleContext); // when final Future> future = target.call(auctionResponsePayload, invocationContext); final InvocationResult result = future.result(); + final BidResponse bidResponse = result + .payloadUpdate() + .apply(AuctionResponsePayloadImpl.of(givenBidResponse())) + .bidResponse(); + final JsonNode passthrough = bidResponse.getExt().getPrebid().getPassthrough(); // then assertThat(future).isNotNull(); assertThat(future.succeeded()).isTrue(); assertThat(result).isNotNull() .returns(InvocationStatus.success, InvocationResult::status) - .returns(InvocationAction.no_action, InvocationResult::action); + .returns(InvocationAction.update, InvocationResult::action); + assertThat(passthrough).isNotNull(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo(signature); } private ObjectNode givenAccountConfig(boolean cacheEnabled) { diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java index c38a0821d49..41dc2b83ff7 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java @@ -1,9 +1,11 @@ package org.prebid.server.hooks.modules.optable.targeting.v1.core; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; import com.iab.openrtb.request.Eid; import com.iab.openrtb.request.Uid; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Ortb2; import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.TargetingResult; @@ -16,6 +18,13 @@ public class Id5ResolverTest { + private ObjectMapper mapper; + + @BeforeEach + public void setUp() { + mapper = ObjectMapperProvider.mapper(); + } + @Test public void shouldReturnNullWhenTargetingResultIsNull() { // when @@ -75,8 +84,8 @@ public void shouldReturnNullWhenEidsDoNotMatchOptableAndId5Source() { @Test public void shouldReturnNullWhenMatchingEidButRefsAreAbsent() { // given - final ObjectNode uidExt = ObjectMapperProvider.mapper().createObjectNode(); - uidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + final ObjectNode uidExt = mapper.createObjectNode(); + uidExt.set("optable", mapper.createObjectNode() .set("ref", TextNode.valueOf("refValue"))); final Eid eid = Eid.builder() @@ -99,8 +108,8 @@ public void shouldReturnNullWhenMatchingEidButRefsAreAbsent() { @Test public void shouldReturnNullWhenRefsDoNotContainResolvedRef() { // given - final ObjectNode uidExt = ObjectMapperProvider.mapper().createObjectNode(); - uidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + final ObjectNode uidExt = mapper.createObjectNode(); + uidExt.set("optable", mapper.createObjectNode() .set("ref", TextNode.valueOf("refValue"))); final Eid eid = Eid.builder() @@ -108,8 +117,8 @@ public void shouldReturnNullWhenRefsDoNotContainResolvedRef() { .inserter("optable.co") .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) .build(); - final ObjectNode refs = ObjectMapperProvider.mapper().createObjectNode(); - refs.set("otherRef", ObjectMapperProvider.mapper().createObjectNode() + final ObjectNode refs = mapper.createObjectNode(); + refs.set("otherRef", mapper.createObjectNode() .set("signature", TextNode.valueOf("signatureValue"))); final TargetingResult targetingResult = new TargetingResult( List.of(), @@ -126,8 +135,8 @@ public void shouldReturnNullWhenRefsDoNotContainResolvedRef() { @Test public void shouldReturnSignatureWhenAllConditionsAreMet() { // given - final ObjectNode uidExt = ObjectMapperProvider.mapper().createObjectNode(); - uidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + final ObjectNode uidExt = mapper.createObjectNode(); + uidExt.set("optable", mapper.createObjectNode() .set("ref", TextNode.valueOf("refValue"))); final Eid eid = Eid.builder() @@ -135,8 +144,8 @@ public void shouldReturnSignatureWhenAllConditionsAreMet() { .inserter("optable.co") .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) .build(); - final ObjectNode refs = ObjectMapperProvider.mapper().createObjectNode(); - refs.set("refValue", ObjectMapperProvider.mapper().createObjectNode() + final ObjectNode refs = mapper.createObjectNode(); + refs.set("refValue", mapper.createObjectNode() .set("signature", TextNode.valueOf("signatureValue"))); final TargetingResult targetingResult = new TargetingResult( List.of(), @@ -153,12 +162,12 @@ public void shouldReturnSignatureWhenAllConditionsAreMet() { @Test public void shouldReturnSignatureWhenMultipleMatchingEidsExist() { // given - final ObjectNode firstUidExt = ObjectMapperProvider.mapper().createObjectNode(); - firstUidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + final ObjectNode firstUidExt = mapper.createObjectNode(); + firstUidExt.set("optable", mapper.createObjectNode() .set("ref", TextNode.valueOf("firstRef"))); - final ObjectNode secondUidExt = ObjectMapperProvider.mapper().createObjectNode(); - secondUidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + final ObjectNode secondUidExt = mapper.createObjectNode(); + secondUidExt.set("optable", mapper.createObjectNode() .set("ref", TextNode.valueOf("secondRef"))); final Eid firstEid = Eid.builder() @@ -171,10 +180,10 @@ public void shouldReturnSignatureWhenMultipleMatchingEidsExist() { .inserter("optable.co") .uids(List.of(Uid.builder().id("id2").ext(secondUidExt).build())) .build(); - final ObjectNode refs = ObjectMapperProvider.mapper().createObjectNode(); - refs.set("firstRef", ObjectMapperProvider.mapper().createObjectNode() + final ObjectNode refs = mapper.createObjectNode(); + refs.set("firstRef", mapper.createObjectNode() .set("signature", TextNode.valueOf("firstSignature"))); - refs.set("secondRef", ObjectMapperProvider.mapper().createObjectNode() + refs.set("secondRef", mapper.createObjectNode() .set("signature", TextNode.valueOf("secondSignature"))); final TargetingResult targetingResult = new TargetingResult( List.of(), @@ -191,12 +200,12 @@ public void shouldReturnSignatureWhenMultipleMatchingEidsExist() { @Test public void shouldReturnSignatureFromSecondMatchingEidWhenFirstRefNotInRefs() { // given - final ObjectNode firstUidExt = ObjectMapperProvider.mapper().createObjectNode(); - firstUidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + final ObjectNode firstUidExt = mapper.createObjectNode(); + firstUidExt.set("optable", mapper.createObjectNode() .set("ref", TextNode.valueOf("firstRef"))); - final ObjectNode secondUidExt = ObjectMapperProvider.mapper().createObjectNode(); - secondUidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + final ObjectNode secondUidExt = mapper.createObjectNode(); + secondUidExt.set("optable", mapper.createObjectNode() .set("ref", TextNode.valueOf("secondRef"))); final Eid firstEid = Eid.builder() @@ -209,8 +218,8 @@ public void shouldReturnSignatureFromSecondMatchingEidWhenFirstRefNotInRefs() { .inserter("optable.co") .uids(List.of(Uid.builder().id("id2").ext(secondUidExt).build())) .build(); - final ObjectNode refs = ObjectMapperProvider.mapper().createObjectNode(); - refs.set("secondRef", ObjectMapperProvider.mapper().createObjectNode() + final ObjectNode refs = mapper.createObjectNode(); + refs.set("secondRef", mapper.createObjectNode() .set("signature", TextNode.valueOf("secondSignature"))); final TargetingResult targetingResult = new TargetingResult( List.of(), @@ -227,8 +236,8 @@ public void shouldReturnSignatureFromSecondMatchingEidWhenFirstRefNotInRefs() { @Test public void shouldReturnNullWhenRefEntrySignatureIsBlank() { // given - final ObjectNode uidExt = ObjectMapperProvider.mapper().createObjectNode(); - uidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + final ObjectNode uidExt = mapper.createObjectNode(); + uidExt.set("optable", mapper.createObjectNode() .set("ref", TextNode.valueOf("refValue"))); final Eid eid = Eid.builder() @@ -236,8 +245,8 @@ public void shouldReturnNullWhenRefEntrySignatureIsBlank() { .inserter("optable.co") .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) .build(); - final ObjectNode refs = ObjectMapperProvider.mapper().createObjectNode(); - refs.set("refValue", ObjectMapperProvider.mapper().createObjectNode() + final ObjectNode refs = mapper.createObjectNode(); + refs.set("refValue", mapper.createObjectNode() .set("signature", TextNode.valueOf(" "))); final TargetingResult targetingResult = new TargetingResult( List.of(), @@ -254,8 +263,8 @@ public void shouldReturnNullWhenRefEntrySignatureIsBlank() { @Test public void shouldReturnNullWhenRefEntrySignatureIsContainerNode() { // given - final ObjectNode uidExt = ObjectMapperProvider.mapper().createObjectNode(); - uidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + final ObjectNode uidExt = mapper.createObjectNode(); + uidExt.set("optable", mapper.createObjectNode() .set("ref", TextNode.valueOf("refValue"))); final Eid eid = Eid.builder() @@ -263,9 +272,9 @@ public void shouldReturnNullWhenRefEntrySignatureIsContainerNode() { .inserter("optable.co") .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) .build(); - final ObjectNode refs = ObjectMapperProvider.mapper().createObjectNode(); - refs.set("refValue", ObjectMapperProvider.mapper().createObjectNode() - .set("signature", ObjectMapperProvider.mapper().createObjectNode())); + final ObjectNode refs = mapper.createObjectNode(); + refs.set("refValue", mapper.createObjectNode() + .set("signature", mapper.createObjectNode())); final TargetingResult targetingResult = new TargetingResult( List.of(), new Ortb2(new User(List.of(eid), null)), @@ -281,8 +290,8 @@ public void shouldReturnNullWhenRefEntrySignatureIsContainerNode() { @Test public void shouldReturnNullWhenRefEntryHasNoSignature() { // given - final ObjectNode uidExt = ObjectMapperProvider.mapper().createObjectNode(); - uidExt.set("optable", ObjectMapperProvider.mapper().createObjectNode() + final ObjectNode uidExt = mapper.createObjectNode(); + uidExt.set("optable", mapper.createObjectNode() .set("ref", TextNode.valueOf("refValue"))); final Eid eid = Eid.builder() @@ -290,8 +299,8 @@ public void shouldReturnNullWhenRefEntryHasNoSignature() { .inserter("optable.co") .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) .build(); - final ObjectNode refs = ObjectMapperProvider.mapper().createObjectNode(); - refs.set("refValue", ObjectMapperProvider.mapper().createObjectNode() + final ObjectNode refs = mapper.createObjectNode(); + refs.set("refValue", mapper.createObjectNode() .set("other", TextNode.valueOf("value"))); final TargetingResult targetingResult = new TargetingResult( List.of(), From 603fd5cb130b78fb8b73a599b5b25543e0873b77 Mon Sep 17 00:00:00 2001 From: softcoder Date: Tue, 28 Jul 2026 20:30:32 +0200 Subject: [PATCH 14/25] optable-targeting: Update cache key --- .../optable/targeting/model/Query.java | 4 +- .../targeting/v1/core/QueryBuilder.java | 26 +++- .../targeting/v1/net/CachedAPIClient.java | 11 +- .../optable/targeting/v1/BaseOptableTest.java | 2 +- .../targeting/v1/core/QueryBuilderTest.java | 134 ++++++++++++++++++ .../targeting/v1/net/CachedAPIClientTest.java | 89 ++++++++++++ 6 files changed, 261 insertions(+), 5 deletions(-) diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Query.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Query.java index 19b725ab666..9b24ed2ed9d 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Query.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/model/Query.java @@ -11,7 +11,9 @@ public class Query { String attributes; + String hidAttributes; + public String toQueryString() { - return ids + hid + attributes; + return ids + hid + attributes + hidAttributes; } } diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java index 0417855a6ff..3278fb8de1a 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java @@ -41,7 +41,8 @@ public static Query build(List ids, OptableAttributes optableAttributes, return Query.of( buildIdsString(ids, idPrefixOrder), buildHidString(ids, hidPrefixes), - buildAttributesString(optableAttributes)); + buildAttributesString(optableAttributes), + buildHidAttributesString(optableAttributes)); } private static String buildHidString(List ids, String hidPrefixesString) { @@ -138,4 +139,27 @@ private static String buildAttributesString(OptableAttributes optableAttributes) return sb.toString(); } + + private static String buildHidAttributesString(OptableAttributes optableAttributes) { + final StringBuilder sb = new StringBuilder(); + + Optional.ofNullable(optableAttributes.getApp()) + .ifPresent(app -> { + final String bundle = app.getBundle(); + if (StringUtils.isNotEmpty(bundle)) { + sb.append("&bundle=").append(URLEncoder.encode(bundle, StandardCharsets.UTF_8)); + + final String ver = app.getVer(); + if (StringUtils.isNotEmpty(ver)) { + sb.append("&ver=").append(URLEncoder.encode(ver, StandardCharsets.UTF_8)); + } + } + }); + + Optional.ofNullable(optableAttributes.getId5Signature()) + .ifPresent(id5Signature -> sb.append("&id5_signature=") + .append(URLEncoder.encode(id5Signature, StandardCharsets.UTF_8))); + + return sb.toString(); + } } diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClient.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClient.java index eabccfdbff0..ada03ab1528 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClient.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClient.java @@ -53,12 +53,19 @@ public Future getTargeting(OptableTargetingProperties propertie } private String createCachingKey(String tenant, String origin, List ips, Query query, boolean encodeQuery) { - return "%s:%s:%s:%s".formatted( + return "%s:%s:%s:%s:%s:%s".formatted( tenant, origin, ips.getFirst(), encodeQuery ? URLEncoder.encode(query.getIds(), StandardCharsets.UTF_8) - : query.getIds()); + : query.getIds(), + encodeQuery && query.getHid() != null + ? URLEncoder.encode(query.getHid(), StandardCharsets.UTF_8) + : query.getHid(), + encodeQuery && query.getHidAttributes() != null + ? URLEncoder.encode(query.getHidAttributes(), StandardCharsets.UTF_8) + : query.getHidAttributes() + ); } } diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/BaseOptableTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/BaseOptableTest.java index 2ebf65c7b6e..77ccb5be730 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/BaseOptableTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/BaseOptableTest.java @@ -301,7 +301,7 @@ protected OptableTargetingProperties givenOptableTargetingProperties(String key, } protected Query givenQuery() { - return Query.of("?que", "r", "y"); + return Query.of("?que", "r", "y", ""); } protected ObjectNode givenAccountConfig(String key, String tenant, String origin, boolean cacheEnabled) { diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java index 08f6ce82815..4b329d6c136 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java @@ -336,6 +336,140 @@ public void shouldNotAppendBundleAndVerWhenAppIsNull() { assertThat(query).doesNotContain("&bundle=", "&ver="); } + @Test + public void shouldBuildHidAttributesWithBundleAndVerWhenAppIsPresent() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .app(App.of("com.example.app", "1.2.3")) + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, attributes, properties()); + + // then + assertThat(query.getHidAttributes()) + .isEqualTo("&bundle=com.example.app&ver=1.2.3"); + } + + @Test + public void shouldBuildHidAttributesWithBundleOnlyWhenVerIsEmpty() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .app(App.of("com.example.app", "")) + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, attributes, properties()); + + // then + assertThat(query.getHidAttributes()).isEqualTo("&bundle=com.example.app"); + } + + @Test + public void shouldNotBuildHidAttributesWithBundleWhenBundleIsEmpty() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .app(App.of("", "1.2.3")) + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, attributes, properties()); + + // then + assertThat(query.getHidAttributes()).doesNotContain("&bundle=", "&ver="); + } + + @Test + public void shouldNotBuildHidAttributesWithBundleWhenAppIsNull() { + // given + final OptableAttributes attributes = OptableAttributes.builder().build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, attributes, properties()); + + // then + assertThat(query.getHidAttributes()).isEmpty(); + } + + @Test + public void shouldBuildHidAttributesWithId5SignatureWhenPresent() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .id5Signature("signature") + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, attributes, properties()); + + // then + assertThat(query.getHidAttributes()).isEqualTo("&id5_signature=signature"); + } + + @Test + public void shouldNotBuildHidAttributesWithId5SignatureWhenNull() { + // given + final OptableAttributes attributes = OptableAttributes.builder().build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, attributes, properties()); + + // then + assertThat(query.getHidAttributes()).doesNotContain("&id5_signature="); + } + + @Test + public void shouldIncludeHidAttributesInToQueryString() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .app(App.of("com.example.app", "1.2.3")) + .id5Signature("signature") + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final String queryString = QueryBuilder.build(ids, attributes, properties()).toQueryString(); + + // then + assertThat(queryString).endsWith("&bundle=com.example.app&ver=1.2.3&id5_signature=signature"); + } + + @Test + public void shouldAppendBundleAndVerToHidAttributesWithSpecialCharacters() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .app(App.of("com.example app", "1.2 3")) + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, attributes, properties()); + + // then + assertThat(query.getHidAttributes()) + .isEqualTo("&bundle=com.example+app&ver=1.2+3"); + } + + @Test + public void shouldAppendId5SignatureToHidAttributesWithSpecialCharacters() { + // given + final OptableAttributes attributes = OptableAttributes.builder() + .id5Signature("a b&c=d") + .build(); + final List ids = List.of(Id.of(Id.EMAIL, "email")); + + // when + final Query query = QueryBuilder.build(ids, attributes, properties()); + + // then + assertThat(query.getHidAttributes()).isEqualTo("&id5_signature=a+b%26c%3Dd"); + } + @Test public void shouldAppendId5SignatureWhenPresent() { // given diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClientTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClientTest.java index e624fa56a8c..db7542c3b32 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClientTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/net/CachedAPIClientTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.prebid.server.execution.timeout.Timeout; @@ -13,6 +14,8 @@ import org.prebid.server.hooks.modules.optable.targeting.v1.BaseOptableTest; import org.prebid.server.hooks.modules.optable.targeting.v1.core.Cache; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -170,4 +173,90 @@ public void shouldCacheEmptyResultWhenCircuitBreakerIsOn() { assertThat(result.getAudience()).isNull(); verify(cache, times(1)).put(any(), eq(targetingResult.result()), anyInt()); } + + @Test + public void shouldIncludeHidInCacheKey() { + // given + final Query query = Query.of("&id=e%3Aemail", "&hid=c:234", "&gdpr=1", ""); + final ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); + when(cache.get(keyCaptor.capture())).thenReturn(Future.succeededFuture(givenTargetingResult())); + + // when + target.getTargeting( + givenOptableTargetingProperties(true), + query, + List.of("8.8.8.8"), + "user agent", + timeout); + + // then + final String key = keyCaptor.getValue(); + assertThat(key).contains(URLEncoder.encode("&hid=c:234", StandardCharsets.UTF_8)); + } + + @Test + public void shouldIncludeHidAttributesInCacheKey() { + // given + final Query query = Query.of("&id=e%3Aemail", "", "&gdpr=1", "&bundle=com.example.app"); + final ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); + when(cache.get(keyCaptor.capture())).thenReturn(Future.succeededFuture(givenTargetingResult())); + + // when + target.getTargeting( + givenOptableTargetingProperties(true), + query, + List.of("8.8.8.8"), + "user agent", + timeout); + + // then + final String key = keyCaptor.getValue(); + assertThat(key).contains(URLEncoder.encode("&bundle=com.example.app", StandardCharsets.UTF_8)); + } + + @Test + public void shouldBuildCacheKeyWithAllComponents() { + // given + final Query query = Query.of("&id=e%3Aemail", "&hid=c:234", "&gdpr=1", "&id5_signature=sig"); + final ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); + when(cache.get(keyCaptor.capture())).thenReturn(Future.succeededFuture(givenTargetingResult())); + + // when + target.getTargeting( + givenOptableTargetingProperties("key", "accountId", "origin", true), + query, + List.of("8.8.8.8"), + "user agent", + timeout); + + // then + final String key = keyCaptor.getValue(); + assertThat(key).isEqualTo( + "accountId:origin:8.8.8.8:" + + URLEncoder.encode("&id=e%3Aemail", StandardCharsets.UTF_8) + + ":" + + URLEncoder.encode("&hid=c:234", StandardCharsets.UTF_8) + + ":" + + URLEncoder.encode("&id5_signature=sig", StandardCharsets.UTF_8)); + } + + @Test + public void shouldUseNullInCacheKeyWhenHidIsNull() { + // given + final Query query = Query.of("&id=e%3Aemail", null, "&gdpr=1", null); + final ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); + when(cache.get(keyCaptor.capture())).thenReturn(Future.succeededFuture(givenTargetingResult())); + + // when + target.getTargeting( + givenOptableTargetingProperties(true), + query, + List.of("8.8.8.8"), + "user agent", + timeout); + + // then + final String key = keyCaptor.getValue(); + assertThat(key).endsWith(":null:null"); + } } From 4da5787683e907661a3aecbe7c44888fe7419cd9 Mon Sep 17 00:00:00 2001 From: Eugene Dorfman Date: Wed, 29 Jul 2026 12:28:12 +0200 Subject: [PATCH 15/25] optable-targeting: Replace em-dashes in README --- extra/modules/optable-targeting/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extra/modules/optable-targeting/README.md b/extra/modules/optable-targeting/README.md index 1dae9829156..586627d1288 100644 --- a/extra/modules/optable-targeting/README.md +++ b/extra/modules/optable-targeting/README.md @@ -295,20 +295,20 @@ the Targeting API: For app traffic (requests with an `app` object) the module forwards the application's bundle identifier as the `bundle=` query parameter and, when available, its version as `ver=`. Both values are URL-encoded. The `ver` parameter -is only sent when `bundle` is present and non-empty — requests with a version but no bundle will not produce either +is only sent when `bundle` is present and non-empty; requests with a version but no bundle will not produce either parameter. ### ID5 signature The ID5 signature is propagated to the Targeting API in two ways: -1. **Request-side signature** — when the incoming OpenRTB request carries `user.ext.optable.id5_signature`, that value +1. **Request-side signature**: when the incoming OpenRTB request carries `user.ext.optable.id5_signature`, that value is sent to the Targeting API as the `id5_signature=` query parameter. -2. **Response-side signature** — when the Targeting API response contains refs with an ID5 signature, the module +2. **Response-side signature**: when the Targeting API response contains refs with an ID5 signature, the module resolves the signature through the matching optable-eid and propagates it back into the request context, where it is later injected into the bid response (`ext.prebid.passthrough.optable.id5_signature`) for downstream use. -The `id5_signature` field is also part of the Optable input erasure — see above. +The `id5_signature` field is also part of the Optable input erasure, see above. ## Analytics Tags From ab01858c6a2b0ed935ee9ddffc8eeb0e7b6a88ae Mon Sep 17 00:00:00 2001 From: softcoder Date: Thu, 30 Jul 2026 15:30:44 +0200 Subject: [PATCH 16/25] optable-targeting: Fix double rendering of query attributes --- .../targeting/v1/core/QueryBuilder.java | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java index 3278fb8de1a..7220d138c55 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java @@ -120,26 +120,13 @@ private static String buildAttributesString(OptableAttributes optableAttributes) sb.append("&osdk=").append(REQUEST_SOURCE); - Optional.ofNullable(optableAttributes.getApp()) - .ifPresent(app -> { - final String bundle = app.getBundle(); - if (StringUtils.isNotEmpty(bundle)) { - sb.append("&bundle=").append(URLEncoder.encode(bundle, StandardCharsets.UTF_8)); - - final String ver = app.getVer(); - if (StringUtils.isNotEmpty(ver)) { - sb.append("&ver=").append(URLEncoder.encode(ver, StandardCharsets.UTF_8)); - } - } - }); - - Optional.ofNullable(optableAttributes.getId5Signature()) - .ifPresent(id5Signature -> sb.append("&id5_signature=") - .append(URLEncoder.encode(id5Signature, StandardCharsets.UTF_8))); - return sb.toString(); } - + + /** + * Kept apart from buildAttributesString because these are the only attributes that + * discriminate one user from another, so they have to take part in the cache key. + */ private static String buildHidAttributesString(OptableAttributes optableAttributes) { final StringBuilder sb = new StringBuilder(); From 05520b6c0c1d32d6dde06b875b20c0ad43d4bec5 Mon Sep 17 00:00:00 2001 From: softcoder Date: Thu, 30 Jul 2026 15:46:08 +0200 Subject: [PATCH 17/25] optable-targeting: add Targeting API failure branch to bidder request hook --- .../optable/targeting/v1/OptableBidderRequestHook.java | 10 ++-------- .../optable/targeting/v1/core/QueryBuilder.java | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java index d42dbfb3ec1..430d80247b5 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java @@ -102,14 +102,8 @@ private Future> noAction(ModuleContext mo return targetingCall.compose(targetingResult -> { moduleContext.setId5Signature(Id5Resolver.resolveId5Signature(targetingResult)); - return Future.succeededFuture( - InvocationResultImpl.builder() - .status(InvocationStatus.success) - .action(InvocationAction.no_action) - .analyticsTags(analyticsTags) - .moduleContext(moduleContext) - .build()); - }); + return noActionResponse(moduleContext, analyticsTags); + }).recover(ignored -> noActionResponse(moduleContext, analyticsTags)); } return noActionResponse(moduleContext, analyticsTags); diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java index 7220d138c55..78d362d9fec 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilder.java @@ -122,7 +122,7 @@ private static String buildAttributesString(OptableAttributes optableAttributes) return sb.toString(); } - + /** * Kept apart from buildAttributesString because these are the only attributes that * discriminate one user from another, so they have to take part in the cache key. From a6090721af5b312294459346ea8c3a0042232ebe Mon Sep 17 00:00:00 2001 From: softcoder Date: Thu, 30 Jul 2026 16:04:22 +0200 Subject: [PATCH 18/25] optable-targeting: Remove redundant dependency --- .../optable/targeting/config/OptableTargetingConfig.java | 2 +- .../hooks/modules/optable/targeting/v1/core/IdsMapper.java | 5 +---- .../modules/optable/targeting/v1/core/IdsMapperTest.java | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/config/OptableTargetingConfig.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/config/OptableTargetingConfig.java index 91ae6691bbf..ef36467d2b5 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/config/OptableTargetingConfig.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/config/OptableTargetingConfig.java @@ -48,7 +48,7 @@ OptableTargetingProperties optableTargetingProperties() { @Bean IdsMapper queryParametersExtractor(@Value("${logging.sampling-rate:0.01}") double logSamplingRate) { - return new IdsMapper(ObjectMapperProvider.mapper(), logSamplingRate); + return new IdsMapper(logSamplingRate); } @Bean diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapper.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapper.java index ddb47141248..2ea248334b3 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapper.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapper.java @@ -1,6 +1,5 @@ package org.prebid.server.hooks.modules.optable.targeting.v1.core; -import com.fasterxml.jackson.databind.ObjectMapper; import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.request.Device; import com.iab.openrtb.request.Eid; @@ -27,11 +26,9 @@ public class IdsMapper { "utiq.com", Id.UTIQ, "netid.de", Id.NET_ID); - private final ObjectMapper objectMapper; private final double logSamplingRate; - public IdsMapper(ObjectMapper objectMapper, double logSamplingRate) { - this.objectMapper = Objects.requireNonNull(objectMapper); + public IdsMapper(double logSamplingRate) { this.logSamplingRate = logSamplingRate; } diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapperTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapperTest.java index 714fc538a34..ee273761f04 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapperTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/IdsMapperTest.java @@ -32,7 +32,7 @@ public class IdsMapperTest { @BeforeEach public void setUp() { - target = new IdsMapper(objectMapper, 0.01); + target = new IdsMapper(0.01); } @Test From 23a1548ecf235ca0153516a6f3b55730576cb4d2 Mon Sep 17 00:00:00 2001 From: softcoder Date: Thu, 30 Jul 2026 17:49:29 +0200 Subject: [PATCH 19/25] optable-targeting: improve tests coverage --- .../v1/OptableBidderRequestHookTest.java | 19 +++++++++++++++ .../targeting/v1/core/Id5ResolverTest.java | 23 +++++++++++++++++++ .../targeting/v1/core/QueryBuilderTest.java | 16 +++++++++++++ 3 files changed, 58 insertions(+) diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java index 9ae7f6e4d96..3c2d1c660ab 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java @@ -78,6 +78,25 @@ public void shouldReturnNoActionWhenPerBidderEnrichmentIsDisabled() { assertThat(result.moduleContext()).isSameAs(moduleContext); } + @Test + public void shouldReturnNoActionWhenPerBidderEnrichmentIsDisabledAndTargetingCallFailed() { + // given + final ModuleContext moduleContext = givenModuleContextWithProperties( + givenOptableTargetingProperties(false)); + moduleContext.setOptableTargetingCall(Future.failedFuture(new RuntimeException("timeout"))); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + // when + final Future> future = + target.call(bidderRequestPayload, invocationContext); + // then + assertThat(future.succeeded()).isTrue(); + final InvocationResult result = future.result(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + assertThat(moduleContext.getId5Signature()).isNull(); + } + @Test public void shouldSetId5SignatureOnModuleContextWhenPerBidderEnrichmentIsDisabledAndTargetingCallIsPresent() { // given diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java index 41dc2b83ff7..da0e5bb4161 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/Id5ResolverTest.java @@ -25,6 +25,29 @@ public void setUp() { mapper = ObjectMapperProvider.mapper(); } + @Test + public void shouldReturnNullWhenSignatureIsJsonNull() { + // given + final ObjectNode uidExt = mapper.createObjectNode(); + uidExt.set("optable", mapper.createObjectNode() + .set("ref", TextNode.valueOf("refValue"))); + final Eid eid = Eid.builder() + .source("id5-sync.com") + .inserter("optable.co") + .uids(List.of(Uid.builder().id("id").ext(uidExt).build())) + .build(); + final ObjectNode refs = mapper.createObjectNode(); + refs.set("refValue", mapper.createObjectNode().putNull("signature")); + final TargetingResult targetingResult = new TargetingResult( + List.of(), + new Ortb2(new User(List.of(eid), null)), + refs); + // when + final String result = Id5Resolver.resolveId5Signature(targetingResult); + // then + assertThat(result).isNull(); + } + @Test public void shouldReturnNullWhenTargetingResultIsNull() { // when diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java index 4b329d6c136..f8eb3a99c36 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/QueryBuilderTest.java @@ -437,6 +437,22 @@ public void shouldIncludeHidAttributesInToQueryString() { // then assertThat(queryString).endsWith("&bundle=com.example.app&ver=1.2.3&id5_signature=signature"); + // they live in hidAttributes so that they take part in the cache key, and must not + // also be emitted from buildAttributesString + assertThat(queryString.split("&bundle=", -1)).hasSize(2); + assertThat(queryString.split("&ver=", -1)).hasSize(2); + assertThat(queryString.split("&id5_signature=", -1)).hasSize(2); + } + + @Test + public void shouldTrimWhitespaceAroundHidPrefixes() { + // given + final List ids = List.of(Id.of(Id.EMAIL, "email"), Id.of(Id.PHONE, "phone")); + // when + final Query query = QueryBuilder.build( + ids, OptableAttributes.builder().build(), givenProperties(idPrefixOrder, " e , p ")); + // then + assertThat(query.getHid()).isEqualTo("&hid=e:email&hid=p:phone"); } @Test From 609d416c24ca04a11a9919777e663158cdbf94ab Mon Sep 17 00:00:00 2001 From: softcoder Date: Thu, 30 Jul 2026 17:59:45 +0200 Subject: [PATCH 20/25] optable-targeting: don't add blank id5_signature into a query --- .../optable/targeting/v1/core/OptableAttributesResolver.java | 1 + 1 file changed, 1 insertion(+) diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java index 39783e9bf34..0828adea34d 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/core/OptableAttributesResolver.java @@ -68,6 +68,7 @@ public static OptableAttributes resolveAttributes(AuctionContext auctionContext, .map(ext -> ext.getProperty("optable")) .map(it -> ExtUserOptableResolver.resolveExtUserOptable(it, logSamplingRate)) .map(ExtUserOptable::getId5Signature) + .filter(StringUtils::isNotBlank) .ifPresent(builder::id5Signature); return builder.build(); From 40be6aea20bb73f8052d0236d5a28e7d7c04f110 Mon Sep 17 00:00:00 2001 From: softcoder Date: Thu, 30 Jul 2026 19:02:20 +0200 Subject: [PATCH 21/25] optable-targeting: remove id5_signature resolving for bidders which are not participate in targeting enrichment process. --- .../targeting/v1/OptableBidderRequestHook.java | 17 ++--------------- .../v1/OptableBidderRequestHookTest.java | 8 ++++---- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java index 430d80247b5..317cb621fe4 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHook.java @@ -81,7 +81,7 @@ private Future> failedAction(ModuleContex final Tags analyticsTags = AnalyticTagsResolver.toBidderEnrichRequestAnalyticTags( bidder, outcome, executionTime); - return noActionResponse(moduleContext, analyticsTags); + return noAction(moduleContext, analyticsTags); } private static boolean hasEnrichmentData(TargetingResult targetingResult) { @@ -96,20 +96,7 @@ private static long calcExecutionTime(ModuleContext moduleContext) { return startTime > 0 ? System.currentTimeMillis() - startTime : 0; } - private Future> noAction(ModuleContext moduleContext, Tags analyticsTags) { - final Future targetingCall = moduleContext.getOptableTargetingCall(); - if (targetingCall != null) { - return targetingCall.compose(targetingResult -> { - moduleContext.setId5Signature(Id5Resolver.resolveId5Signature(targetingResult)); - - return noActionResponse(moduleContext, analyticsTags); - }).recover(ignored -> noActionResponse(moduleContext, analyticsTags)); - } - - return noActionResponse(moduleContext, analyticsTags); - } - - private Future> noActionResponse(ModuleContext moduleContext, + private Future> noAction(ModuleContext moduleContext, Tags analyticsTags) { return Future.succeededFuture( diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java index 3c2d1c660ab..5778ac41289 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableBidderRequestHookTest.java @@ -98,7 +98,7 @@ public void shouldReturnNoActionWhenPerBidderEnrichmentIsDisabledAndTargetingCal } @Test - public void shouldSetId5SignatureOnModuleContextWhenPerBidderEnrichmentIsDisabledAndTargetingCallIsPresent() { + public void shouldNotSetId5SignatureOnModuleContextWhenPerBidderEnrichmentIsDisabledAndTargetingCallIsPresent() { // given final String refValue = "refValue"; final String signature = "id5Signature"; @@ -121,11 +121,11 @@ public void shouldSetId5SignatureOnModuleContextWhenPerBidderEnrichmentIsDisable assertThat(result).isNotNull() .returns(InvocationStatus.success, InvocationResult::status) .returns(InvocationAction.no_action, InvocationResult::action); - assertThat(moduleContext.getId5Signature()).isEqualTo(signature); + assertThat(moduleContext.getId5Signature()).isNull(); } @Test - public void shouldSetId5SignatureOnModuleContextWhenBidderNotInEnrichmentSetAndTargetingCallIsPresent() { + public void shouldNotSetId5SignatureOnModuleContextWhenBidderNotInEnrichmentSetAndTargetingCallIsPresent() { // given final String refValue = "refValue"; final String signature = "id5Signature"; @@ -149,7 +149,7 @@ public void shouldSetId5SignatureOnModuleContextWhenBidderNotInEnrichmentSetAndT assertThat(result).isNotNull() .returns(InvocationStatus.success, InvocationResult::status) .returns(InvocationAction.no_action, InvocationResult::action); - assertThat(moduleContext.getId5Signature()).isEqualTo(signature); + assertThat(moduleContext.getId5Signature()).isNull(); } @Test From ce92ffbda03d396b4d47090df7a12cf6eba8434e Mon Sep 17 00:00:00 2001 From: softcoder Date: Thu, 30 Jul 2026 19:55:47 +0200 Subject: [PATCH 22/25] optable-targeting: render id5_signature only if targeting enabled --- .../OptableTargetingAuctionResponseHook.java | 32 +++++-------- ...tableTargetingAuctionResponseHookTest.java | 45 ++++++++++++++----- 2 files changed, 45 insertions(+), 32 deletions(-) diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java index 8b0e233a003..0b94e1bf6be 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java @@ -2,7 +2,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.vertx.core.Future; -import org.apache.commons.collections4.CollectionUtils; import org.prebid.server.hooks.execution.v1.InvocationResultImpl; import org.prebid.server.hooks.modules.optable.targeting.model.EnrichmentStatus; import org.prebid.server.hooks.modules.optable.targeting.model.ModuleContext; @@ -57,32 +56,18 @@ public Future> call(AuctionResponsePayl return success(moduleContext); } + final List targeting = moduleContext.getTargeting(); + final String id5Signature = moduleContext.getId5Signature(); + final EnrichmentStatus validationStatus = AuctionResponseValidator.checkEnrichmentPossibility( - auctionResponsePayload.bidResponse(), moduleContext.getTargeting()); + auctionResponsePayload.bidResponse(), targeting); moduleContext.setEnrichResponseStatus(validationStatus); return validationStatus.getStatus() == Status.SUCCESS - ? enrichedPayload(moduleContext) - : enrichedById5SignaturePayload(moduleContext); - } - - private Future> enrichedPayload(ModuleContext moduleContext) { - final List targeting = moduleContext.getTargeting(); - final String id5Signature = moduleContext.getId5Signature(); - - return CollectionUtils.isNotEmpty(targeting) ? update(fullEnrichmentChain(targeting, id5Signature), moduleContext) : update(id5SignatureEnrichmentChain(id5Signature), moduleContext); } - private Future> enrichedById5SignaturePayload( - ModuleContext moduleContext) { - - final String id5Signature = moduleContext.getId5Signature(); - - return update(id5SignatureEnrichmentChain(id5Signature), moduleContext); - } - private PayloadUpdate fullEnrichmentChain(final List targeting, final String id5Signature) { @@ -109,8 +94,13 @@ private Future> update( } private Future> success(ModuleContext moduleContext) { - final String id5Signature = moduleContext.getId5Signature(); - return update(id5SignatureEnrichmentChain(id5Signature), moduleContext); + return Future.succeededFuture( + InvocationResultImpl.builder() + .status(InvocationStatus.success) + .action(InvocationAction.no_action) + .moduleContext(moduleContext) + .analyticsTags(AnalyticTagsResolver.toEnrichResponseAnalyticTags(moduleContext)) + .build()); } @Override diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java index 19632668f63..9bb0d1a7a10 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java @@ -11,6 +11,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.prebid.server.hooks.execution.v1.auction.AuctionResponsePayloadImpl; import org.prebid.server.hooks.modules.optable.targeting.model.ModuleContext; +import org.prebid.server.hooks.modules.optable.targeting.model.config.OptableTargetingProperties; import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.Audience; import org.prebid.server.hooks.modules.optable.targeting.model.openrtb.AudienceId; import org.prebid.server.hooks.modules.optable.targeting.v1.core.ConfigResolver; @@ -283,32 +284,54 @@ public void shouldReturnResultWithUpdateActionWhenTargetingCallFailsAndNoBids() } @Test - public void shouldReturnUpdateActionWithId5SignatureWhenSkipEnrichmentIsTrue() { + public void shouldReturnNoActionWhenAdserverTargetingIsDisabled() { + // given + final OptableTargetingProperties properties = givenOptableTargetingProperties(false); + properties.setAdserverTargeting(false); + configResolver = new ConfigResolver(mapper, jsonMerger, properties); + target = new OptableTargetingAuctionResponseHook(configResolver, mapper, jsonMerger); + when(invocationContext.accountConfig()).thenReturn(mapper.valueToTree(properties)); + when(invocationContext.moduleContext()).thenReturn(givenModuleContext(List.of( + new Audience( + "provider", + List.of(new AudienceId("audienceId")), + "keyspace", + 1)), + Future.failedFuture(new RuntimeException("error")))); + + // when + final Future> future = + target.call(auctionResponsePayload, invocationContext); + final InvocationResult result = future.result(); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + assertThat(result.payloadUpdate()).isNull(); + } + + @Test + public void shouldReturnNoActionWhenSkipEnrichmentIsTrue() { // given - final String signature = "id5Signature"; final ModuleContext moduleContext = givenModuleContext(); moduleContext.setShouldSkipEnrichment(true); - moduleContext.setId5Signature(signature); when(invocationContext.moduleContext()).thenReturn(moduleContext); // when final Future> future = target.call(auctionResponsePayload, invocationContext); final InvocationResult result = future.result(); - final BidResponse bidResponse = result - .payloadUpdate() - .apply(AuctionResponsePayloadImpl.of(givenBidResponse())) - .bidResponse(); - final JsonNode passthrough = bidResponse.getExt().getPrebid().getPassthrough(); // then assertThat(future).isNotNull(); assertThat(future.succeeded()).isTrue(); assertThat(result).isNotNull() .returns(InvocationStatus.success, InvocationResult::status) - .returns(InvocationAction.update, InvocationResult::action); - assertThat(passthrough).isNotNull(); - assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo(signature); + .returns(InvocationAction.no_action, InvocationResult::action); + assertThat(result.payloadUpdate()).isNull(); } private ObjectNode givenAccountConfig(boolean cacheEnabled) { From 6f0afeef74397b8d1df4eed93b13a1e362257187 Mon Sep 17 00:00:00 2001 From: softcoder Date: Fri, 31 Jul 2026 12:10:56 +0200 Subject: [PATCH 23/25] optable-targeting: split targeting and id5 features rendering --- .../OptableTargetingAuctionResponseHook.java | 14 +- ...tableTargetingAuctionResponseHookTest.java | 178 +++++++++++++++++- 2 files changed, 182 insertions(+), 10 deletions(-) diff --git a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java index 0b94e1bf6be..5b915618b0c 100644 --- a/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java +++ b/extra/modules/optable-targeting/src/main/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHook.java @@ -53,19 +53,18 @@ public Future> call(AuctionResponsePayl moduleContext.setAdserverTargetingEnabled(adserverTargeting); if (moduleContext.isShouldSkipEnrichment() || !adserverTargeting) { - return success(moduleContext); + return id5SignatureOnlyPayload(moduleContext); } final List targeting = moduleContext.getTargeting(); - final String id5Signature = moduleContext.getId5Signature(); final EnrichmentStatus validationStatus = AuctionResponseValidator.checkEnrichmentPossibility( auctionResponsePayload.bidResponse(), targeting); moduleContext.setEnrichResponseStatus(validationStatus); return validationStatus.getStatus() == Status.SUCCESS - ? update(fullEnrichmentChain(targeting, id5Signature), moduleContext) - : update(id5SignatureEnrichmentChain(id5Signature), moduleContext); + ? update(fullEnrichmentChain(targeting, moduleContext.getId5Signature()), moduleContext) + : id5SignatureOnlyPayload(moduleContext); } private PayloadUpdate fullEnrichmentChain(final List targeting, @@ -75,6 +74,13 @@ private PayloadUpdate fullEnrichmentChain(final List> id5SignatureOnlyPayload(ModuleContext moduleContext) { + final String id5Signature = moduleContext.getId5Signature(); + return id5Signature != null + ? update(id5SignatureEnrichmentChain(id5Signature), moduleContext) + : success(moduleContext); + } + private PayloadUpdate id5SignatureEnrichmentChain(String id5Signature) { return Id5SignatureBidResponseEnricher.of(id5Signature, objectMapper, jsonMerger); } diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java index 9bb0d1a7a10..948b1b50675 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java @@ -60,7 +60,7 @@ public void shouldHaveCode() { } @Test - public void shouldReturnResultWithUpdateActionAndWithPBSAnalyticsTags() { + public void shouldReturnResultWithNoActionAndWithPBSAnalyticsTagsWhenTargetingIsEmptyAndNoId5Signature() { // given when(invocationContext.moduleContext()).thenReturn( givenModuleContext(null, Future.failedFuture(new RuntimeException("error")))); @@ -76,7 +76,8 @@ public void shouldReturnResultWithUpdateActionAndWithPBSAnalyticsTags() { final InvocationResult result = future.result(); assertThat(result).isNotNull() .returns(InvocationStatus.success, InvocationResult::status) - .returns(InvocationAction.update, InvocationResult::action); + .returns(InvocationAction.no_action, InvocationResult::action); + assertThat(result.payloadUpdate()).isNull(); assertThat(result.analyticsTags()) .extracting(Tags::activities) .extracting(List::getFirst) @@ -88,6 +89,36 @@ public void shouldReturnResultWithUpdateActionAndWithPBSAnalyticsTags() { assertThat(result.errors()).isNull(); } + @Test + public void shouldReturnResultWithUpdateActionAndId5SignatureWhenTargetingIsEmptyButId5SignatureIsPresent() { + // given + final String signature = "id5Signature"; + final ModuleContext moduleContext = + givenModuleContext(null, Future.failedFuture(new RuntimeException("error"))); + moduleContext.setId5Signature(signature); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + when(auctionResponsePayload.bidResponse()).thenReturn(givenBidResponse()); + + // when + final Future> future = + target.call(auctionResponsePayload, invocationContext); + final InvocationResult result = future.result(); + final BidResponse bidResponse = result + .payloadUpdate() + .apply(AuctionResponsePayloadImpl.of(givenBidResponse())) + .bidResponse(); + final JsonNode passthrough = bidResponse.getExt().getPrebid().getPassthrough(); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action); + assertThat(passthrough).isNotNull(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo(signature); + } + @Test public void shouldReturnResultWithUpdateActionWhenAdvertiserTargetingOptionIsOn() { // given @@ -241,7 +272,7 @@ public void shouldReturnUpdateActionWhenTargetingIsPresentEvenIfOptableTargeting } @Test - public void shouldReturnUpdateActionWhenOptableTargetingCallFailsAndTargetingIsEmpty() { + public void shouldReturnNoActionWhenOptableTargetingCallFailsAndTargetingIsEmptyAndNoId5Signature() { // given when(invocationContext.moduleContext()).thenReturn( givenModuleContext(null, Future.failedFuture(new RuntimeException("error")))); @@ -251,16 +282,47 @@ public void shouldReturnUpdateActionWhenOptableTargetingCallFailsAndTargetingIsE target.call(auctionResponsePayload, invocationContext); final InvocationResult result = future.result(); + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + assertThat(result.payloadUpdate()).isNull(); + } + + @Test + public void shouldReturnUpdateActionWithId5SignatureWhenOptableTargetingCallFailsAndTargetingIsEmpty() { + // given + final String signature = "id5Signature"; + final ModuleContext moduleContext = + givenModuleContext(null, Future.failedFuture(new RuntimeException("error"))); + moduleContext.setId5Signature(signature); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + when(auctionResponsePayload.bidResponse()).thenReturn(givenBidResponse()); + + // when + final Future> future = + target.call(auctionResponsePayload, invocationContext); + final InvocationResult result = future.result(); + final BidResponse bidResponse = result + .payloadUpdate() + .apply(AuctionResponsePayloadImpl.of(givenBidResponse())) + .bidResponse(); + final JsonNode passthrough = bidResponse.getExt().getPrebid().getPassthrough(); + // then assertThat(future).isNotNull(); assertThat(future.succeeded()).isTrue(); assertThat(result).isNotNull() .returns(InvocationStatus.success, InvocationResult::status) .returns(InvocationAction.update, InvocationResult::action); + assertThat(passthrough).isNotNull(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo(signature); } @Test - public void shouldReturnResultWithUpdateActionWhenTargetingCallFailsAndNoBids() { + public void shouldReturnNoActionWhenTargetingCallFailsAndNoBidsAndNoId5Signature() { // given when(invocationContext.moduleContext()).thenReturn(givenModuleContext(List.of( new Audience( @@ -275,16 +337,52 @@ public void shouldReturnResultWithUpdateActionWhenTargetingCallFailsAndNoBids() target.call(auctionResponsePayload, invocationContext); final InvocationResult result = future.result(); + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.no_action, InvocationResult::action); + assertThat(result.payloadUpdate()).isNull(); + } + + @Test + public void shouldReturnUpdateActionWithId5SignatureWhenTargetingCallFailsAndNoBids() { + // given + final String signature = "id5Signature"; + final ModuleContext moduleContext = givenModuleContext(List.of( + new Audience( + "provider", + List.of(new AudienceId("audienceId")), + "keyspace", + 1)), + Future.failedFuture(new RuntimeException("error"))); + moduleContext.setId5Signature(signature); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + when(auctionResponsePayload.bidResponse()).thenReturn(givenBidResponse()); + + // when + final Future> future = + target.call(auctionResponsePayload, invocationContext); + final InvocationResult result = future.result(); + final BidResponse bidResponse = result + .payloadUpdate() + .apply(AuctionResponsePayloadImpl.of(givenBidResponse())) + .bidResponse(); + final JsonNode passthrough = bidResponse.getExt().getPrebid().getPassthrough(); + // then assertThat(future).isNotNull(); assertThat(future.succeeded()).isTrue(); assertThat(result).isNotNull() .returns(InvocationStatus.success, InvocationResult::status) .returns(InvocationAction.update, InvocationResult::action); + assertThat(passthrough).isNotNull(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo(signature); } @Test - public void shouldReturnNoActionWhenAdserverTargetingIsDisabled() { + public void shouldReturnNoActionWhenAdserverTargetingIsDisabledAndNoId5Signature() { // given final OptableTargetingProperties properties = givenOptableTargetingProperties(false); properties.setAdserverTargeting(false); @@ -314,7 +412,46 @@ public void shouldReturnNoActionWhenAdserverTargetingIsDisabled() { } @Test - public void shouldReturnNoActionWhenSkipEnrichmentIsTrue() { + public void shouldReturnUpdateActionWithId5SignatureWhenAdserverTargetingIsDisabled() { + // given + final String signature = "id5Signature"; + final OptableTargetingProperties properties = givenOptableTargetingProperties(false); + properties.setAdserverTargeting(false); + configResolver = new ConfigResolver(mapper, jsonMerger, properties); + target = new OptableTargetingAuctionResponseHook(configResolver, mapper, jsonMerger); + when(invocationContext.accountConfig()).thenReturn(mapper.valueToTree(properties)); + final ModuleContext moduleContext = givenModuleContext(List.of( + new Audience( + "provider", + List.of(new AudienceId("audienceId")), + "keyspace", + 1)), + Future.failedFuture(new RuntimeException("error"))); + moduleContext.setId5Signature(signature); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + + // when + final Future> future = + target.call(auctionResponsePayload, invocationContext); + final InvocationResult result = future.result(); + final BidResponse bidResponse = result + .payloadUpdate() + .apply(AuctionResponsePayloadImpl.of(givenBidResponse())) + .bidResponse(); + final JsonNode passthrough = bidResponse.getExt().getPrebid().getPassthrough(); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action); + assertThat(passthrough).isNotNull(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo(signature); + } + + @Test + public void shouldReturnNoActionWhenSkipEnrichmentIsTrueAndNoId5Signature() { // given final ModuleContext moduleContext = givenModuleContext(); moduleContext.setShouldSkipEnrichment(true); @@ -334,6 +471,35 @@ public void shouldReturnNoActionWhenSkipEnrichmentIsTrue() { assertThat(result.payloadUpdate()).isNull(); } + @Test + public void shouldReturnUpdateActionWithId5SignatureWhenSkipEnrichmentIsTrue() { + // given + final String signature = "id5Signature"; + final ModuleContext moduleContext = givenModuleContext(); + moduleContext.setShouldSkipEnrichment(true); + moduleContext.setId5Signature(signature); + when(invocationContext.moduleContext()).thenReturn(moduleContext); + + // when + final Future> future = + target.call(auctionResponsePayload, invocationContext); + final InvocationResult result = future.result(); + final BidResponse bidResponse = result + .payloadUpdate() + .apply(AuctionResponsePayloadImpl.of(givenBidResponse())) + .bidResponse(); + final JsonNode passthrough = bidResponse.getExt().getPrebid().getPassthrough(); + + // then + assertThat(future).isNotNull(); + assertThat(future.succeeded()).isTrue(); + assertThat(result).isNotNull() + .returns(InvocationStatus.success, InvocationResult::status) + .returns(InvocationAction.update, InvocationResult::action); + assertThat(passthrough).isNotNull(); + assertThat(passthrough.get("optable").get("id5_signature").asText()).isEqualTo(signature); + } + private ObjectNode givenAccountConfig(boolean cacheEnabled) { return mapper.valueToTree(givenOptableTargetingProperties(cacheEnabled)); } From 765a5381da839280471ff0c99d12b49cd7c4d593 Mon Sep 17 00:00:00 2001 From: Eugene Dorfman Date: Fri, 31 Jul 2026 12:37:56 +0200 Subject: [PATCH 24/25] optable-targeting: fix NoBids test to use a bid-less response The test named for the NOBID branch stubbed a bid response that has a bid, so with non-empty targeting the validator returned SUCCESS and the test exercised the full enrichment chain instead of the signature-only one. --- .../v1/OptableTargetingAuctionResponseHookTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java index 948b1b50675..08c1d4d3997 100644 --- a/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java +++ b/extra/modules/optable-targeting/src/test/java/org/prebid/server/hooks/modules/optable/targeting/v1/OptableTargetingAuctionResponseHookTest.java @@ -359,7 +359,8 @@ public void shouldReturnUpdateActionWithId5SignatureWhenTargetingCallFailsAndNoB Future.failedFuture(new RuntimeException("error"))); moduleContext.setId5Signature(signature); when(invocationContext.moduleContext()).thenReturn(moduleContext); - when(auctionResponsePayload.bidResponse()).thenReturn(givenBidResponse()); + final BidResponse bidlessResponse = BidResponse.builder().build(); + when(auctionResponsePayload.bidResponse()).thenReturn(bidlessResponse); // when final Future> future = @@ -367,7 +368,7 @@ public void shouldReturnUpdateActionWithId5SignatureWhenTargetingCallFailsAndNoB final InvocationResult result = future.result(); final BidResponse bidResponse = result .payloadUpdate() - .apply(AuctionResponsePayloadImpl.of(givenBidResponse())) + .apply(AuctionResponsePayloadImpl.of(bidlessResponse)) .bidResponse(); final JsonNode passthrough = bidResponse.getExt().getPrebid().getPassthrough(); From bde6c04a35af2e612e371933a43902bcbb66d776 Mon Sep 17 00:00:00 2001 From: Eugene Dorfman Date: Fri, 31 Jul 2026 15:29:25 +0200 Subject: [PATCH 25/25] optable-targeting: remove editor and AI tool config committed by mistake .cursorrules, GEMINI.md, .gemini/ and extra/.vscode_old/ are local tooling config that got swept into the branch during a rebase. Nothing references them. --- .cursorrules | 262 -------------------------------- .gemini/settings.json | 15 -- GEMINI.md | 9 -- extra/.vscode_old/launch.json | 36 ----- extra/.vscode_old/settings.json | 18 --- extra/.vscode_old/tasks.json | 33 ---- 6 files changed, 373 deletions(-) delete mode 100644 .cursorrules delete mode 100644 .gemini/settings.json delete mode 100644 GEMINI.md delete mode 100644 extra/.vscode_old/launch.json delete mode 100644 extra/.vscode_old/settings.json delete mode 100644 extra/.vscode_old/tasks.json diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index 9d6f12e9da7..00000000000 --- a/.cursorrules +++ /dev/null @@ -1,262 +0,0 @@ -# Prebid Server Java Coding Rules - -You are an expert Java developer and Professor of Software Engineering, specializing in the Prebid Server Java codebase. You adhere to strict high-quality standards, favoring Modern Java (17+), Vert.x patterns, JUnit 5, Mockito (BDD style), and AssertJ. - -## 1. General Coding Philosophy -- **Non-Blocking I/O**: This is a Vert.x application. NEVER block the Event Loop. Use asynchronous patterns (`Future`, `Promise`) for I/O operations. -- **Immutability**: Prefer immutable data structures. - - Use `final` for all local variables, fields, and parameters. - - **Modern Java:** Use Java 17+ features like `record` and `sealed interface` where appropriate for data carriers and hierarchy restrictions. - - **Var Usage:** `var` is **STRICTLY PROHIBITED** in production code. Use explicit types. `var` is ALLOWED in tests. - - **Final Keyword:** Use `final` for all variables and parameters where possible to ensure immutability. - - **Switch Expressions:** Use modern `switch` expressions with arrow syntax `->`. - - **DTOs / POJOs**: **STRICTLY PROHIBITED** to write manual Getters, Setters, `toString`, `equals`, `hashCode`, or Constructors if Lombok can handle it. - - Use `@Value` + `staticConstructor="of"` for immutable value objects (default). - - Use `@Builder(toBuilder = true)` for complex objects (>4 fields) or when mutation-like copies are needed. - - Use `@AllArgsConstructor` / `@RequiredArgsConstructor` for simple dependency injection or wrappers. -- **Variable Types**: **DO NOT USE `var`**. Always write full variable types (Project Convention). -- **Null Safety**: - - Avoid returning `null`. Use `Optional` for return types. - - Avoid long chains of null checks; use `Optional` or `org.prebid.server.util.ObjectUtil`. -- **Privacy**: **STRICTLY PROHIBITED** to log private data (publishers, exchanges, usage analytics). -- **JSON Handling**: - - **FORBIDDEN**: `io.vertx.core.json.Json` (static Vert.x mapper). - - **ALLOWED**: Inject and use `JacksonMapper` or the project's configured `ObjectMapper`. - -## 3. Libraries & Utilities -- **Apache Commons**: Prefer using existing helpers from `commons-lang3` and `commons-collections4` over writing custom utility methods. - - Used: `StringUtils`, `ObjectUtils`, `CollectionUtils`, `MapUtils`. - - Avoid creating new `*Util` classes if an Apache Commons equivalent exists. - - Example: Use `StringUtils.isBlank(str)` instead of `str == null || str.trim().isEmpty()`. -- **Project Utilities**: - - **Project Utilities:** Use `org.prebid.server.util.ObjectUtil.getIfNotNull()` for concise, null-safe access chains (alternative to `Optional` in hot paths). - - **Apache Commons:** Heavily prefer `StringUtils`, `ObjectUtils`, `CollectionUtils`, `ListUtils`, `MapUtils` for null-safe operations. - - **Validation:** Use `java.util.Objects.requireNonNull(arg)` in constructors to enforce non-null dependencies. - - **Collection Utilities:** Use `java.util.Collections.emptyList()`, `Map.of()`, `Set.of()` etc. for better readability and immutability. - - -## 2. Code Style & Standards (Oracle & Checkstyle) -- **Base Standard**: Adhere to [Oracle Java Code Conventions](https://www.oracle.com/docs/tech/java/codeconventions.pdf) unless overridden by project-specific Checkstyle rules. -- **Indentation**: - - **4 spaces** for class members, methods, and blocks. - - **8 spaces** for line continuations (wrapping). -- **Line Length**: Max 120 characters (overrides Oracle's 80). -- **Class Structure** (Oracle Convention): - 1. Class/Instance Variables (Standard Order: public, protected, package, private). - 2. Constructors. - 3. Methods (Grouped by functionality, logic flow, or readability). -- **Declarations**: - - One variable declaration per line. - - Initialize variables at declaration where possible. - - Place declarations at the beginning of blocks (legacy Oracle) OR near first usage (Modern/Clean Code) -> **Prefer near first usage**. -- **Statements**: - - Always use braces `{}` for `if`, `else`, `for`, `do`, `while` (K&R style). - - No parentheses in `return` statements. - -- **Imports**: - - **FORBIDDEN**: Wildcard imports (`import java.util.*;`). - - Order: Third-party libraries first, then `java.*`/`jakarta.*` at the bottom (separated by blank line). - - No `static` imports in production code (except standard utilities if really needed), but allowed in Tests. - - **Illegal Imports**: `org.junit.Test` (JUnit 4), `org.apache.commons.lang` (use `lang3`), `io.vertx.core.json.Json`. -- **Naming**: - - Constants: `UPPER_CASE_WITH_UNDERSCORES`. - - Methods/Fields: `camelCase`. - - **Maps**: Use `keyToValue` convention (e.g., `impToExt`, `accountIdToBidder`). - - **Self-Explanatory**: Avoid single-letter variables. `resolvedParam` > `s`. -- **Collections**: - - Use literals/factories: `List.of()`, `Collections.emptyList()`, `Collections.singletonList()`. -- **Formatting**: - - Parenthesis on expression end. - - Ternary operators: Long ones on separate lines, short ones on one line. - - Boolean logic: Explicit parenthesis for precedence `(a && b) || c`. - - **Method Ordering**: Call order (Interface method -> private methods it calls -> next Interface method). -- **Dependencies**: Do not call methods from transitive dependencies; declare them explicitly in `pom.xml`. -- **Lombok**: - - Use `@Value` + `staticConstructor="of"`. - - Use `@Builder` for constructors with > 4 arguments. - - `toBuilder = true` for updates. - -## 4. Architecture, SOLID & Design Patterns - -### 4.1 SOLID Principles -- **SRP (Single Responsibility)**: - - Classes must have one clearly defined purpose. - - **Refactor**: Split large "God Classes" into smaller delegates or services. -- **OCP (Open/Closed)**: - - Design for extension. Use Interfaces and Strategy patterns so new behavior can be added without changing existing code. -- **LSP (Liskov Substitution)**: - - Subclasses/Implementations must behave consistently. Do not throw `UnsupportedOperationException` unexpectedly. -- **ISP (Interface Segregation)**: - - Prefer focused interfaces (e.g., `Reader`, `Writer`) over large broad ones. -- **DIP (Dependency Inversion)**: - - Depend on abstractions (Interfaces). - - **Framework**: Use **Spring Framework** for Dependency Injection. - - **Injection Style**: **Constructor Injection** is REQUIRED. Field injection (`@Autowired` on fields) is **STRICTLY PROHIBITED**. - -### 4.2 Clean Code & Best Practices -- **Readability**: Code is read more often than written. Optimize for reading. -- **Naming**: - - **Intent-Revealing**: `daysSinceCreation` > `d`. - - **No Encodings**: No Hungarian notation or type prefixes. -- **Functions**: - - **Small**: Methods should be small and do one thing. - - **Guard Clauses**: Use strict guard clauses/early returns to flatten nesting. - - **Pure Functions**: Prefer `private static` methods for stateless logic. -- **Comments**: - - **Why, not What**: Comments should explain the business decision, not the syntax. - - **No Code Comments**: Do not comment out code; delete it. Git history remembers. - -### 4.3 Architecture & Reactive Vert.x Patterns -- **Reactive Philosophy**: - - **Everything is a Stream/Future**: Treat business logic as a pipeline of transformation steps. - - **Chaining**: Build flows by chaining operator methods (`map`, `compose`, `onContentType`, `recover`). - - **No Callbacks**: "Callback Hell" is strictly prohibited. Use functional composition. -- **Future Composition (The Glue)**: - - **Strict Transformation Pipeline:** Treat business logic as a stream of data transformations. - - **Chaining:** Use `.map()` for synchronous transformations and `.compose()` for asynchronous operations. - - **Parallelism:** Use `Future.all()`, `CompositeFuture.join()`, or `CompositeFuture.all()` for parallel execution. - - **Side Effects:** Use `.onSuccess()`, `.onFailure()`, and `.onComplete()` **ONLY** for side effects (logging, metrics). **NEVER** use them for control flow or chaining logic. - - **Error Handling:** Propagate errors down the chain. Use `.recover()` for falling back or transforming errors. - - **Avoid Callbacks:** **STRICTLY PROHIBIT** nested callbacks or "Callback Hell". logic must be flat. - - **Thread Safety:** Always assume the code runs on the Event Loop. **NEVER** block the thread. - - **Context Awareness:** Pass `RoutingContext` or `AuctionContext` as a carrier of state through the chain. - - ### 4.4 Object-Oriented Patterns - - **Value Objects:** Use Lombok `@Value(staticConstructor = "of")` for immutable data carriers. - - **Factory Methods:** Prefer static factory methods named `of(...)` or `create(...)` over public constructors for complex object creation. - - **NoOp Pattern:** For interfaces that may have empty implementations (e.g., hooks, empty services), create a `NoOpExtension` or `static NoOp` implementation within the interface or as a separate class. - - **Sealed Hierarchies:** Use `sealed interface` and `record` (Java 17+) for restricted class hierarchies (e.g., specialized result types). - - ### 4.5 JSON & Serialization - - **JacksonMapper:** Always use the project's `JacksonMapper` wrapper instead of raw `ObjectMapper` where possible. - - **Dynamic JSON:** Use `ObjectNode` and `ArrayNode` for handling dynamic or unstructured data (especially `ext` fields) rather than untyped `Map`. - - **JsonPointer:** Use `JsonPointer` for safe and readable deep traversal of JSON trees (`node.at("/path/to/field")`). -- **Concurrency (Vert.x Core Rule)**: - - **Single Threaded Event Loop**: The application runs on the Event Loop. - - **PROHIBITED**: `synchronized`, `wait()`, `notify()`, `Thread.sleep()`, `BlockingQueue`, or any blocking Java concurrency primitive. - - **Blocking Code**: If you MUST run blocking code (e.g. legacy JDBC), use `vertx.executeBlocking(...)` but prefer non-blocking clients. - - **ALLOWED**: `Future`, `Promise`, `vertx.setTimer()`. -- **Error Handling**: - - **Async**: In async chains, errors propagate automatically. Do not break the chain. - - **Return Failed Future**: In async methods, return `Future.failedFuture(e)` immediately rather than throwing runtime exceptions. - - **Granularity**: Catch specific exceptions in `.recover()`. Avoid broad catches unless it's a top-level handler. -- **Context**: Be aware of the Vert.x `Context`. Ensure context is preserved when switching threads. -- **Logging**: - - Use standard SLF4J usages: `private static final Logger logger = LoggerFactory.getLogger(MyClass.class);` - - Do not use `System.out.println` or `e.printStackTrace()`. - - Log at `DEBUG` for high-volume messages, `INFO` for lifecycle events, `WARN/ERROR` for unexpected conditions. - -## 5. Testing Rules -- **Frameworks**: - - **JUnit 5**: `org.junit.jupiter.api.*`. - - **Behavior Driven Development (BDD)**: Use `given(mock.method()).willReturn(...)` instead of `when(...)`. - - **Strictness**: Use `@Mock(strictness = Mock.Strictness.LENIENT)` for infrastructure mocks (Metrics, Services) defined in `setUp` but not used in every test to avoid `UnnecessaryStubbingException`. - - **Async Testing**: For `Future`-based tests, use `VertxTestContext`: - ```java - @Test - void shouldSucceed(VertxTestContext context) { - future.onComplete(context.succeeding(result -> { - assertThat(result).isNotNull(); - context.completeNow(); - })); - } - ``` - - **Time Testing**: Mock `java.time.Clock` ensures deterministic time tests. - - **Static Imports**: ALWAYS statically import: - - `org.assertj.core.api.Assertions.*` (assertThat, tuple, entry) - - `org.mockito.BDDMockito.*` (given) - - `org.mockito.Mockito.*` (verify, never, etc.) - - `org.mockito.ArgumentMatchers.*` (any, eq, etc.) - - **Assertions**: - - Use `extracting()` for nested properties. - - Use `containsExactlyInAnyOrder` for lists. - - Use `containsOnly` for verifying single-element logical containment. - - **Structure**: - - Use `target` as the name for the class under test instance. - - Use `setUp()` method annotated with `@BeforeEach`. - - **Vert.x Data**: Use `MultiMap.caseInsensitiveMultiMap()` for testing headers/params. - - Fields: SUT (System Under Test) named `target`. -- **Granularity**: - - 1 Test = 1 Logic Path. Avoid `testFooAndBar`. Split into `testFoo` and `testBar`. - - No `ParameterizedTest` preference; explicitly write separate tests for meaningful scenarios (per docs). -- **Data Placement**: - - **Inline Data**: Place test data INSIDE the test method (local variables). - - **No Constants**: Avoid class-level constants for test data. - - **Fake Data**: Do not use real URLs/IDs (use `test.com`, `id`). -- **Mocking**: - - Annotate test class with `@ExtendWith(MockitoExtension.class)`. - - Use `@Mock` for dependencies. - - Use strict mocking (default). Only use `lenientness` if absolutely necessary for shared setup. -- **Naming**: `methodNameShouldReturnExpectedBehaviorWhenCondition`. - - Example: `processDataShouldReturnResultWhenInputIsData`. -- **BDD Style**: - ```java - // given - given(dependency.call()).willReturn(futureResult); - final var input = "test-input"; // 'var' is acceptable in tests only if brevity aids readability, but prefer explicit types per project rule. - - // when - Future result = target.execute(input); - - // then - assertThat(result.succeeded()).isTrue(); - ``` - -## 6. Implementation Workflow (Agent Instructions) -1. **Analyze**: Read existing code and `pom.xml` to understand dependencies. -2. **Plan**: Propose changes before writing. -3. **Implement**: - - Write logic using functional style (`Stream` API). - - Ensure extensive logging for debuggability (at `debug` or `trace` level for high-volume paths). -4. **Test**: - - ALWAYS write or update unit tests for changed logic. - - Validate logic with `VertxTest` if async/JSON is involved. - -## 7. Example Test Template - -```java -package org.prebid.server.component; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.prebid.server.VertxTest; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; - -@ExtendWith(MockitoExtension.class) -class ExampleTest { - - @Mock - private Dependency dependency; - @Mock(strictness = Mock.Strictness.LENIENT) // Common infra mock - private Metrics metrics; - - private Example target; - - @BeforeEach - void setUp() { - target = new Example(dependency, metrics); - } - - @Test - void shouldReturnExpectedValueWhenConditionMet() { - // given - given(dependency.getData()).willReturn("data"); - - // when - final var result = target.process(); - - // then - assertThat(result) - .extracting(Result::getValue) - .isEqualTo("data"); - verify(metrics).updateMetric(any()); - } -} -``` diff --git a/.gemini/settings.json b/.gemini/settings.json deleted file mode 100644 index 44d4be1fc97..00000000000 --- a/.gemini/settings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "hooks": { - "BeforeTool": [ - { - "matcher": "read_file|list_directory", - "hooks": [ - { - "type": "command", - "command": "python -c \"import sys,pathlib,json;e=pathlib.Path('graphify-out/graph.json').exists();d={'decision':'allow'};e and d.update({'additionalContext':'graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files.'});sys.stdout.write(json.dumps(d))\"" - } - ] - } - ] - } -} \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index f449b672b54..00000000000 --- a/GEMINI.md +++ /dev/null @@ -1,9 +0,0 @@ -## graphify - -This project has a graphify knowledge graph at graphify-out/. - -Rules: -- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure -- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files -- For cross-module "how does X relate to Y" questions, prefer `graphify query ""`, `graphify path "" ""`, or `graphify explain ""` over grep — these traverse the graph's EXTRACTED + INFERRED edges instead of scanning files -- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) diff --git a/extra/.vscode_old/launch.json b/extra/.vscode_old/launch.json deleted file mode 100644 index 3641a02708c..00000000000 --- a/extra/.vscode_old/launch.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "type": "java", - "name": "Debug Prebid Server (default config)", - "request": "launch", - "mainClass": "org.prebid.server.Application", - "projectName": "prebid-server-bundle", - "args": [ - "--spring.config.additional-location=sample/configs/prebid-config-with-optable.yaml" - ], - "vmArgs": "-Xmx2G -Dlogging.level.root=INFO" - }, - { - "type": "java", - "name": "Debug Prebid Server (custom YAML)", - "request": "launch", - "mainClass": "org.prebid.server.Application", - "projectName": "prebid-server", - "args": [ - "--spring.config.additional-location=config/my-prebid.yaml" - ], - "vmArgs": "-Xmx2G -Dlogging.level.root=DEBUG" - }, - { - "type": "java", - "name": "Debug Prebid Server (no args)", - "request": "launch", - "mainClass": "org.prebid.server.Application", - "projectName": "prebid-server", - "vmArgs": "-Xmx1G" - } - ] - } - \ No newline at end of file diff --git a/extra/.vscode_old/settings.json b/extra/.vscode_old/settings.json deleted file mode 100644 index 6426ceea05a..00000000000 --- a/extra/.vscode_old/settings.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "java.configuration.maven.pomFile": "extra/pom.xml", - "java.import.maven.enabled": true, - "java.autobuild.enabled": true, - "java.configuration.updateBuildConfiguration": "automatic", - "java.errors.incompleteClasspath.severity": "ignore", - "files.watcherExclude": { - "**/target/**": true - }, - "java.completion.importOrder": [ - "#", - "java" - ], - "editor.codeActionsOnSave": { - "source.organizeImports": "never" - } - } - \ No newline at end of file diff --git a/extra/.vscode_old/tasks.json b/extra/.vscode_old/tasks.json deleted file mode 100644 index aa0aab32b13..00000000000 --- a/extra/.vscode_old/tasks.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Maven: Fetch dependencies", - "type": "shell", - "command": "mvn -q dependency:go-offline", - "group": "build", - "problemMatcher": [] - }, - { - "label": "Maven: Build (full package)", - "type": "shell", - "command": "mvn -q clean package -DskipTests", - "group": "build", - "problemMatcher": "$maven" - }, - { - "label": "Maven: Test", - "type": "shell", - "command": "mvn -q test", - "group": "test", - "problemMatcher": "$maven" - }, - { - "label": "Format Java (Google Style)", - "type": "shell", - "command": "mvn -q spotless:apply", - "problemMatcher": [] - } - ] - } - \ No newline at end of file