diff --git a/src/main/java/org/prebid/server/bidder/yahooads/YahooAdsBidder.java b/src/main/java/org/prebid/server/bidder/yahooads/YahooAdsBidder.java index 800f21f2c9c..e6ebc55a1d1 100644 --- a/src/main/java/org/prebid/server/bidder/yahooads/YahooAdsBidder.java +++ b/src/main/java/org/prebid/server/bidder/yahooads/YahooAdsBidder.java @@ -17,6 +17,7 @@ import io.vertx.core.MultiMap; import io.vertx.core.http.HttpMethod; import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import org.prebid.server.bidder.Bidder; import org.prebid.server.bidder.model.BidderBid; @@ -38,6 +39,7 @@ import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.regex.Pattern; public class YahooAdsBidder implements Bidder { @@ -49,6 +51,9 @@ public class YahooAdsBidder implements Bidder { private static final String GPP_PROPERTY = "gpp"; private static final String GPP_SID_PROPERTY = "gpp_sid"; + // VAST_MARKUP is a heuristic to find video bids without mtype on banner+video imps. + private static final Pattern VAST_MARKUP = Pattern.compile("]"); + private final String endpointUrl; private final JacksonMapper mapper; @@ -248,50 +253,78 @@ private static MultiMap makeHeaders(Device device) { public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { try { final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); - return Result.of(extractBids(bidResponse, httpCall.getRequest().getPayload()), Collections.emptyList()); + final List errors = new ArrayList<>(); + final List bids = extractBids(bidResponse, httpCall.getRequest().getPayload(), errors); + return Result.of(bids, errors); } catch (DecodeException | PreBidException e) { return Result.withError(BidderError.badServerResponse(e.getMessage())); } } - private static List extractBids(BidResponse bidResponse, BidRequest bidRequest) { + private static List extractBids(BidResponse bidResponse, + BidRequest bidRequest, + List errors) { final List seatBids = bidResponse != null ? bidResponse.getSeatbid() : null; if (seatBids == null) { return Collections.emptyList(); } - return bidsFromResponse(bidResponse, bidRequest.getImp()); + return bidsFromResponse(bidResponse, bidRequest.getImp(), errors); } - private static List bidsFromResponse(BidResponse bidResponse, List imps) { + private static List bidsFromResponse(BidResponse bidResponse, + List imps, + List errors) { return bidResponse.getSeatbid().stream() .filter(Objects::nonNull) .map(SeatBid::getBid) .filter(Objects::nonNull) .flatMap(Collection::stream) .filter(Objects::nonNull) - .map(bid -> makeBidderBid(bid, imps, bidResponse.getCur())) + .map(bid -> makeBidderBid(bid, imps, bidResponse.getCur(), errors)) .filter(Objects::nonNull) .toList(); } - private static BidderBid makeBidderBid(Bid bid, List imps, String currency) { - final BidType bidType = getBidType(bid, imps); - return bidType != null - ? BidderBid.of(bid, bidType, currency) - : null; + private static BidderBid makeBidderBid(Bid bid, List imps, String currency, List errors) { + final Imp imp = findImp(bid, imps); + try { + final BidType bidType = resolveBidType(bid, imp); + return bidType != null + ? BidderBid.of(bid, bidType, currency) + : null; + } catch (PreBidException e) { + errors.add(BidderError.badServerResponse(e.getMessage())); + return null; + } } - private static BidType getBidType(Bid bid, List imps) { + private static Imp findImp(Bid bid, List imps) { for (Imp imp : imps) { if (imp.getId().equals(bid.getImpid())) { - if (imp.getBanner() != null) { - return BidType.banner; - } else if (imp.getVideo() != null) { - return BidType.video; - } - return null; + return imp; } } throw new PreBidException("Unknown ad unit code '%s'".formatted(bid.getImpid())); } + + private static BidType resolveBidType(Bid bid, Imp imp) { + return switch (ObjectUtils.getIfNull(bid.getMtype(), 0)) { + case 0 -> resolveBidTypeFromImp(bid, imp); + case 1 -> BidType.banner; + case 2 -> BidType.video; + default -> throw new PreBidException( + "Unsupported mtype %d for bid '%s'".formatted(bid.getMtype(), bid.getId())); + }; + } + + private static BidType resolveBidTypeFromImp(Bid bid, Imp imp) { + if (imp.getVideo() != null && (imp.getBanner() == null || isVast(bid.getAdm()))) { + return BidType.video; + } + return imp.getBanner() != null ? BidType.banner : null; + } + + private static boolean isVast(String adm) { + return StringUtils.isNotEmpty(adm) && VAST_MARKUP.matcher(adm).find(); + } } diff --git a/src/test/java/org/prebid/server/bidder/yahooads/YahooAdsBidderTest.java b/src/test/java/org/prebid/server/bidder/yahooads/YahooAdsBidderTest.java index 5abbb2dc509..0da85da532d 100644 --- a/src/test/java/org/prebid/server/bidder/yahooads/YahooAdsBidderTest.java +++ b/src/test/java/org/prebid/server/bidder/yahooads/YahooAdsBidderTest.java @@ -390,6 +390,213 @@ public void makeBidsShouldSkipNotSupportedImpAndReturnVideoBidWhenVideoPresent() .containsOnly(BidderBid.of(Bid.builder().impid("321").build(), video, "USD")); } + @Test + public void makeBidsShouldReturnVideoBidWhenMtypeIsVideoOnMultiFormatImp() throws JsonProcessingException { + // given + final Bid bid = Bid.builder().impid("321").mtype(2).adm("").build(); + final BidderCall httpCall = givenHttpCall(givenMultiFormatImp(), bid); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).containsOnly(BidderBid.of(bid, video, "USD")); + } + + @Test + public void makeBidsShouldReturnBannerBidWhenMtypeIsBannerOnMultiFormatImp() throws JsonProcessingException { + // given + final Bid bid = Bid.builder().impid("321").mtype(1).adm("
ad
").build(); + final BidderCall httpCall = givenHttpCall(givenMultiFormatImp(), bid); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).containsOnly(BidderBid.of(bid, banner, "USD")); + } + + @Test + public void makeBidsShouldTreatZeroMtypeAsAbsent() throws JsonProcessingException { + // given + final Bid bid = Bid.builder().impid("321").mtype(0).adm("").build(); + final BidderCall httpCall = givenHttpCall(givenMultiFormatImp(), bid); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).containsOnly(BidderBid.of(bid, video, "USD")); + } + + @Test + public void makeBidsShouldReturnErrorForUnsupportedMtypeAndKeepOtherBids() throws JsonProcessingException { + // given + final Bid audioBid = Bid.builder().id("audioBidId").impid("321").mtype(3).build(); + final Bid nativeBid = Bid.builder().id("nativeBidId").impid("321").mtype(4).build(); + final Bid videoBid = Bid.builder().id("videoBidId").impid("321").mtype(2).build(); + final BidderCall httpCall = givenHttpCall(givenMultiFormatImp(), audioBid, nativeBid, videoBid); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).containsExactly( + BidderError.badServerResponse("Unsupported mtype 3 for bid 'audioBidId'"), + BidderError.badServerResponse("Unsupported mtype 4 for bid 'nativeBidId'")); + assertThat(result.getValue()).containsExactly(BidderBid.of(videoBid, video, "USD")); + } + + @Test + public void makeBidsShouldReturnErrorForUnsupportedMtypeOnBannerOnlyImp() throws JsonProcessingException { + // given + final Bid bannerBid = Bid.builder().id("bannerBidId").impid("321").mtype(1).adm("
ad
").build(); + final Bid nativeBid = Bid.builder().id("nativeBidId").impid("321").mtype(4).build(); + final Imp bannerOnlyImp = Imp.builder().id("321").banner(Banner.builder().build()).build(); + final BidderCall httpCall = givenHttpCall(bannerOnlyImp, bannerBid, nativeBid); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()) + .containsExactly(BidderError.badServerResponse("Unsupported mtype 4 for bid 'nativeBidId'")); + assertThat(result.getValue()).containsExactly(BidderBid.of(bannerBid, banner, "USD")); + } + + @Test + public void makeBidsShouldReturnVideoBidWhenNoMtypeAndAdmIsVastOnMultiFormatImp() throws JsonProcessingException { + // given + final Bid bid = Bid.builder().impid("321").adm("").build(); + final BidderCall httpCall = givenHttpCall(givenMultiFormatImp(), bid); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).containsOnly(BidderBid.of(bid, video, "USD")); + } + + @Test + public void makeBidsShouldReturnBannerBidWhenNoMtypeAndAdmIsHtmlOnMultiFormatImp() throws JsonProcessingException { + // given + final Bid bid = Bid.builder().impid("321").adm("
ad
").build(); + final BidderCall httpCall = givenHttpCall(givenMultiFormatImp(), bid); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).containsOnly(BidderBid.of(bid, banner, "USD")); + } + + @Test + public void makeBidsShouldReturnVideoBidWhenVastFollowsXmlDeclaration() throws JsonProcessingException { + // given + final Bid bid = Bid.builder() + .impid("321") + .adm("") + .build(); + final BidderCall httpCall = givenHttpCall(givenMultiFormatImp(), bid); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).containsOnly(BidderBid.of(bid, video, "USD")); + } + + @Test + public void makeBidsShouldReturnVideoBidWhenVastAttributesStartOnNextLine() throws JsonProcessingException { + // given + final Bid bid = Bid.builder().impid("321").adm("").build(); + final BidderCall httpCall = givenHttpCall(givenMultiFormatImp(), bid); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).containsOnly(BidderBid.of(bid, video, "USD")); + } + + @Test + public void makeBidsShouldReturnBannerBidWhenVastTagIsLowercase() throws JsonProcessingException { + // given + final Bid bid = Bid.builder().impid("321").adm("").build(); + final BidderCall httpCall = givenHttpCall(givenMultiFormatImp(), bid); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).containsOnly(BidderBid.of(bid, banner, "USD")); + } + + @Test + public void makeBidsShouldReturnBannerBidWhenAdmHasNoVastElement() throws JsonProcessingException { + // given + final Bid bid = Bid.builder().impid("321").adm("u").build(); + final BidderCall httpCall = givenHttpCall(givenMultiFormatImp(), bid); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).containsOnly(BidderBid.of(bid, banner, "USD")); + } + + @Test + public void makeBidsShouldReturnBannerBidWhenAdmMentionsVastOnlyAsText() throws JsonProcessingException { + // given + final Bid bid = Bid.builder().impid("321").adm("
VAST
").build(); + final BidderCall httpCall = givenHttpCall(givenMultiFormatImp(), bid); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).containsOnly(BidderBid.of(bid, banner, "USD")); + } + + @Test + public void makeBidsShouldReturnBannerBidWhenNoMtypeAndNoAdmOnMultiFormatImp() throws JsonProcessingException { + // given + final Bid bid = Bid.builder().impid("321").build(); + final BidderCall httpCall = givenHttpCall(givenMultiFormatImp(), bid); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).containsOnly(BidderBid.of(bid, banner, "USD")); + } + + @Test + public void makeBidsShouldReturnBannerBidWhenNoMtypeOnBannerOnlyImp() throws JsonProcessingException { + // given + final Bid bid = Bid.builder().impid("321").adm("
ad
").build(); + final Imp bannerOnlyImp = Imp.builder().id("321").banner(Banner.builder().build()).build(); + final BidderCall httpCall = givenHttpCall(bannerOnlyImp, bid); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).containsOnly(BidderBid.of(bid, banner, "USD")); + } + @Test public void makeHttpRequestsShouldPreserveTopLevel26RegsAndExtTypedFields() { // given @@ -955,10 +1162,27 @@ private static BidResponse givenBidResponse(Function givenHttpCall(BidRequest bidRequest, String body) { return BidderCall.succeededHttp( HttpRequest.builder().payload(bidRequest).build(), HttpResponse.of(200, null, body), null); } + + private static BidderCall givenHttpCall(Imp imp, Bid... bids) throws JsonProcessingException { + return givenHttpCall( + BidRequest.builder().imp(singletonList(imp)).build(), + mapper.writeValueAsString(BidResponse.builder() + .cur("USD") + .seatbid(singletonList(SeatBid.builder().bid(asList(bids)).build())) + .build())); + } }