diff --git a/src/main/java/org/prebid/server/auction/BidResponseCreator.java b/src/main/java/org/prebid/server/auction/BidResponseCreator.java index 567f73e63de..1c0b837bb4e 100644 --- a/src/main/java/org/prebid/server/auction/BidResponseCreator.java +++ b/src/main/java/org/prebid/server/auction/BidResponseCreator.java @@ -18,6 +18,7 @@ import io.vertx.core.CompositeFuture; import io.vertx.core.Future; import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.ListUtils; import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.ObjectUtils; @@ -191,10 +192,11 @@ Future createOnSkippedAuction(AuctionContext auctionContext, List cur = bidRequest.getCur(); final BidResponse bidResponse = BidResponse.builder() .id(bidRequest.getId()) - .cur(Stream.ofNullable(bidRequest.getCur()).flatMap(Collection::stream).findFirst().orElse(null)) - .seatbid(Optional.ofNullable(seatBids).orElse(Collections.emptyList())) + .cur(CollectionUtils.isNotEmpty(cur) ? cur.getFirst() : null) + .seatbid(ListUtils.emptyIfNull(seatBids)) .ext(extBidResponse) .build(); diff --git a/src/main/java/org/prebid/server/auction/StoredResponseProcessor.java b/src/main/java/org/prebid/server/auction/StoredResponseProcessor.java index 64ec4869a9b..b769d2974b1 100644 --- a/src/main/java/org/prebid/server/auction/StoredResponseProcessor.java +++ b/src/main/java/org/prebid/server/auction/StoredResponseProcessor.java @@ -69,31 +69,30 @@ public StoredResponseProcessor(ApplicationSettings applicationSettings, Future getStoredResponseResult(List imps, Timeout timeout) { final Map impExtPrebids = getImpsExtPrebid(imps); - final Map auctionStoredResponseToImpId = getAuctionStoredResponses(impExtPrebids); - final List requiredRequestImps = excludeStoredAuctionResponseImps(imps, auctionStoredResponseToImpId); + final Map impIdsToStoredResponses = getAuctionStoredResponses(impExtPrebids); + final List requiredRequestImps = excludeStoredAuctionResponseImps(imps, impIdsToStoredResponses); - final Map> impToBidderToStoredBidResponseId = getStoredBidResponses(impExtPrebids, - requiredRequestImps); + final Map> impToBidderToStoredBidResponseId = + getStoredBidResponses(impExtPrebids, requiredRequestImps); - final Set storedIds = new HashSet<>(auctionStoredResponseToImpId.keySet()); + final Set storedResponses = new HashSet<>(impIdsToStoredResponses.values()); - storedIds.addAll( - impToBidderToStoredBidResponseId.values().stream() - .flatMap(bidderToId -> bidderToId.values().stream()) - .collect(Collectors.toSet())); + impToBidderToStoredBidResponseId.values() + .forEach(bidderToStoredResponse -> storedResponses.addAll(bidderToStoredResponse.values())); - if (storedIds.isEmpty()) { - return Future.succeededFuture(StoredResponseResult.of(imps, Collections.emptyList(), - Collections.emptyMap())); + if (storedResponses.isEmpty()) { + return Future.succeededFuture( + StoredResponseResult.of(imps, Collections.emptyList(), Collections.emptyMap())); } - return applicationSettings.getStoredResponses(storedIds, timeout) + return getStoredResponses(storedResponses, timeout) .recover(exception -> Future.failedFuture(new InvalidRequestException( "Stored response fetching failed with reason: " + exception.getMessage()))) .map(storedResponseDataResult -> StoredResponseResult.of( requiredRequestImps, - convertToSeatBid(storedResponseDataResult, auctionStoredResponseToImpId), - mapStoredBidResponseIdsToValues(storedResponseDataResult.getIdToStoredResponses(), + convertToSeatBid(storedResponseDataResult, impIdsToStoredResponses), + mapStoredBidResponseIdsToValues( + storedResponseDataResult.getIdToStoredResponses(), impToBidderToStoredBidResponseId))); } @@ -107,161 +106,95 @@ Future getStoredResponseResult(String storedId, Timeout ti Collections.emptyMap())); } - private List excludeStoredAuctionResponseImps(List imps, - Map auctionStoredResponseToImpId) { - + private Map getImpsExtPrebid(List imps) { return imps.stream() - .filter(imp -> !auctionStoredResponseToImpId.containsValue(imp.getId())) - .toList(); - } - - public List updateStoredBidResponse(List auctionParticipations) { - return auctionParticipations.stream() - .map(StoredResponseProcessor::updateStoredBidResponse) - .collect(Collectors.toList()); + .collect(Collectors.toMap(Imp::getId, imp -> getExtImp(imp.getExt(), imp.getId()).getPrebid())); } - private static AuctionParticipation updateStoredBidResponse(AuctionParticipation auctionParticipation) { - final BidderRequest bidderRequest = auctionParticipation.getBidderRequest(); - final BidRequest bidRequest = bidderRequest.getBidRequest(); - - final List imps = bidRequest.getImp(); - // Аor now, Stored Bid Response works only for bid requests with single imp - if (imps.size() > 1 || StringUtils.isEmpty(bidderRequest.getStoredResponse())) { - return auctionParticipation; + private ExtImp getExtImp(ObjectNode extImpNode, String impId) { + try { + return mapper.mapper().treeToValue(extImpNode, ExtImp.class); + } catch (JsonProcessingException e) { + throw new InvalidRequestException( + "Error decoding bidRequest.imp.ext for impId = %s : %s".formatted(impId, e.getMessage())); } - - final BidderResponse bidderResponse = auctionParticipation.getBidderResponse(); - final BidderSeatBid initialSeatBid = bidderResponse.getSeatBid(); - final BidderSeatBid adjustedSeatBid = updateSeatBid(initialSeatBid, imps.getFirst().getId()); - - return auctionParticipation.with(bidderResponse.with(adjustedSeatBid)); - } - - private static BidderSeatBid updateSeatBid(BidderSeatBid bidderSeatBid, String impId) { - final List bids = bidderSeatBid.getBids().stream() - .map(bidderBid -> resolveBidImpId(bidderBid, impId)) - .collect(Collectors.toList()); - - return bidderSeatBid.with(bids); } - private static BidderBid resolveBidImpId(BidderBid bidderBid, String impId) { - final Bid bid = bidderBid.getBid(); - final String bidImpId = bid.getImpid(); - if (!StringUtils.contains(bidImpId, PBS_IMPID_MACRO)) { - return bidderBid; - } - - return bidderBid.toBuilder() - .bid(bid.toBuilder().impid(bidImpId.replace(PBS_IMPID_MACRO, impId)).build()) - .build(); + private Map getAuctionStoredResponses(Map extImpPrebids) { + return extImpPrebids.entrySet().stream() + .map(impIdToExtPrebid -> Tuple2.of( + impIdToExtPrebid.getKey(), + extractAuctionStoredResponseId(impIdToExtPrebid.getValue()))) + .filter(impIdToStoredResponseId -> impIdToStoredResponseId.getRight() != null) + .collect(Collectors.toMap(Tuple2::getLeft, Tuple2::getRight)); } - List mergeWithBidderResponses(List auctionParticipations, - List storedAuctionResponses, - List imps, - Map bidRejectionTrackers) { - if (CollectionUtils.isEmpty(storedAuctionResponses)) { - return auctionParticipations; - } - - final Map bidderToAuctionParticipation = auctionParticipations.stream() - .collect(Collectors.toMap(AuctionParticipation::getBidder, Function.identity())); - final Map bidderToSeatBid = storedAuctionResponses.stream() - .collect(Collectors.toMap(SeatBid::getSeat, Function.identity())); - final Map impIdToBidType = imps.stream() - .collect(Collectors.toMap(Imp::getId, this::resolveBidType)); - final Set responseBidders = new HashSet<>(bidderToAuctionParticipation.keySet()); - responseBidders.addAll(bidderToSeatBid.keySet()); - - return responseBidders.stream() - .map(bidder -> updateBidderResponse(bidderToAuctionParticipation.get(bidder), - bidderToSeatBid.get(bidder), impIdToBidType)) - .map(auctionParticipation -> restoreStoredBidsFromRejection(bidRejectionTrackers, auctionParticipation)) - .toList(); + private StoredResponse extractAuctionStoredResponseId(ExtImpPrebid extImpPrebid) { + final ExtStoredAuctionResponse storedAuctionResponse = extImpPrebid.getStoredAuctionResponse(); + return Optional.ofNullable(storedAuctionResponse) + .map(ExtStoredAuctionResponse::getSeatBid) + .map(StoredResponse.StoredResponseObject::new) + .or(() -> Optional.ofNullable(storedAuctionResponse) + .map(ExtStoredAuctionResponse::getId) + .map(StoredResponse.StoredResponseId::new)) + .orElse(null); } - private static AuctionParticipation restoreStoredBidsFromRejection( - Map bidRejectionTrackers, - AuctionParticipation auctionParticipation) { - - final BidRejectionTracker bidRejectionTracker = bidRejectionTrackers.get(auctionParticipation.getBidder()); - - if (bidRejectionTracker != null) { - Optional.ofNullable(auctionParticipation.getBidderResponse()) - .map(BidderResponse::getSeatBid) - .map(BidderSeatBid::getBids) - .ifPresent(bidRejectionTracker::restoreFromRejection); - } - - return auctionParticipation; - } + private List excludeStoredAuctionResponseImps(List imps, + Map impIdToStoredResponse) { - private Map getImpsExtPrebid(List imps) { return imps.stream() - .collect(Collectors.toMap(Imp::getId, imp -> getExtImp(imp.getExt(), imp.getId()).getPrebid())); - } - - private Map getAuctionStoredResponses(Map extImpPrebids) { - return extImpPrebids.entrySet().stream() - .map(impIdToExtPrebid -> Tuple2.of(impIdToExtPrebid.getKey(), - extractAuctionStoredResponseId(impIdToExtPrebid.getValue()))) - .filter(impIdToStoredResponseId -> impIdToStoredResponseId.getRight() != null) - .collect(Collectors.toMap(Tuple2::getRight, Tuple2::getLeft)); + .filter(imp -> !impIdToStoredResponse.containsKey(imp.getId())) + .toList(); } - private String extractAuctionStoredResponseId(ExtImpPrebid extImpPrebid) { - final ExtStoredAuctionResponse storedAuctionResponse = extImpPrebid.getStoredAuctionResponse(); - return storedAuctionResponse != null ? storedAuctionResponse.getId() : null; - } + private Map> getStoredBidResponses( + Map extImpPrebids, + List imps) { - private Map> getStoredBidResponses(Map extImpPrebids, - List imps) { // PBS supports stored bid response only for requests with single impression, but it can be changed in future if (imps.size() != 1) { return Collections.emptyMap(); } - final Set impsIds = imps.stream().map(Imp::getId).collect(Collectors.toSet()); - return extImpPrebids.entrySet().stream() - .filter(impIdToExtPrebid -> impsIds.contains(impIdToExtPrebid.getKey())) - .filter(impIdToExtPrebid -> CollectionUtils - .isNotEmpty(impIdToExtPrebid.getValue().getStoredBidResponse())) - .collect(Collectors.toMap(Map.Entry::getKey, + .filter(impIdToExtPrebid -> + CollectionUtils.isNotEmpty(impIdToExtPrebid.getValue().getStoredBidResponse())) + .collect(Collectors.toMap( + Map.Entry::getKey, impIdToStoredResponses -> resolveStoredBidResponse(impIdToStoredResponses.getValue().getStoredBidResponse()))); } - private ExtImp getExtImp(ObjectNode extImpNode, String impId) { - try { - return mapper.mapper().treeToValue(extImpNode, ExtImp.class); - } catch (JsonProcessingException e) { - throw new InvalidRequestException( - "Error decoding bidRequest.imp.ext for impId = %s : %s".formatted(impId, e.getMessage())); - } - } + private Map resolveStoredBidResponse( + List storedBidResponse) { - private Map resolveStoredBidResponse(List storedBidResponse) { return storedBidResponse.stream() - .collect(Collectors.toMap(ExtStoredBidResponse::getBidder, ExtStoredBidResponse::getId)); + .collect(Collectors.toMap( + ExtStoredBidResponse::getBidder, + extStoredBidResponse -> new StoredResponse.StoredResponseId(extStoredBidResponse.getId()))); + } + + private Future getStoredResponses(Set storedResponses, Timeout timeout) { + return applicationSettings.getStoredResponses( + storedResponses.stream() + .filter(StoredResponse.StoredResponseId.class::isInstance) + .map(StoredResponse.StoredResponseId.class::cast) + .map(StoredResponse.StoredResponseId::id) + .collect(Collectors.toSet()), + timeout); } private List convertToSeatBid(StoredResponseDataResult storedResponseDataResult, - Map auctionStoredResponses) { + Map impIdsToStoredResponses) { + final List resolvedSeatBids = new ArrayList<>(); final Map idToStoredResponses = storedResponseDataResult.getIdToStoredResponses(); - for (final Map.Entry storedIdToImpId : auctionStoredResponses.entrySet()) { - final String id = storedIdToImpId.getKey(); - final String impId = storedIdToImpId.getValue(); - final String rowSeatBid = idToStoredResponses.get(id); - if (rowSeatBid == null) { - throw new InvalidRequestException( - "Failed to fetch stored auction response for impId = %s and storedAuctionResponse id = %s." - .formatted(impId, id)); - } - final List seatBids = parseSeatBid(id, rowSeatBid); + for (Map.Entry impIdToStoredResponse : impIdsToStoredResponses.entrySet()) { + final String impId = impIdToStoredResponse.getKey(); + final StoredResponse storedResponse = impIdToStoredResponse.getValue(); + final List seatBids = resolveSeatBids(storedResponse, idToStoredResponses, impId); + validateStoredSeatBid(seatBids); resolvedSeatBids.addAll(seatBids.stream() .map(seatBid -> updateSeatBidBids(seatBid, impId)) @@ -273,7 +206,7 @@ private List convertToSeatBid(StoredResponseDataResult storedResponseDa private List convertToSeatBid(StoredResponseDataResult storedResponseDataResult) { final List resolvedSeatBids = new ArrayList<>(); final Map idToStoredResponses = storedResponseDataResult.getIdToStoredResponses(); - for (final Map.Entry storedIdToImpId : idToStoredResponses.entrySet()) { + for (Map.Entry storedIdToImpId : idToStoredResponses.entrySet()) { final String id = storedIdToImpId.getKey(); final String rowSeatBid = storedIdToImpId.getValue(); if (rowSeatBid == null) { @@ -287,6 +220,25 @@ private List convertToSeatBid(StoredResponseDataResult storedResponseDa return mergeSameBidderSeatBid(resolvedSeatBids); } + private List resolveSeatBids(StoredResponse storedResponse, + Map idToStoredResponses, + String impId) { + + if (storedResponse instanceof StoredResponse.StoredResponseObject storedResponseObject) { + return Collections.singletonList(storedResponseObject.seatBid()); + } + + final String storedResponseId = ((StoredResponse.StoredResponseId) storedResponse).id(); + final String rowSeatBid = idToStoredResponses.get(storedResponseId); + if (rowSeatBid == null) { + throw new InvalidRequestException( + "Failed to fetch stored auction response for impId = %s and storedAuctionResponse id = %s." + .formatted(impId, storedResponseId)); + } + + return parseSeatBid(storedResponseId, rowSeatBid); + } + private List parseSeatBid(String id, String rowSeatBid) { try { return mapper.mapper().readValue(rowSeatBid, SEATBID_LIST_TYPE); @@ -295,18 +247,6 @@ private List parseSeatBid(String id, String rowSeatBid) { } } - private SeatBid updateSeatBidBids(SeatBid seatBid, String impId) { - return seatBid.toBuilder().bid(updateBidsWithImpId(seatBid.getBid(), impId)).build(); - } - - private List updateBidsWithImpId(List bids, String impId) { - return bids.stream().map(bid -> updateBidWithImpId(bid, impId)).toList(); - } - - private static Bid updateBidWithImpId(Bid bid, String impId) { - return bid.toBuilder().impid(impId).build(); - } - private void validateStoredSeatBid(List seatBids) { for (final SeatBid seatBid : seatBids) { if (StringUtils.isEmpty(seatBid.getSeat())) { @@ -319,6 +259,18 @@ private void validateStoredSeatBid(List seatBids) { } } + private SeatBid updateSeatBidBids(SeatBid seatBid, String impId) { + return seatBid.toBuilder().bid(updateBidsWithImpId(seatBid.getBid(), impId)).build(); + } + + private List updateBidsWithImpId(List bids, String impId) { + return bids.stream().map(bid -> updateBidWithImpId(bid, impId)).toList(); + } + + private static Bid updateBidWithImpId(Bid bid, String impId) { + return bid.toBuilder().impid(impId).build(); + } + private List mergeSameBidderSeatBid(List seatBids) { return seatBids.stream().collect(Collectors.groupingBy(SeatBid::getSeat, Collectors.toList())) .entrySet().stream() @@ -336,23 +288,108 @@ private SeatBid makeMergedSeatBid(String seat, List storedSeatBids) { private Map> mapStoredBidResponseIdsToValues( Map idToStoredResponses, - Map> impToBidderToStoredBidResponseId) { + Map> impToBidderToStoredBidResponseId) { return impToBidderToStoredBidResponseId.entrySet().stream() .collect(Collectors.toMap( Map.Entry::getKey, entry -> entry.getValue().entrySet().stream() - .filter(bidderToId -> idToStoredResponses.containsKey(bidderToId.getValue())) + .filter(bidderToId -> idToStoredResponses.containsKey(bidderToId.getValue().id())) .collect(Collectors.toMap( Map.Entry::getKey, - bidderToId -> idToStoredResponses.get(bidderToId.getValue()), + bidderToId -> idToStoredResponses.get(bidderToId.getValue().id()), (first, second) -> second, CaseInsensitiveMap::new)))); } + public List updateStoredBidResponse(List auctionParticipations) { + return auctionParticipations.stream() + .map(StoredResponseProcessor::updateStoredBidResponse) + .collect(Collectors.toList()); + } + + private static AuctionParticipation updateStoredBidResponse(AuctionParticipation auctionParticipation) { + final BidderRequest bidderRequest = auctionParticipation.getBidderRequest(); + final BidRequest bidRequest = bidderRequest.getBidRequest(); + + final List imps = bidRequest.getImp(); + // Аor now, Stored Bid Response works only for bid requests with single imp + if (imps.size() > 1 || StringUtils.isEmpty(bidderRequest.getStoredResponse())) { + return auctionParticipation; + } + + final BidderResponse bidderResponse = auctionParticipation.getBidderResponse(); + final BidderSeatBid initialSeatBid = bidderResponse.getSeatBid(); + final BidderSeatBid adjustedSeatBid = updateSeatBid(initialSeatBid, imps.getFirst().getId()); + + return auctionParticipation.with(bidderResponse.with(adjustedSeatBid)); + } + + private static BidderSeatBid updateSeatBid(BidderSeatBid bidderSeatBid, String impId) { + final List bids = bidderSeatBid.getBids().stream() + .map(bidderBid -> resolveBidImpId(bidderBid, impId)) + .collect(Collectors.toList()); + + return bidderSeatBid.with(bids); + } + + private static BidderBid resolveBidImpId(BidderBid bidderBid, String impId) { + final Bid bid = bidderBid.getBid(); + final String bidImpId = bid.getImpid(); + if (!StringUtils.contains(bidImpId, PBS_IMPID_MACRO)) { + return bidderBid; + } + + return bidderBid.toBuilder() + .bid(bid.toBuilder().impid(bidImpId.replace(PBS_IMPID_MACRO, impId)).build()) + .build(); + } + + List mergeWithBidderResponses(List auctionParticipations, + List storedAuctionResponses, + List imps, + Map bidRejectionTrackers) { + + if (CollectionUtils.isEmpty(storedAuctionResponses)) { + return auctionParticipations; + } + + final Map bidderToAuctionParticipation = auctionParticipations.stream() + .collect(Collectors.toMap(AuctionParticipation::getBidder, Function.identity())); + final Map bidderToSeatBid = storedAuctionResponses.stream() + .collect(Collectors.toMap(SeatBid::getSeat, Function.identity())); + final Map impIdToBidType = imps.stream() + .collect(Collectors.toMap(Imp::getId, this::resolveBidType)); + final Set responseBidders = new HashSet<>(bidderToAuctionParticipation.keySet()); + responseBidders.addAll(bidderToSeatBid.keySet()); + + return responseBidders.stream() + .map(bidder -> updateBidderResponse( + bidderToAuctionParticipation.get(bidder), + bidderToSeatBid.get(bidder), + impIdToBidType)) + .map(auctionParticipation -> restoreStoredBidsFromRejection(bidRejectionTrackers, auctionParticipation)) + .toList(); + } + + private BidType resolveBidType(Imp imp) { + BidType bidType = BidType.banner; + if (imp.getBanner() != null) { + return bidType; + } else if (imp.getVideo() != null) { + bidType = BidType.video; + } else if (imp.getXNative() != null) { + bidType = BidType.xNative; + } else if (imp.getAudio() != null) { + bidType = BidType.audio; + } + return bidType; + } + private AuctionParticipation updateBidderResponse(AuctionParticipation auctionParticipation, SeatBid storedSeatBid, Map impIdToBidType) { + if (auctionParticipation != null) { if (auctionParticipation.isRequestBlocked()) { return auctionParticipation; @@ -377,13 +414,17 @@ private AuctionParticipation updateBidderResponse(AuctionParticipation auctionPa } } - private BidderSeatBid makeBidderSeatBid(BidderSeatBid bidderSeatBid, SeatBid seatBid, + private BidderSeatBid makeBidderSeatBid(BidderSeatBid bidderSeatBid, + SeatBid seatBid, Map impIdToBidType) { + final boolean nonNullBidderSeatBid = bidderSeatBid != null; final String bidCurrency = nonNullBidderSeatBid ? bidderSeatBid.getBids().stream() - .map(BidderBid::getBidCurrency).filter(Objects::nonNull) - .findAny().orElse(DEFAULT_BID_CURRENCY) + .map(BidderBid::getBidCurrency) + .filter(Objects::nonNull) + .findAny() + .orElse(DEFAULT_BID_CURRENCY) : DEFAULT_BID_CURRENCY; final List bidderBids = seatBid != null ? seatBid.getBid().stream() @@ -416,17 +457,28 @@ private ExtBidPrebid parseExtBidPrebid(ObjectNode bidExtPrebid) { } } - private BidType resolveBidType(Imp imp) { - BidType bidType = BidType.banner; - if (imp.getBanner() != null) { - return bidType; - } else if (imp.getVideo() != null) { - bidType = BidType.video; - } else if (imp.getXNative() != null) { - bidType = BidType.xNative; - } else if (imp.getAudio() != null) { - bidType = BidType.audio; + private static AuctionParticipation restoreStoredBidsFromRejection( + Map bidRejectionTrackers, + AuctionParticipation auctionParticipation) { + + final BidRejectionTracker bidRejectionTracker = bidRejectionTrackers.get(auctionParticipation.getBidder()); + + if (bidRejectionTracker != null) { + Optional.ofNullable(auctionParticipation.getBidderResponse()) + .map(BidderResponse::getSeatBid) + .map(BidderSeatBid::getBids) + .ifPresent(bidRejectionTracker::restoreFromRejection); + } + + return auctionParticipation; + } + + private sealed interface StoredResponse { + + record StoredResponseId(String id) implements StoredResponse { + } + + record StoredResponseObject(SeatBid seatBid) implements StoredResponse { } - return bidType; } } diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtStoredAuctionResponse.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtStoredAuctionResponse.java index 852a106a7ef..e8f83afcccb 100644 --- a/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtStoredAuctionResponse.java +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/ExtStoredAuctionResponse.java @@ -15,4 +15,7 @@ public class ExtStoredAuctionResponse { @JsonProperty("seatbidarr") List seatBids; + + @JsonProperty("seatbidobj") + SeatBid seatBid; } diff --git a/src/main/java/org/prebid/server/validation/ImpValidator.java b/src/main/java/org/prebid/server/validation/ImpValidator.java index f90df916af1..d5d9ecba2f1 100644 --- a/src/main/java/org/prebid/server/validation/ImpValidator.java +++ b/src/main/java/org/prebid/server/validation/ImpValidator.java @@ -457,8 +457,9 @@ private void validateImpExtPrebidStoredResponses(ExtImpPrebid extPrebid, + " is not supported at the imp level"); } - if (extStoredAuctionResponse.getId() == null) { - throw new ValidationException("request.imp[%d].ext.prebid.storedauctionresponse.id should be defined", + if (extStoredAuctionResponse.getId() == null && extStoredAuctionResponse.getSeatBid() == null) { + throw new ValidationException( + "request.imp[%d].ext.prebid.storedauctionresponse.{id or seatbidobj} should be defined", impIndex); } } diff --git a/src/test/groovy/org/prebid/server/functional/model/request/auction/StoredAuctionResponse.groovy b/src/test/groovy/org/prebid/server/functional/model/request/auction/StoredAuctionResponse.groovy index 2cab8a9fdc1..cde5f2268de 100644 --- a/src/test/groovy/org/prebid/server/functional/model/request/auction/StoredAuctionResponse.groovy +++ b/src/test/groovy/org/prebid/server/functional/model/request/auction/StoredAuctionResponse.groovy @@ -10,4 +10,6 @@ class StoredAuctionResponse { String id @JsonProperty("seatbidarr") List seatBids + @JsonProperty("seatbidobj") + SeatBid seatBidObject } diff --git a/src/test/groovy/org/prebid/server/functional/tests/StoredResponseSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/StoredResponseSpec.groovy index e2a8a39ffef..fccb14c8bab 100644 --- a/src/test/groovy/org/prebid/server/functional/tests/StoredResponseSpec.groovy +++ b/src/test/groovy/org/prebid/server/functional/tests/StoredResponseSpec.groovy @@ -2,11 +2,14 @@ package org.prebid.server.functional.tests import org.prebid.server.functional.model.db.StoredResponse import org.prebid.server.functional.model.request.auction.BidRequest +import org.prebid.server.functional.model.request.auction.Imp import org.prebid.server.functional.model.request.auction.StoredAuctionResponse import org.prebid.server.functional.model.request.auction.StoredBidResponse +import org.prebid.server.functional.model.response.auction.Bid import org.prebid.server.functional.model.response.auction.BidResponse import org.prebid.server.functional.model.response.auction.ErrorType import org.prebid.server.functional.model.response.auction.SeatBid +import org.prebid.server.functional.service.PrebidServerException import org.prebid.server.functional.util.PBSUtils import spock.lang.PendingFeature @@ -249,4 +252,127 @@ class StoredResponseSpec extends BaseSpec { and: "PBS not send request to bidder" assert bidder.getRequestCount(bidRequest.id) == 0 } + + def "PBS should set seatBid in response from single imp.ext.prebid.storedBidResponse.seatbidobj when it is defined"() { + given: "Default basic BidRequest with stored response" + def bidRequest = BidRequest.defaultBidRequest + def storedAuctionResponse = SeatBid.getStoredResponse(bidRequest) + bidRequest.imp[0].ext.prebid.storedAuctionResponse = new StoredAuctionResponse(seatBidObject: storedAuctionResponse) + + when: "PBS processes auction request" + def response = defaultPbsService.sendAuctionRequest(bidRequest) + + then: "Response should contain same stored auction response as requested" + assert convertToComparableSeatBid(response.seatbid) == [storedAuctionResponse] + + and: "PBS not send request to bidder" + assert bidder.getRequestCount(bidRequest.id) == 0 + } + + def "PBS should throw error when imp.ext.prebid.storedBidResponse.seatbidobj is with empty seatbid"() { + given: "Default basic BidRequest with empty stored response" + def bidRequest = BidRequest.defaultBidRequest + bidRequest.imp[0].ext.prebid.storedAuctionResponse = new StoredAuctionResponse(seatBidObject: new SeatBid()) + + when: "PBS processes auction request" + defaultPbsService.sendAuctionRequest(bidRequest) + + then: "PBS throws an exception" + def exception = thrown(PrebidServerException) + assert exception.statusCode == 400 + assert exception.responseBody == 'Invalid request format: Seat can\'t be empty in stored response seatBid' + + and: "PBS not send request to bidder" + assert bidder.getRequestCount(bidRequest.id) == 0 + } + + def "PBS should throw error when imp.ext.prebid.storedBidResponse.seatbidobj is with empty bids"() { + given: "Default basic BidRequest with empty bids for stored response" + def bidRequest = BidRequest.defaultBidRequest + bidRequest.imp[0].ext.prebid.storedAuctionResponse = new StoredAuctionResponse(seatBidObject: new SeatBid(bid: [], seat: GENERIC)) + + when: "PBS processes auction request" + defaultPbsService.sendAuctionRequest(bidRequest) + + then: "PBS throws an exception" + def exception = thrown(PrebidServerException) + assert exception.statusCode == 400 + assert exception.responseBody == 'Invalid request format: There must be at least one bid in stored response seatBid' + + and: "PBS not send request to bidder" + assert bidder.getRequestCount(bidRequest.id) == 0 + } + + def "PBS should prefer seatbidobj over storedAuctionResponse.id from imp when both are present"() { + given: "Default basic BidRequest with stored response" + def bidRequest = BidRequest.defaultBidRequest + def storedAuctionResponse = SeatBid.getStoredResponse(bidRequest) + bidRequest.imp[0].ext.prebid.storedAuctionResponse = new StoredAuctionResponse().tap { + id = PBSUtils.randomString + seatBidObject = storedAuctionResponse + } + + when: "PBS processes auction request" + def response = defaultPbsService.sendAuctionRequest(bidRequest) + + then: "Response should contain same stored auction response as requested" + assert convertToComparableSeatBid(response.seatbid) == [storedAuctionResponse] + + and: "PBS not send request to bidder" + assert bidder.getRequestCount(bidRequest.id) == 0 + } + + def "PBS should set seatBids in response from multiple imp.ext.prebid.storedBidResponse.seatbidobj when it is defined"() { + given: "BidRequest with multiple imps" + def bidRequest = BidRequest.defaultBidRequest.tap { + imp = [impWithSeatBidObject, impWithSeatBidObject] + } + + when: "PBS processes auction request" + def response = defaultPbsService.sendAuctionRequest(bidRequest) + + then: "Response should contain same stored auction response bids as requested" + assert convertToComparableSeatBid(response.seatbid).bid.flatten().sort() == + bidRequest.imp.ext.prebid.storedAuctionResponse.seatBidObject.bid.flatten().sort() + + and: "PBS not send request to bidder" + assert bidder.getRequestCount(bidRequest.id) == 0 + } + + def "PBS should prefer seatbidarr from request over seatbidobj from imp when both are present"() { + given: "Default basic BidRequest with stored response" + def bidRequest = BidRequest.defaultBidRequest + def storedAuctionResponse = SeatBid.getStoredResponse(bidRequest) + bidRequest.tap{ + imp[0].ext.prebid.storedAuctionResponse = new StoredAuctionResponse().tap { + seatBidObject = SeatBid.getStoredResponse(bidRequest) + } + ext.prebid.storedAuctionResponse = new StoredAuctionResponse(seatBids: [storedAuctionResponse]) + } + + when: "PBS processes auction request" + def response = defaultPbsService.sendAuctionRequest(bidRequest) + + then: "Response should contain same stored auction response as requested" + assert response.seatbid == [storedAuctionResponse] + + and: "PBS not send request to bidder" + assert bidder.getRequestCount(bidRequest.id) == 0 + } + + private static final Imp getImpWithSeatBidObject() { + def imp = Imp.defaultImpression + def bids = Bid.getDefaultBids([imp]) + def seatBid = new SeatBid(bid: bids, seat: GENERIC) + imp.tap { + ext.prebid.storedAuctionResponse = new StoredAuctionResponse(seatBidObject: seatBid) + } + } + + private static final List convertToComparableSeatBid(List seatBid) { + seatBid*.tap { + it.bid*.ext = null + it.group = null + } + } } diff --git a/src/test/java/org/prebid/server/auction/SkippedAuctionServiceTest.java b/src/test/java/org/prebid/server/auction/SkippedAuctionServiceTest.java index b284cf60f3a..adcd1e9a296 100644 --- a/src/test/java/org/prebid/server/auction/SkippedAuctionServiceTest.java +++ b/src/test/java/org/prebid/server/auction/SkippedAuctionServiceTest.java @@ -126,7 +126,7 @@ public void skipAuctionShouldReturnFailedFutureWhenStoredResponseSeatBidAndIdAre final AuctionContext auctionContext = AuctionContext.builder() .bidRequest(BidRequest.builder() .ext(ExtRequest.of(ExtRequestPrebid.builder() - .storedAuctionResponse(ExtStoredAuctionResponse.of(null, null)) + .storedAuctionResponse(ExtStoredAuctionResponse.of(null, null, null)) .build())) .build()) .build(); @@ -147,7 +147,7 @@ public void skipAuctionShouldReturnFailedFutureWhenStoredResponseSeatBidAndIdAre public void skipAuctionShouldReturnBidResponseWithSeatBidsFromStoredAuctionResponse() { // given final List givenSeatBids = givenSeatBids("bidId1", "bidId2"); - final ExtStoredAuctionResponse givenStoredResponse = ExtStoredAuctionResponse.of("id", givenSeatBids); + final ExtStoredAuctionResponse givenStoredResponse = ExtStoredAuctionResponse.of("id", givenSeatBids, null); final AuctionContext givenAuctionContext = AuctionContext.builder() .debugWarnings(new ArrayList<>()) .bidRequest(BidRequest.builder() @@ -179,7 +179,8 @@ public void skipAuctionShouldReturnBidResponseWithSeatBidsFromStoredAuctionRespo @Test public void skipAuctionShouldReturnEmptySeatBidsWhenSeatBidIsNull() { // given - final ExtStoredAuctionResponse givenStoredResponse = ExtStoredAuctionResponse.of("id", singletonList(null)); + final ExtStoredAuctionResponse givenStoredResponse = ExtStoredAuctionResponse.of( + "id", singletonList(null), null); final AuctionContext givenAuctionContext = AuctionContext.builder() .debugWarnings(new ArrayList<>()) .bidRequest(BidRequest.builder() @@ -214,7 +215,7 @@ public void skipAuctionShouldReturnEmptySeatBidsWhenSeatBidIsNull() { public void skipAuctionShouldReturnEmptySeatBidsWhenSeatIsEmpty() { // given final List givenSeatBids = singletonList(SeatBid.builder().seat("").build()); - final ExtStoredAuctionResponse givenStoredResponse = ExtStoredAuctionResponse.of("id", givenSeatBids); + final ExtStoredAuctionResponse givenStoredResponse = ExtStoredAuctionResponse.of("id", givenSeatBids, null); final AuctionContext givenAuctionContext = AuctionContext.builder() .debugWarnings(new ArrayList<>()) .bidRequest(BidRequest.builder() @@ -249,7 +250,7 @@ public void skipAuctionShouldReturnEmptySeatBidsWhenSeatIsEmpty() { public void skipAuctionShouldReturnEmptySeatBidsWhenBidsAreEmpty() { // given final List givenSeatBids = singletonList(SeatBid.builder().seat("seat").bid(emptyList()).build()); - final ExtStoredAuctionResponse givenStoredResponse = ExtStoredAuctionResponse.of("id", givenSeatBids); + final ExtStoredAuctionResponse givenStoredResponse = ExtStoredAuctionResponse.of("id", givenSeatBids, null); final AuctionContext givenAuctionContext = AuctionContext.builder() .debugWarnings(new ArrayList<>()) .bidRequest(BidRequest.builder() @@ -283,7 +284,7 @@ public void skipAuctionShouldReturnEmptySeatBidsWhenBidsAreEmpty() { @Test public void skipAuctionShouldReturnBidResponseWithEmptySeatBidsWhenNoValueAvailableById() { // given - final ExtStoredAuctionResponse givenStoredResponse = ExtStoredAuctionResponse.of("id", null); + final ExtStoredAuctionResponse givenStoredResponse = ExtStoredAuctionResponse.of("id", null, null); final AuctionContext givenAuctionContext = AuctionContext.builder() .debugWarnings(new ArrayList<>()) .timeoutContext(TimeoutContext.of(1000L, timeout, 0)) @@ -320,7 +321,7 @@ public void skipAuctionShouldReturnBidResponseWithEmptySeatBidsWhenNoValueAvaila public void skipAuctionShouldReturnBidResponseWithStoredSeatBidsByProvidedId() { // given final List givenSeatBids = givenSeatBids("bidId1", "bidId2"); - final ExtStoredAuctionResponse givenStoredResponse = ExtStoredAuctionResponse.of("id", null); + final ExtStoredAuctionResponse givenStoredResponse = ExtStoredAuctionResponse.of("id", null, null); final AuctionContext givenAuctionContext = AuctionContext.builder() .debugWarnings(new ArrayList<>()) .timeoutContext(TimeoutContext.of(1000L, timeout, 0)) diff --git a/src/test/java/org/prebid/server/auction/StoredResponseProcessorTest.java b/src/test/java/org/prebid/server/auction/StoredResponseProcessorTest.java index 7b88fb26c34..3b625ddf379 100644 --- a/src/test/java/org/prebid/server/auction/StoredResponseProcessorTest.java +++ b/src/test/java/org/prebid/server/auction/StoredResponseProcessorTest.java @@ -83,7 +83,7 @@ public void setUp() { @Test public void getStoredResponseResultShouldReturnSeatBidsForAuctionResponseId() throws JsonProcessingException { // given - final List imps = singletonList(givenImp("impId", ExtStoredAuctionResponse.of("1", null), null)); + final List imps = singletonList(givenImp("impId", ExtStoredAuctionResponse.of("1", null, null), null)); given(applicationSettings.getStoredResponses(any(), any())) .willReturn(Future.succeededFuture(StoredResponseDataResult.of(singletonMap("1", @@ -149,7 +149,7 @@ public void getStoredResponseResultShouldAddImpToRequiredRequestWhenItsStoredAuc @Test public void getStoredResponseResultShouldReturnFailedFutureWhenErrorHappenedDuringRetrievingStoredResponse() { // given - final List imps = singletonList(givenImp("impId", ExtStoredAuctionResponse.of("1", null), null)); + final List imps = singletonList(givenImp("impId", ExtStoredAuctionResponse.of("1", null, null), null)); given(applicationSettings.getStoredResponses(any(), any())) .willReturn(Future.failedFuture(new PreBidException("Failed."))); @@ -194,7 +194,7 @@ public void getStoredResponseResultShouldReturnResultForBidAndAuctionStoredRespo // given final Imp imp1 = givenImp( "impId1", - ExtStoredAuctionResponse.of("storedAuctionResponseId", Collections.emptyList()), + ExtStoredAuctionResponse.of("storedAuctionResponseId", Collections.emptyList(), null), null); final Imp imp2 = givenImp( "impId2", @@ -228,7 +228,7 @@ public void getStoredResponseResultShouldReturnResultForBidAndAuctionStoredRespo @Test public void getStoredResponseResultShouldThrowInvalidRequestExceptionWhenStoredAuctionResponseWasNotFound() { // given - final Imp imp1 = givenImp("impId1", ExtStoredAuctionResponse.of("storedAuctionResponseId", null), null); + final Imp imp1 = givenImp("impId1", ExtStoredAuctionResponse.of("storedAuctionResponseId", null, null), null); given(applicationSettings.getStoredResponses(any(), any())).willReturn( Future.succeededFuture(StoredResponseDataResult.of(emptyMap(), emptyList()))); @@ -247,8 +247,8 @@ public void getStoredResponseResultShouldThrowInvalidRequestExceptionWhenStoredA public void getStoredResponseResultShouldMergeStoredSeatBidsForTheSameBidder() throws JsonProcessingException { // given final List imps = asList( - givenImp("impId1", ExtStoredAuctionResponse.of("storedAuctionResponse1", null), null), - givenImp("impId2", ExtStoredAuctionResponse.of("storedAuctionResponse2", null), null)); + givenImp("impId1", ExtStoredAuctionResponse.of("storedAuctionResponse1", null, null), null), + givenImp("impId2", ExtStoredAuctionResponse.of("storedAuctionResponse2", null, null), null)); final Map storedResponse = new HashMap<>(); storedResponse.put("storedAuctionResponse1", mapper.writeValueAsString(asList( @@ -275,9 +275,65 @@ public void getStoredResponseResultShouldMergeStoredSeatBidsForTheSameBidder() t SeatBid.builder() .seat("rubicon") .bid(asList( - Bid.builder().id("id2").impid("impId2").build(), - Bid.builder().id("id3").impid("impId1").build() - )) + Bid.builder().id("id3").impid("impId1").build(), + Bid.builder().id("id2").impid("impId2").build())) + .build()), + emptyMap())); + } + + @Test + public void getStoredResponseResultShouldUseStoredSeatBidsFromRequest() throws JsonProcessingException { + // given + final List imps = asList( + givenImp( + "impId1", + ExtStoredAuctionResponse.of( + "storedAuctionResponse1", + null, + SeatBid.builder() + .seat("rubicon") + .bid(singletonList(Bid.builder().id("id4").build())) + .build()), + null), + givenImp("impId2", ExtStoredAuctionResponse.of("storedAuctionResponse2", null, null), null), + givenImp( + "impId3", + ExtStoredAuctionResponse.of( + null, + null, + SeatBid.builder() + .seat("appnexus") + .bid(singletonList(Bid.builder().id("id5").build())) + .build()), + null)); + + final Map storedResponse = new HashMap<>(); + storedResponse.put("storedAuctionResponse1", mapper.writeValueAsString(asList( + SeatBid.builder().seat("appnexus").bid(singletonList(Bid.builder().id("id1").build())).build(), + SeatBid.builder().seat("rubicon").bid(singletonList(Bid.builder().id("id3").build())).build()))); + storedResponse.put("storedAuctionResponse2", mapper.writeValueAsString(singletonList( + SeatBid.builder().seat("rubicon").bid(singletonList(Bid.builder().id("id2").build())).build()))); + + given(applicationSettings.getStoredResponses(any(), any())).willReturn( + Future.succeededFuture(StoredResponseDataResult.of(storedResponse, emptyList()))); + + // when + final Future result = + target.getStoredResponseResult(imps, timeout); + + // then + assertThat(result.result()).isEqualTo(StoredResponseResult.of( + emptyList(), + asList( + SeatBid.builder() + .seat("appnexus") + .bid(singletonList(Bid.builder().id("id5").impid("impId3").build())) + .build(), + SeatBid.builder() + .seat("rubicon") + .bid(asList( + Bid.builder().id("id4").impid("impId1").build(), + Bid.builder().id("id2").impid("impId2").build())) .build()), emptyMap())); } @@ -301,7 +357,7 @@ public void getStoredResponseResultShouldReturnFailedFutureWhenSeatIsEmptyInStor throws JsonProcessingException { // given - final List imps = singletonList(givenImp("impId", ExtStoredAuctionResponse.of("1", null), null)); + final List imps = singletonList(givenImp("impId", ExtStoredAuctionResponse.of("1", null, null), null)); given(applicationSettings.getStoredResponses(any(), any())) .willReturn(Future.succeededFuture(StoredResponseDataResult.of( @@ -328,7 +384,7 @@ public void getStoredResponseResultShouldReturnFailedFutureWhenBidsAreEmptyInSto // given final List imps = singletonList( - givenImp("impId", ExtStoredAuctionResponse.of("1", null), null)); + givenImp("impId", ExtStoredAuctionResponse.of("1", null, null), null)); given(applicationSettings.getStoredResponses(any(), any())) .willReturn(Future.succeededFuture(StoredResponseDataResult.of( @@ -352,7 +408,7 @@ public void getStoredResponseResultShouldReturnFailedFutureWhenBidsAreEmptyInSto @Test public void getStoredResponseResultShouldReturnFailedFutureSeatBidsCannotBeParsed() { // given - final List imps = singletonList(givenImp("impId", ExtStoredAuctionResponse.of("1", null), null)); + final List imps = singletonList(givenImp("impId", ExtStoredAuctionResponse.of("1", null, null), null)); given(applicationSettings.getStoredResponses(any(), any())).willReturn(Future.succeededFuture( StoredResponseDataResult.of(singletonMap("1", "{invalid"), emptyList()))); diff --git a/src/test/java/org/prebid/server/validation/ImpValidatorTest.java b/src/test/java/org/prebid/server/validation/ImpValidatorTest.java index 04e5eb1f326..652150b6732 100644 --- a/src/test/java/org/prebid/server/validation/ImpValidatorTest.java +++ b/src/test/java/org/prebid/server/validation/ImpValidatorTest.java @@ -18,6 +18,7 @@ import com.iab.openrtb.request.TitleObject; import com.iab.openrtb.request.Video; import com.iab.openrtb.request.VideoObject; +import com.iab.openrtb.response.SeatBid; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -42,6 +43,7 @@ import static java.util.Collections.singletonMap; import static java.util.function.UnaryOperator.identity; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -1347,7 +1349,7 @@ public void validateImpsShouldReturnValidationMessageWhenImpExtPrebidBiddersNotD // given final ObjectNode prebid = mapper.valueToTree(ExtImpPrebid.builder() .storedBidResponse(singletonList(ExtStoredBidResponse.of("bidder", "id"))) - .storedAuctionResponse(ExtStoredAuctionResponse.of("id", null)) + .storedAuctionResponse(ExtStoredAuctionResponse.of("id", null, null)) .build()); final List givenImps = singletonList(validImpBuilder() @@ -1573,14 +1575,28 @@ public void validateImpsShouldReturnValidationMessageWhenExtImpPrebidHasStoredAu // given final List givenImps = singletonList(validImpBuilder() - .ext(mapper.valueToTree(singletonMap("prebid", singletonMap( - "storedauctionresponse", mapper.createObjectNode())))) + .ext(mapper.valueToTree(singletonMap("prebid", singletonMap( + "storedauctionresponse", mapper.createObjectNode())))) .build()); // when & then assertThatThrownBy(() -> target.validateImps(givenImps, Collections.emptyMap(), null)) .isInstanceOf(ValidationException.class) - .hasMessage("request.imp[0].ext.prebid.storedauctionresponse.id should be defined"); + .hasMessage("request.imp[0].ext.prebid.storedauctionresponse.{id or seatbidobj} should be defined"); + } + + @Test + public void validateImpsShouldNotReturnValidationMessageWhenStoredAuctionResponseWithoutIdAndWithSeatBidObj() + throws ValidationException { + + // given + final List givenImps = singletonList(validImpBuilder() + .ext(mapper.valueToTree(singletonMap("prebid", singletonMap( + "storedauctionresponse", singletonMap("seatbidobj", SeatBid.builder().build()))))) + .build()); + + // when & then + assertThatNoException().isThrownBy(() -> target.validateImps(givenImps, Collections.emptyMap(), null)); } @Test @@ -1589,11 +1605,11 @@ public void validateImpsShouldReturnWarningMessageWhenExtImpPrebidHasStoredAucti // given final List givenImps = singletonList(validImpBuilder() - .ext(mapper.valueToTree(singletonMap("prebid", Map.of( - "storedauctionresponse", mapper.createObjectNode() - .put("id", "1") - .set("seatbidarr", mapper.createArrayNode()))) - )).build()); + .ext(mapper.valueToTree(singletonMap("prebid", Map.of( + "storedauctionresponse", mapper.createObjectNode() + .put("id", "1") + .set("seatbidarr", mapper.createArrayNode()))) + )).build()); final List debugMessages = new ArrayList<>();