diff --git a/docs/developers/openrtb-request-model.md b/docs/developers/openrtb-request-model.md new file mode 100644 index 00000000000..9cd061ad4e4 --- /dev/null +++ b/docs/developers/openrtb-request-model.md @@ -0,0 +1,30 @@ +# OpenRTB request model compatibility + +The request classes in `com.iab.openrtb.request` represent OpenRTB 2.6 and retain selected legacy fields for OpenRTB 2.5 interoperability. Optional fields remain unset when absent from the input; the model does not insert specification defaults. + +## OpenRTB 2.6 fields + +`Video.podid` and `Audio.podid` are strings, as defined in the IAB [Video](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/main/2.6.md#objectvideo) and [Audio](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/main/2.6.md#objectaudio) tables. This preserves identifiers such as `pod-001` and numeric-looking strings with leading zeros. Numeric JSON input is accepted by the existing Jackson coercion and serialized as a string. Java callers must supply a `String` to the builders and recompile against the updated model. + +The separate `/openrtb2/video` endpoint uses its own numeric pod IDs, which are not these OpenRTB impression fields. + +This matches the Go models in `prebid/openrtb` used by Prebid Server: [Video](https://github.com/prebid/openrtb/blob/v20.3.0/openrtb2/video.go) and [Audio](https://github.com/prebid/openrtb/blob/v20.3.0/openrtb2/audio.go). The corresponding Video type correction was merged in [prebid/openrtb#2](https://github.com/prebid/openrtb/pull/2). + +The [Content](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/main/2.6.md#objectcontent) model includes `gtax`, `genres`, `realtime`, and `firstbroadcast`. The [Data](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/main/2.6.md#objectdata) model includes `cids` for extended content identifiers. These fields survive JSON decoding and encoding in site, app, and DOOH content. + +In the current 2.6 specification, `content.livestream` describes scheduled versus on-demand delivery, while `content.realtime` describes whether the event is happening in real time. Earlier definitions of `livestream` described live content. The model preserves the supplied numeric value; adapters should interpret it according to the OpenRTB version they use. + +## Legacy OpenRTB 2.5 fields + +The following fields were already deprecated by OpenRTB 2.5 and removed in 2.6. They are retained so that a 2.5 request does not lose them when decoded and encoded through the shared model. + +| Field | Purpose in 2.5 | Replacement in 2.6 | +| --- | --- | --- | +| `banner.wmax`, `banner.hmax` | Maximum banner dimensions | `banner.format` | +| `banner.wmin`, `banner.hmin` | Minimum banner dimensions | `banner.format` | +| `video.protocol` | Single supported video protocol | `video.protocols` | +| `content.videoquality` | Video production quality | `content.prodq` | + +See the [IAB specification change log](https://github.com/InteractiveAdvertisingBureau/openrtb2.x/blob/main/2.6.md#appendixb). Retaining these fields does not make them OpenRTB 2.6 attributes. The shared model preserves supplied values independently of their replacements; callers producing strictly 2.6 requests should use the replacements and omit the legacy fields. + +`user.language` is not a standard OpenRTB request attribute and is not included in the model. diff --git a/src/main/java/com/iab/openrtb/request/Audio.java b/src/main/java/com/iab/openrtb/request/Audio.java index 58901a625b3..3f1fccce76b 100644 --- a/src/main/java/com/iab/openrtb/request/Audio.java +++ b/src/main/java/com/iab/openrtb/request/Audio.java @@ -81,7 +81,7 @@ public class Audio { * within a bid request share the same podid, this indicates that * those impression opportunities belong to the same audio ad pod. */ - Integer podid; + String podid; /** * The sequence (position) of the audio ad pod within a diff --git a/src/main/java/com/iab/openrtb/request/Banner.java b/src/main/java/com/iab/openrtb/request/Banner.java index b24584b110b..3f34ab3ea21 100644 --- a/src/main/java/com/iab/openrtb/request/Banner.java +++ b/src/main/java/com/iab/openrtb/request/Banner.java @@ -44,6 +44,30 @@ public class Banner { */ Integer h; + /** + * Maximum width in device independent pixels (DIPS), retained for OpenRTB 2.5 round trips. + * Deprecated as of OpenRTB 2.5 and removed in 2.6 in favor of format. + */ + Integer wmax; + + /** + * Maximum height in device independent pixels (DIPS), retained for OpenRTB 2.5 round trips. + * Deprecated as of OpenRTB 2.5 and removed in 2.6 in favor of format. + */ + Integer hmax; + + /** + * Minimum width in device independent pixels (DIPS), retained for OpenRTB 2.5 round trips. + * Deprecated as of OpenRTB 2.5 and removed in 2.6 in favor of format. + */ + Integer wmin; + + /** + * Minimum height in device independent pixels (DIPS), retained for OpenRTB 2.5 round trips. + * Deprecated as of OpenRTB 2.5 and removed in 2.6 in favor of format. + */ + Integer hmin; + /** * Blocked banner ad types. * Values: diff --git a/src/main/java/com/iab/openrtb/request/Content.java b/src/main/java/com/iab/openrtb/request/Content.java index 486630d8eed..0419512c7a8 100644 --- a/src/main/java/com/iab/openrtb/request/Content.java +++ b/src/main/java/com/iab/openrtb/request/Content.java @@ -64,6 +64,16 @@ public class Content { */ String genre; + /** + * Taxonomy used by genres. If omitted, Content Category Taxonomy 3.1 (9) is assumed. + */ + Integer gtax; + + /** + * Genre IDs from the taxonomy specified by gtax. + */ + List genres; + /** * Album to which the content belongs; typically for audio. */ @@ -103,6 +113,12 @@ public class Content { */ Integer prodq; + /** + * Video production quality retained for OpenRTB 2.5 round trips. + * Deprecated as of OpenRTB 2.5 and removed in 2.6 in favor of prodq. + */ + Integer videoquality; + /** * Type of content (game, video, text, etc.). Refer to * List: Content Contexts in AdCOM 1.0. @@ -136,7 +152,7 @@ public class Content { List kwarray; /** - * 0 = not live, 1 = content is live (e.g., stream, live blog). + * Indicates whether the broadcast is scheduled: 0 = on-demand, 1 = scheduled (linear viewing). */ Integer livestream; @@ -182,6 +198,16 @@ public class Content { */ Channel channel; + /** + * Indicates whether the event is happening in real time: 0 = replay, 1 = real time. + */ + Integer realtime; + + /** + * Indicates whether this is the first broadcast of the content: 0 = no, 1 = yes. + */ + Integer firstbroadcast; + /** * Placeholder for exchange-specific extensions to OpenRTB. */ diff --git a/src/main/java/com/iab/openrtb/request/Data.java b/src/main/java/com/iab/openrtb/request/Data.java index fd48b7100b8..49a1ee7b7a7 100644 --- a/src/main/java/com/iab/openrtb/request/Data.java +++ b/src/main/java/com/iab/openrtb/request/Data.java @@ -28,6 +28,11 @@ public class Data { */ String name; + /** + * Extended video or audio content IDs from the source specified by name. + */ + List cids; + /** * Array of {@link Segment} (Section 3.2.22) objects that contain the actual data * values. diff --git a/src/main/java/com/iab/openrtb/request/Video.java b/src/main/java/com/iab/openrtb/request/Video.java index 369d576a3ac..b476ee4c8fb 100644 --- a/src/main/java/com/iab/openrtb/request/Video.java +++ b/src/main/java/com/iab/openrtb/request/Video.java @@ -74,6 +74,12 @@ public class Video { */ List protocols; + /** + * Video protocol retained for OpenRTB 2.5 round trips. + * Deprecated as of OpenRTB 2.5 and removed in 2.6 in favor of protocols. + */ + Integer protocol; + /** * Width of the video player in device independent pixels (DIPS). */ @@ -90,7 +96,7 @@ public class Video { * within a bid request share the same podid, this indicates that * those impression opportunities belong to the same video ad pod. */ - Integer podid; + String podid; /** * The sequence (position) of the video ad pod within a diff --git a/src/test/groovy/org/prebid/server/functional/model/request/auction/Audio.groovy b/src/test/groovy/org/prebid/server/functional/model/request/auction/Audio.groovy index 57d9bbd40ee..e262da95e35 100644 --- a/src/test/groovy/org/prebid/server/functional/model/request/auction/Audio.groovy +++ b/src/test/groovy/org/prebid/server/functional/model/request/auction/Audio.groovy @@ -14,7 +14,7 @@ class Audio { List protocols Integer startdelay List rqddurs - Integer podid + String podid Integer podseq Integer sequence Integer slotinpod diff --git a/src/test/groovy/org/prebid/server/functional/model/request/auction/Video.groovy b/src/test/groovy/org/prebid/server/functional/model/request/auction/Video.groovy index 53e86c5ed28..e71191dc113 100644 --- a/src/test/groovy/org/prebid/server/functional/model/request/auction/Video.groovy +++ b/src/test/groovy/org/prebid/server/functional/model/request/auction/Video.groovy @@ -19,7 +19,7 @@ class Video { Integer width @JsonProperty("h") Integer height - Integer podid + String podid Integer podseq List rqddurs VideoPlacementSubtypes placement diff --git a/src/test/groovy/org/prebid/server/functional/tests/OrtbConverterSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/OrtbConverterSpec.groovy index 21bb80df135..af7aa064ceb 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/OrtbConverterSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/OrtbConverterSpec.groovy @@ -556,7 +556,7 @@ class OrtbConverterSpec extends BaseSpec { rqddurs = [PBSUtils.randomNumber] maxseq = PBSUtils.randomNumber poddur = PBSUtils.randomNumber - podid = PBSUtils.randomNumber + podid = PBSUtils.randomString podseq = PBSUtils.randomNumber mincpmpersec = PBSUtils.randomDecimal slotinpod = PBSUtils.randomNumber @@ -580,7 +580,7 @@ class OrtbConverterSpec extends BaseSpec { rqddurs = [PBSUtils.randomNumber] maxseq = PBSUtils.randomNumber poddur = PBSUtils.randomNumber - podid = PBSUtils.randomNumber + podid = PBSUtils.randomString podseq = PBSUtils.randomNumber mincpmpersec = PBSUtils.randomDecimal slotinpod = PBSUtils.randomNumber @@ -604,7 +604,7 @@ class OrtbConverterSpec extends BaseSpec { rqddurs = [PBSUtils.randomNumber] maxseq = PBSUtils.randomNumber poddur = PBSUtils.randomNumber - podid = PBSUtils.randomNumber + podid = PBSUtils.randomString podseq = PBSUtils.randomNumber mincpmpersec = PBSUtils.randomDecimal slotinpod = PBSUtils.randomNumber @@ -626,7 +626,7 @@ class OrtbConverterSpec extends BaseSpec { rqddurs = [PBSUtils.randomNumber] maxseq = PBSUtils.randomNumber poddur = PBSUtils.randomNumber - podid = PBSUtils.randomNumber + podid = PBSUtils.randomString podseq = PBSUtils.randomNumber mincpmpersec = BigDecimal.valueOf(1) slotinpod = PBSUtils.randomNumber diff --git a/src/test/java/org/prebid/server/json/OpenRtbRequestModelTest.java b/src/test/java/org/prebid/server/json/OpenRtbRequestModelTest.java new file mode 100644 index 00000000000..4c2984c43ee --- /dev/null +++ b/src/test/java/org/prebid/server/json/OpenRtbRequestModelTest.java @@ -0,0 +1,219 @@ +package org.prebid.server.json; + +import com.fasterxml.jackson.databind.JsonNode; +import com.iab.openrtb.request.Audio; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Content; +import com.iab.openrtb.request.Video; +import org.junit.jupiter.api.Test; +import org.prebid.server.VertxTest; + +import static org.assertj.core.api.Assertions.assertThat; + +public class OpenRtbRequestModelTest extends VertxTest { + + private final JacksonMapper target = jacksonMapper; + + @Test + public void decodeValueShouldPreserveOpenRtb25BannerSizeBounds() { + // given + final String json = """ + {"imp":[{"banner":{"wmax":970,"hmax":250,"wmin":300,"hmin":50}}]} + """; + + // when + final BidRequest result = target.decodeValue(json, BidRequest.class); + + // then + assertThat(mapper.valueToTree(result)).isEqualTo(target.decodeValue(json, JsonNode.class)); + } + + @Test + public void decodeValueShouldPreserveOpenRtb25VideoProtocol() { + // given + final String json = """ + {"imp":[{"video":{"protocol":3,"protocols":[2,3]}}]} + """; + + // when + final BidRequest result = target.decodeValue(json, BidRequest.class); + + // then + assertThat(mapper.valueToTree(result)).isEqualTo(target.decodeValue(json, JsonNode.class)); + } + + @Test + public void decodeValueShouldPreserveOpenRtb25ContentVideoQuality() { + // given + final String json = "{\"site\":{\"content\":{\"videoquality\":0,\"prodq\":1}}}"; + + // when + final BidRequest result = target.decodeValue(json, BidRequest.class); + + // then + assertThat(mapper.valueToTree(result)).isEqualTo(target.decodeValue(json, JsonNode.class)); + } + + @Test + public void decodeValueShouldPreserveVideoPodId() { + // given + final String json = """ + {"imp":[{"video":{"podid":"pod-001"}}]} + """; + + // when + final BidRequest result = target.decodeValue(json, BidRequest.class); + + // then + assertThat(result.getImp().getFirst().getVideo().getPodid()).isEqualTo("pod-001"); + assertThat(mapper.valueToTree(result)).isEqualTo(target.decodeValue(json, JsonNode.class)); + } + + @Test + public void decodeValueShouldPreserveAudioPodId() { + // given + final String json = """ + {"imp":[{"audio":{"podid":"pod-001"}}]} + """; + + // when + final BidRequest result = target.decodeValue(json, BidRequest.class); + + // then + assertThat(result.getImp().getFirst().getAudio().getPodid()).isEqualTo("pod-001"); + assertThat(mapper.valueToTree(result)).isEqualTo(target.decodeValue(json, JsonNode.class)); + } + + @Test + public void decodeValueShouldAcceptNumericVideoPodId() { + // given + final String json = "{\"podid\":123}"; + + // when + final Video result = target.decodeValue(json, Video.class); + + // then + assertThat(result.getPodid()).isEqualTo("123"); + assertThat(target.encodeToString(result)).isEqualTo("{\"podid\":\"123\"}"); + } + + @Test + public void decodeValueShouldPreserveLeadingZerosInVideoPodId() { + // given + final String json = "{\"podid\":\"00123\"}"; + + // when + final Video result = target.decodeValue(json, Video.class); + + // then + assertThat(result.getPodid()).isEqualTo("00123"); + assertThat(target.encodeToString(result)).isEqualTo(json); + } + + @Test + public void decodeValueShouldPreserveLeadingZerosInAudioPodId() { + // given + final String json = "{\"podid\":\"00123\"}"; + + // when + final Audio result = target.decodeValue(json, Audio.class); + + // then + assertThat(result.getPodid()).isEqualTo("00123"); + assertThat(target.encodeToString(result)).isEqualTo(json); + } + + @Test + public void decodeValueShouldAcceptNumericAudioPodId() { + // given + final String json = "{\"podid\":123}"; + + // when + final Audio result = target.decodeValue(json, Audio.class); + + // then + assertThat(result.getPodid()).isEqualTo("123"); + assertThat(target.encodeToString(result)).isEqualTo("{\"podid\":\"123\"}"); + } + + @Test + public void decodeValueShouldPreserveSiteContentFields() { + // given + final String json = """ + {"site":{"content":{ + "gtax":9,"genres":["1","2"],"realtime":1,"firstbroadcast":0, + "data":[{"name":"content-provider","cids":["content-001","content-002"]}] + }}} + """; + + // when + final BidRequest result = target.decodeValue(json, BidRequest.class); + + // then + assertThat(mapper.valueToTree(result)) + .isEqualTo(target.decodeValue(json, JsonNode.class)); + assertThat(result.getSite().getContent().isEmpty()).isFalse(); + } + + @Test + public void decodeValueShouldPreserveAppContentFields() { + // given + final String json = """ + {"app":{"content":{ + "gtax":9,"genres":["1","2"],"realtime":0,"firstbroadcast":1, + "data":[{"name":"content-provider","cids":["content-001","content-002"]}] + }}} + """; + + // when + final BidRequest result = target.decodeValue(json, BidRequest.class); + + // then + assertThat(mapper.valueToTree(result)).isEqualTo(target.decodeValue(json, JsonNode.class)); + } + + @Test + public void decodeValueShouldPreserveDoohContentFields() { + // given + final String json = """ + {"dooh":{"content":{ + "gtax":9,"genres":["1","2"],"realtime":1,"firstbroadcast":1, + "data":[{"name":"content-provider","cids":["content-001","content-002"]}] + }}} + """; + + // when + final BidRequest result = target.decodeValue(json, BidRequest.class); + + // then + assertThat(mapper.valueToTree(result)).isEqualTo(target.decodeValue(json, JsonNode.class)); + } + + @Test + public void decodeValueShouldLeaveMissingContentFieldsUnset() { + // when + final Content result = target.decodeValue("{}", Content.class); + + // then + assertThat(result.getGtax()).isNull(); + assertThat(result.getGenres()).isNull(); + assertThat(result.getRealtime()).isNull(); + assertThat(result.getFirstbroadcast()).isNull(); + assertThat(result.isEmpty()).isTrue(); + assertThat(target.encodeToString(result)).isEqualTo("{}"); + } + + @Test + public void decodeValueShouldPreserveEmptyContentArrays() { + // given + final String json = "{\"genres\":[],\"data\":[{\"cids\":[]}]}"; + + // when + final Content result = target.decodeValue(json, Content.class); + + // then + assertThat(result.getGenres()).isEmpty(); + assertThat(result.getData().getFirst().getCids()).isEmpty(); + assertThat(mapper.valueToTree(result)).isEqualTo(target.decodeValue(json, JsonNode.class)); + } +}