From cc0cca60f71e4e135b8eec92a257a11cf5768621 Mon Sep 17 00:00:00 2001 From: William Harris Date: Tue, 20 Feb 2024 18:20:15 +0100 Subject: [PATCH] Yieldlab: Implementing DSA support --- .../bidder/yieldlab/YieldlabBidder.java | 69 ++++++- .../YieldlabDigitalServicesActResponse.java | 23 +++ .../yieldlab/model/YieldlabResponse.java | 2 + .../bidder/yieldlab/YieldlabBidderTest.java | 174 +++++++++++++++++- 4 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/prebid/server/bidder/yieldlab/model/YieldlabDigitalServicesActResponse.java diff --git a/src/main/java/org/prebid/server/bidder/yieldlab/YieldlabBidder.java b/src/main/java/org/prebid/server/bidder/yieldlab/YieldlabBidder.java index bd957cd0ee0..9bf18da1124 100644 --- a/src/main/java/org/prebid/server/bidder/yieldlab/YieldlabBidder.java +++ b/src/main/java/org/prebid/server/bidder/yieldlab/YieldlabBidder.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.iab.openrtb.request.App; import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.request.Device; @@ -23,11 +25,13 @@ import org.prebid.server.bidder.model.BidderError; import org.prebid.server.bidder.model.HttpRequest; import org.prebid.server.bidder.model.Result; +import org.prebid.server.bidder.yieldlab.model.YieldlabDigitalServicesActResponse; import org.prebid.server.bidder.yieldlab.model.YieldlabResponse; import org.prebid.server.exception.PreBidException; import org.prebid.server.json.DecodeException; import org.prebid.server.json.JacksonMapper; import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtRegsDsaTransparency; import org.prebid.server.proto.openrtb.ext.request.ExtRequest; import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; import org.prebid.server.proto.openrtb.ext.request.ExtUser; @@ -41,6 +45,7 @@ import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -189,6 +194,8 @@ private String makeUrl(ExtImpYieldlab extImpYieldlab, BidRequest request) { uriBuilder.addParameter("consent", consent); } + extractDsaRequestParams(request).forEach(uriBuilder::addParameter); + return uriBuilder.toString(); } @@ -231,6 +238,55 @@ private static String getConsentParameter(User user) { return ObjectUtils.defaultIfNull(consent, ""); } + private static Map extractDsaRequestParams(BidRequest request) { + if (!hasDsa(request)) { + return Map.of(); + } + + final var dsa = request.getRegs().getExt().getDsa(); + final var dsaRequestParams = new HashMap(); + + if (dsa.getDsaRequired() != null) { + dsaRequestParams.put("dsarequired", dsa.getDsaRequired().toString()); + } + + if (dsa.getPubRender() != null) { + dsaRequestParams.put("dsapubrender", dsa.getPubRender().toString()); + } + + if (dsa.getDataToPub() != null) { + dsaRequestParams.put("dsadatatopub", dsa.getDataToPub().toString()); + } + + if (dsa.getTransparency() != null && !dsa.getTransparency().isEmpty()) { + dsaRequestParams.put("dsatransparency", encodeTransparenciesAsString(dsa.getTransparency())); + } + + return dsaRequestParams.entrySet().stream() + .filter(entry -> !entry.getValue().isBlank()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private static boolean hasDsa(BidRequest request) { + return request.getRegs() != null + && request.getRegs().getExt() != null + && request.getRegs().getExt().getDsa() != null; + } + + private static String encodeTransparenciesAsString(List transparency) { + return transparency.stream() + .map(YieldlabBidder::encodeTransparency) + .collect(Collectors.joining("~~")); + } + + private static String encodeTransparency(ExtRegsDsaTransparency transparency) { + return "%s~%s".formatted(transparency.getDomain(), encodeTransparencyParams(transparency.getDsaParams())); + } + + private static String encodeTransparencyParams(List dsaParams) { + return dsaParams.stream().map(Objects::toString).collect(Collectors.joining("_")); + } + private static MultiMap resolveHeaders(Site site, Device device, User user) { final MultiMap headers = MultiMap.caseInsensitiveMultiMap() .add(HttpUtil.ACCEPT_HEADER, HttpHeaderValues.APPLICATION_JSON); @@ -339,7 +395,8 @@ private Bid.BidBuilder addBidParams(YieldlabResponse yieldlabResponse, BidReques .dealid(resolveNumberParameter(yieldlabResponse.getPid())) .crid(makeCreativeId(bidRequest, yieldlabResponse, matchedExtImp)) .w(resolveSizeParameter(yieldlabResponse.getAdSize(), true)) - .h(resolveSizeParameter(yieldlabResponse.getAdSize(), false)); + .h(resolveSizeParameter(yieldlabResponse.getAdSize(), false)) + .ext(resolveExtParameter(yieldlabResponse.getDsa())); return updatedBid; } @@ -408,4 +465,14 @@ private String makeNurl(BidRequest bidRequest, ExtImpYieldlab extImpYieldlab, Yi yieldlabResponse.getAdSize(), uriBuilder.toString().replace("?", "")); } + + private ObjectNode resolveExtParameter(YieldlabDigitalServicesActResponse dsa) { + if (dsa == null) { + return null; + } + final var ext = mapper.mapper().createObjectNode(); + final var dsaNode = mapper.mapper().convertValue(dsa, JsonNode.class); + ext.set("dsa", dsaNode); + return ext; + } } diff --git a/src/main/java/org/prebid/server/bidder/yieldlab/model/YieldlabDigitalServicesActResponse.java b/src/main/java/org/prebid/server/bidder/yieldlab/model/YieldlabDigitalServicesActResponse.java new file mode 100644 index 00000000000..e9ac21a9031 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/yieldlab/model/YieldlabDigitalServicesActResponse.java @@ -0,0 +1,23 @@ +package org.prebid.server.bidder.yieldlab.model; + +import lombok.AllArgsConstructor; +import lombok.Value; + +import java.util.List; + +@AllArgsConstructor(staticName = "of") +@Value +public class YieldlabDigitalServicesActResponse { + + String behalf; + String paid; + Integer adrender; + List transparency; + + @AllArgsConstructor(staticName = "of") + @Value + public static class Transparency { + String domain; + List dsaparams; + } +} diff --git a/src/main/java/org/prebid/server/bidder/yieldlab/model/YieldlabResponse.java b/src/main/java/org/prebid/server/bidder/yieldlab/model/YieldlabResponse.java index 4cd1dbf7294..9a2b54652ad 100644 --- a/src/main/java/org/prebid/server/bidder/yieldlab/model/YieldlabResponse.java +++ b/src/main/java/org/prebid/server/bidder/yieldlab/model/YieldlabResponse.java @@ -22,4 +22,6 @@ public class YieldlabResponse { Integer did; String pvid; + + YieldlabDigitalServicesActResponse dsa; } diff --git a/src/test/java/org/prebid/server/bidder/yieldlab/YieldlabBidderTest.java b/src/test/java/org/prebid/server/bidder/yieldlab/YieldlabBidderTest.java index 969f4b3605d..be028ecc651 100644 --- a/src/test/java/org/prebid/server/bidder/yieldlab/YieldlabBidderTest.java +++ b/src/test/java/org/prebid/server/bidder/yieldlab/YieldlabBidderTest.java @@ -1,6 +1,7 @@ package org.prebid.server.bidder.yieldlab; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.iab.openrtb.request.Banner; import com.iab.openrtb.request.BidRequest; import com.iab.openrtb.request.Device; @@ -12,6 +13,9 @@ import com.iab.openrtb.response.Bid; import org.junit.Before; import org.junit.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.prebid.server.VertxTest; import org.prebid.server.bidder.model.BidderBid; import org.prebid.server.bidder.model.BidderCall; @@ -19,13 +23,17 @@ import org.prebid.server.bidder.model.HttpRequest; import org.prebid.server.bidder.model.HttpResponse; import org.prebid.server.bidder.model.Result; +import org.prebid.server.bidder.yieldlab.model.YieldlabDigitalServicesActResponse; import org.prebid.server.bidder.yieldlab.model.YieldlabResponse; import org.prebid.server.proto.openrtb.ext.ExtPrebid; import org.prebid.server.proto.openrtb.ext.request.ExtRegs; +import org.prebid.server.proto.openrtb.ext.request.ExtRegsDsa; +import org.prebid.server.proto.openrtb.ext.request.ExtRegsDsaTransparency; import org.prebid.server.proto.openrtb.ext.request.ExtUser; import org.prebid.server.proto.openrtb.ext.request.yieldlab.ExtImpYieldlab; import org.prebid.server.proto.openrtb.ext.response.BidType; +import java.lang.reflect.InvocationTargetException; import java.math.BigDecimal; import java.time.Clock; import java.time.Instant; @@ -35,12 +43,14 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Stream; import static java.util.Collections.singletonList; import static java.util.Collections.singletonMap; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.tuple; +import static org.junit.jupiter.params.provider.Arguments.arguments; public class YieldlabBidderTest extends VertxTest { @@ -226,7 +236,7 @@ public void makeBidsShouldReturnCorrectBidderBid() throws JsonProcessingExceptio .build(); final YieldlabResponse yieldlabResponse = YieldlabResponse.of(1, 201d, "yieldlab", - "728x90", 1234, 5678, "40cb3251-1e1e-4cfd-8edc-7d32dc1a21e5"); + "728x90", 1234, 5678, "40cb3251-1e1e-4cfd-8edc-7d32dc1a21e5", null); final BidderCall httpCall = givenHttpCall(mapper.writeValueAsString(yieldlabResponse)); @@ -274,7 +284,7 @@ public void makeBidsShouldReturnCorrectAdm() throws JsonProcessingException { .build(); final YieldlabResponse yieldlabResponse = YieldlabResponse.of(12345, 201d, "yieldlab", - "728x90", 1234, 5678, "40cb3251-1e1e-4cfd-8edc-7d32dc1a21e5"); + "728x90", 1234, 5678, "40cb3251-1e1e-4cfd-8edc-7d32dc1a21e5", null); final BidderCall httpCall = givenHttpCall(mapper.writeValueAsString(yieldlabResponse)); @@ -301,6 +311,166 @@ public void makeBidsShouldReturnCorrectAdm() throws JsonProcessingException { .containsExactly(expectedAdm); } + public static Stream dsaRequestCases() { + return Stream.of( + arguments( + "DSA is forwarded", + ExtRegsDsa.of( + 1, 2, 3, List.of(ExtRegsDsaTransparency.of("testDomain", List.of(1, 2, 3))) + ), + Map.of( + "dsarequired", "1", + "dsapubrender", "2", + "dsadatatopub", "3", + "dsatransparency", "testDomain~1_2_3" + ) + ), + arguments( + "DSA with multiple transparencies is encoded correctly", + ExtRegsDsa.of( + 1, 2, 3, List.of( + ExtRegsDsaTransparency.of("testDomain", List.of(1, 2, 3)), + ExtRegsDsaTransparency.of("testDomain2", List.of(4, 5, 6)) + ) + ), + Map.of( + "dsarequired", "1", + "dsapubrender", "2", + "dsadatatopub", "3", + "dsatransparency", "testDomain~1_2_3~~testDomain2~4_5_6" + ) + + ), + arguments( + "Missing values are not forwarded", + ExtRegsDsa.of( + 2, null, 3, null + ), + Map.of( + "dsarequired", "2", + "dsadatatopub", "3" + ) + ) + ); + } + + @ParameterizedTest(name = "{index} - {0}") + @MethodSource("dsaRequestCases") + public void dsaIsForwarded(String testName, ExtRegsDsa dsa, Map expectations) + throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + //given + final var regs = Regs.builder() + .ext(ExtRegs.of(null, null, null, dsa)) + .build(); + + final var bidRequest = BidRequest.builder() + .regs(regs) + .build(); + + final var getDsaRequestParams = + YieldlabBidder.class.getDeclaredMethod("extractDsaRequestParams", BidRequest.class); + getDsaRequestParams.setAccessible(true); + + //when + final var dsaRequestParams = (HashMap) getDsaRequestParams.invoke(null, bidRequest); + + //then + assertThat(dsaRequestParams).containsExactlyInAnyOrderEntriesOf(expectations); + } + + @Test + public void dsaParamsAreSentInRequest() { + //given + final var transparencies = List.of( + ExtRegsDsaTransparency.of("testDomain", List.of(1, 2, 3)), + ExtRegsDsaTransparency.of("testDomain2", List.of(4, 5, 6)) + ); + final var dsa = ExtRegsDsa.of( + 1, 2, 3, transparencies); + final var regs = Regs.builder() + .ext(ExtRegs.of(null, null, null, dsa)) + .build(); + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(Imp.builder() + .ext(mapper.valueToTree(ExtPrebid.of(null, + ExtImpYieldlab.builder() + .adslotId("123") + .build()))) + .build())) + .regs(regs) + .build(); + + //when + final var result = target.makeHttpRequests(bidRequest); + + //then + assertThat(result.getValue().get(0).getUri()) + .contains( + "&dsarequired=1", + "&dsapubrender=2", + "&dsadatatopub=3", + "&dsatransparency=testDomain%7E1_2_3%7E%7EtestDomain2%7E4_5_6" + ); + } + + @Test + public void dsaParamsIsAddedToResponse() throws JsonProcessingException { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(Imp.builder() + .id("test-imp-id") + .banner(Banner.builder().w(1).h(1).build()) + .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpYieldlab.builder() + .adslotId("1") + .supplyId("2") + .adSize("adSize") + .targeting(singletonMap("key", "value")) + .extId("extId") + .build()))) + .build())) + .build(); + + final var dsaResponse = YieldlabDigitalServicesActResponse.of( + "yieldlab", + "yieldlab", + 2, + List.of( + YieldlabDigitalServicesActResponse.Transparency.of( + "yieldlab.de", + List.of(1, 2, 3) + ) + ) + ); + + final var yieldlabResponse = YieldlabResponse.of(1, 201d, "yieldlab", + "728x90", 1234, 5678, "40cb3251-1e1e-4cfd-8edc-7d32dc1a21e5", dsaResponse + ); + + final var httpCall = givenHttpCall(mapper.writeValueAsString(yieldlabResponse)); + + // when + final var result = target.makeBids(httpCall, bidRequest); + + // then + final var expectedTransparency = mapper.createObjectNode(); + expectedTransparency.put("domain", "yieldlab.de"); + expectedTransparency.set("dsaparams", mapper.convertValue(List.of(1, 2, 3), JsonNode.class)); + + final var transparencies = mapper.createArrayNode(); + transparencies.add(expectedTransparency); + + final var expectedDsa = mapper.createObjectNode(); + expectedDsa.put("paid", "yieldlab"); + expectedDsa.put("behalf", "yieldlab"); + expectedDsa.put("adrender", 2); + expectedDsa.set("transparency", transparencies); + + final var actualDsa = result.getValue().get(0).getBid().getExt().get("dsa"); + + assertThat(result.getErrors()).isEmpty(); + assertThat(actualDsa).isEqualTo(expectedDsa); + } + private static BidderCall givenHttpCall(String body) { return BidderCall.succeededHttp( HttpRequest.builder().build(),