diff --git a/src/main/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidder.java b/src/main/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidder.java index e047a077599..d83274e4852 100644 --- a/src/main/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidder.java +++ b/src/main/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidder.java @@ -29,6 +29,7 @@ import org.prebid.server.util.BidderUtil; import org.prebid.server.util.HttpUtil; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -44,6 +45,7 @@ public class TheTradeDeskBidder implements Bidder { private static final String SUPPLY_ID_MACRO = "{{SupplyId}}"; private static final Pattern SUPPLY_ID_PATTERN = Pattern.compile("([a-z]+)$"); + private static final String PRICE_MACRO = "${AUCTION_PRICE}"; private final String endpointUrl; private final String supplyId; @@ -180,32 +182,55 @@ private String resolveEndpoint(String sourceSupplyId) { public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { try { final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); - return Result.withValues(extractBids(bidResponse)); - } catch (DecodeException | PreBidException e) { + final List errors = new ArrayList<>(); + final List bids = extractBids(bidResponse, errors); + return Result.of(bids, errors); + } catch (DecodeException e) { return Result.withError(BidderError.badServerResponse(e.getMessage())); } } - private static List extractBids(BidResponse bidResponse) { + private static List extractBids(BidResponse bidResponse, List errors) { if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) { return Collections.emptyList(); } return bidResponse.getSeatbid().stream() .filter(Objects::nonNull) - .map(SeatBid::getBid).filter(Objects::nonNull) + .map(SeatBid::getBid) + .filter(Objects::nonNull) .flatMap(Collection::stream) .filter(Objects::nonNull) - .map(bid -> BidderBid.of(bid, getBidType(bid), bidResponse.getCur())) + .map(bid -> makeBid(bid, bidResponse.getCur(), errors)) + .filter(Objects::nonNull) .toList(); } - private static BidType getBidType(Bid bid) { + private static BidderBid makeBid(Bid bid, String currency, List errors) { + final BidType bidType = getBidType(bid, errors); + return bidType != null ? BidderBid.of(resolvePriceMacros(bid), bidType, currency) : null; + } + + private static BidType getBidType(Bid bid, List errors) { return switch (bid.getMtype()) { case 1 -> BidType.banner; case 2 -> BidType.video; case 4 -> BidType.xNative; - case null, default -> throw new PreBidException("unsupported mtype: %s".formatted(bid.getMtype())); + case null, default -> { + errors.add(BidderError.badServerResponse( + "could not define media type for impression: " + bid.getImpid())); + yield null; + } }; } + + private static Bid resolvePriceMacros(Bid bid) { + final BigDecimal price = bid.getPrice(); + final String priceAsString = price != null ? price.toPlainString() : "0"; + + return bid.toBuilder() + .nurl(StringUtils.replace(bid.getNurl(), PRICE_MACRO, priceAsString)) + .adm(StringUtils.replace(bid.getAdm(), PRICE_MACRO, priceAsString)) + .build(); + } } diff --git a/src/test/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidderTest.java b/src/test/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidderTest.java index a292ac6d385..8c167590e4f 100644 --- a/src/test/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidderTest.java +++ b/src/test/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidderTest.java @@ -24,6 +24,7 @@ import org.prebid.server.proto.openrtb.ext.ExtPrebid; import org.prebid.server.proto.openrtb.ext.request.thetradedesk.ExtImpTheTradeDesk; +import java.math.BigDecimal; import java.util.Arrays; import java.util.List; import java.util.Set; @@ -33,6 +34,7 @@ import static java.util.function.UnaryOperator.identity; 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.prebid.server.proto.openrtb.ext.response.BidType.banner; import static org.prebid.server.proto.openrtb.ext.response.BidType.video; import static org.prebid.server.proto.openrtb.ext.response.BidType.xNative; @@ -458,7 +460,7 @@ public void makeBidsShouldReturnVideoBid() throws JsonProcessingException { } @Test - public void makeBidsShouldThrowErrorWhenMediaTypeIsMissing() throws JsonProcessingException { + public void makeBidsShouldReturnErrorWhenMediaTypeIsMissing() throws JsonProcessingException { // given final BidderCall httpCall = givenHttpCall( givenBidResponse(bidBuilder -> bidBuilder.impid("123"))); @@ -469,7 +471,234 @@ public void makeBidsShouldThrowErrorWhenMediaTypeIsMissing() throws JsonProcessi // then assertThat(result.getValue()).isEmpty(); assertThat(result.getErrors()).hasSize(1) - .containsOnly(BidderError.badServerResponse("unsupported mtype: null")); + .containsOnly(BidderError.badServerResponse("could not define media type for impression: 123")); + } + + @Test + public void makeBidsShouldReturnValidBidsAndErrorsForMixedMediaTypes() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + mapper.writeValueAsString(BidResponse.builder() + .cur("USD") + .seatbid(singletonList(SeatBid.builder() + .bid(Arrays.asList( + Bid.builder().mtype(1).impid("valid1").build(), // valid banner + Bid.builder().mtype(3).impid("invalid1").build(), // invalid mtype + Bid.builder().mtype(2).impid("valid2").build(), // valid video + Bid.builder().mtype(null).impid("invalid2").build() // null mtype + )) + .build())) + .build())); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getValue()).hasSize(2) + .extracting(bidderBid -> bidderBid.getBid().getImpid()) + .containsExactly("valid1", "valid2"); + assertThat(result.getErrors()).hasSize(2) + .containsExactly( + BidderError.badServerResponse("could not define media type for impression: invalid1"), + BidderError.badServerResponse("could not define media type for impression: invalid2")); + } + + @Test + public void makeBidsShouldReplacePriceMacroInNurlAndAdmWithBidPrice() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder + .mtype(1) + .impid("123") + .price(BigDecimal.valueOf(1.23)) + .nurl("http://example.com/nurl?price=${AUCTION_PRICE}") + .adm("
Price: ${AUCTION_PRICE}
"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm, Bid::getPrice) + .containsOnly(tuple("http://example.com/nurl?price=1.23", "
Price: 1.23
", BigDecimal.valueOf(1.23))); + } + + @Test + public void makeBidsShouldReplacePriceMacroWithZeroWhenBidPriceIsNull() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder + .mtype(1) + .impid("123") + .price(null) + .nurl("http://example.com/nurl?price=${AUCTION_PRICE}") + .adm("
Price: ${AUCTION_PRICE}
"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm) + .containsOnly(tuple("http://example.com/nurl?price=0", "
Price: 0
")); + } + + @Test + public void makeBidsShouldReplacePriceMacroWithZeroWhenBidPriceIsZero() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder + .mtype(1) + .impid("123") + .price(BigDecimal.ZERO) + .nurl("http://example.com/nurl?price=${AUCTION_PRICE}") + .adm("
Price: ${AUCTION_PRICE}
"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm) + .containsOnly(tuple("http://example.com/nurl?price=0", "
Price: 0
")); + } + + @Test + public void makeBidsShouldReplacePriceMacroInNurlOnlyWhenAdmDoesNotContainMacro() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder + .mtype(1) + .impid("123") + .price(BigDecimal.valueOf(5.67)) + .nurl("http://example.com/nurl?price=${AUCTION_PRICE}") + .adm("
No macro here
"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm) + .containsOnly(tuple("http://example.com/nurl?price=5.67", "
No macro here
")); + } + + @Test + public void makeBidsShouldReplacePriceMacroInAdmOnlyWhenNurlDoesNotContainMacro() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder + .mtype(1) + .impid("123") + .price(BigDecimal.valueOf(8.90)) + .nurl("http://example.com/nurl") + .adm("
Price: ${AUCTION_PRICE}
"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm) + .containsOnly(tuple("http://example.com/nurl", "
Price: 8.9
")); + } + + @Test + public void makeBidsShouldNotReplacePriceMacroWhenNurlAndAdmDoNotContainMacro() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder + .mtype(1) + .impid("123") + .price(BigDecimal.valueOf(12.34)) + .nurl("http://example.com/nurl") + .adm("
No macro
"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm) + .containsOnly(tuple("http://example.com/nurl", "
No macro
")); + } + + @Test + public void makeBidsShouldHandleNullNurlAndAdm() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder + .mtype(1) + .impid("123") + .price(BigDecimal.valueOf(15.00)) + .nurl(null) + .adm(null))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm) + .containsOnly(tuple(null, null)); + } + + @Test + public void makeBidsShouldReplaceMultiplePriceMacrosInSameField() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder + .mtype(1) + .impid("123") + .price(BigDecimal.valueOf(9.99)) + .nurl("http://example.com/nurl?price=${AUCTION_PRICE}&backup_price=${AUCTION_PRICE}") + .adm("
Price: ${AUCTION_PRICE}, Fallback: ${AUCTION_PRICE}
"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm) + .containsOnly(tuple("http://example.com/nurl?price=9.99&backup_price=9.99", "
Price: 9.99, Fallback: 9.99
")); + } + + @Test + public void makeBidsShouldHandleLargeDecimalPrices() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder + .mtype(1) + .impid("123") + .price(new BigDecimal("123456789.123456789")) + .nurl("http://example.com/nurl?price=${AUCTION_PRICE}") + .adm("
Price: ${AUCTION_PRICE}
"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm) + .containsOnly(tuple("http://example.com/nurl?price=123456789.123456789", "
Price: 123456789.123456789
")); } private String givenBidResponse(UnaryOperator bidCustomizer) throws JsonProcessingException { @@ -508,5 +737,4 @@ private static ObjectNode impExt(String publisherId) { private static ObjectNode impExt(String publisherId, String supplySourceId) { return mapper.valueToTree(ExtPrebid.of(null, ExtImpTheTradeDesk.of(publisherId, supplySourceId))); } - }