diff --git a/src/main/java/org/prebid/server/bidder/mediasquare/MediasquareBidder.java b/src/main/java/org/prebid/server/bidder/mediasquare/MediasquareBidder.java new file mode 100644 index 00000000000..18c9fca362b --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/mediasquare/MediasquareBidder.java @@ -0,0 +1,296 @@ +package org.prebid.server.bidder.mediasquare; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.Banner; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Format; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Native; +import com.iab.openrtb.request.Regs; +import com.iab.openrtb.request.User; +import com.iab.openrtb.request.Video; +import com.iab.openrtb.response.Bid; +import io.vertx.core.http.HttpMethod; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.lang3.ObjectUtils; +import org.prebid.server.bidder.Bidder; +import org.prebid.server.bidder.mediasquare.request.MediasquareBanner; +import org.prebid.server.bidder.mediasquare.request.MediasquareCode; +import org.prebid.server.bidder.mediasquare.request.MediasquareFloor; +import org.prebid.server.bidder.mediasquare.request.MediasquareGdpr; +import org.prebid.server.bidder.mediasquare.request.MediasquareMediaTypes; +import org.prebid.server.bidder.mediasquare.request.MediasquareRequest; +import org.prebid.server.bidder.mediasquare.request.MediasquareSupport; +import org.prebid.server.bidder.mediasquare.response.MediasquareBid; +import org.prebid.server.bidder.mediasquare.response.MediasquareResponse; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +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.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.ExtRegs; +import org.prebid.server.proto.openrtb.ext.request.ExtRegsDsa; +import org.prebid.server.proto.openrtb.ext.request.mediasquare.ExtImpMediasquare; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebid; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebidMeta; +import org.prebid.server.util.BidderUtil; +import org.prebid.server.util.HttpUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +public class MediasquareBidder implements Bidder { + + private static final String SIZE_FORMAT = "%dx%d"; + private static final TypeReference> TYPE_REFERENCE = new TypeReference<>() { + }; + + private final String endpointUrl; + private final JacksonMapper mapper; + + public MediasquareBidder(String endpointUrl, JacksonMapper mapper) { + this.endpointUrl = HttpUtil.validateUrl(Objects.requireNonNull(endpointUrl)); + this.mapper = Objects.requireNonNull(mapper); + } + + @Override + public Result>> makeHttpRequests(BidRequest request) { + final List codes = new ArrayList<>(); + final List errors = new ArrayList<>(); + + for (Imp imp : request.getImp()) { + try { + final ExtImpMediasquare extImp = parseImpExt(imp); + final MediasquareCode mediasquareCode = makeCode(request, imp, extImp); + if (isCodeValid(mediasquareCode)) { + codes.add(mediasquareCode); + } + } catch (PreBidException e) { + errors.add(BidderError.badInput(e.getMessage())); + } + } + + if (codes.isEmpty()) { + return Result.withErrors(errors); + } + + final MediasquareRequest outgoingRequest = makeRequest(request, codes); + + final HttpRequest httpRequest = HttpRequest.builder() + .method(HttpMethod.POST) + .uri(endpointUrl) + .headers(HttpUtil.headers()) + .body(mapper.encodeToBytes(outgoingRequest)) + .payload(outgoingRequest) + .impIds(BidderUtil.impIds(request)) + .build(); + + return Result.of(List.of(httpRequest), errors); + } + + private ExtImpMediasquare parseImpExt(Imp imp) { + try { + return mapper.mapper().convertValue(imp.getExt(), TYPE_REFERENCE).getBidder(); + } catch (IllegalArgumentException e) { + throw new PreBidException("can not parse imp.ext" + e.getMessage()); + } + } + + private static MediasquareCode makeCode(BidRequest bidRequest, Imp imp, ExtImpMediasquare extImp) { + final MediasquareMediaTypes mediaTypes = makeMediaTypes(imp); + final Map floors = mediaTypes == null + ? null + : makeFloors(MediasquareFloor.of(imp.getBidfloor(), imp.getBidfloorcur()), mediaTypes); + + return MediasquareCode.builder() + .adUnit(imp.getTagid()) + .auctionId(bidRequest.getId()) + .bidId(imp.getId()) + .code(extImp.getCode()) + .owner(extImp.getOwner()) + .mediaTypes(mediaTypes) + .floor(floors) + .build(); + } + + private static MediasquareMediaTypes makeMediaTypes(Imp imp) { + final Video video = imp.getVideo(); + final Banner banner = imp.getBanner(); + final Native xNative = imp.getXNative(); + + if (video == null && banner == null && xNative == null) { + return null; + } + + return MediasquareMediaTypes.builder() + .banner(makeBanner(banner)) + .video(video) + .nativeRequest(xNative != null ? xNative.getRequest() : null) + .build(); + } + + private static MediasquareBanner makeBanner(Banner banner) { + if (banner == null) { + return null; + } + + final List> sizes = new ArrayList<>(); + if (CollectionUtils.isNotEmpty(banner.getFormat())) { + for (Format format : banner.getFormat()) { + sizes.add(List.of(format.getW(), format.getH())); + } + } else { + sizes.add(List.of(banner.getW(), banner.getH())); + } + + return MediasquareBanner.of(sizes); + } + + private static Map makeFloors(MediasquareFloor floor, MediasquareMediaTypes mediaTypes) { + final Map floors = new HashMap<>(); + + final Video video = mediaTypes.getVideo(); + final MediasquareBanner banner = mediaTypes.getBanner(); + final String xNative = mediaTypes.getNativeRequest(); + + if (video != null) { + if (video.getW() != null && video.getH() != null) { + final String videoSize = SIZE_FORMAT.formatted(video.getW(), video.getH()); + floors.put(videoSize, floor); + } + floors.put("*", floor); + } + + if (banner != null) { + for (List format: banner.getSizes()) { + floors.put(SIZE_FORMAT.formatted(format.get(0), format.get(1)), floor); + } + } + + if (xNative != null) { + floors.put("*", floor); + } + + return MapUtils.isNotEmpty(floors) ? floors : null; + } + + private static boolean isCodeValid(MediasquareCode code) { + final MediasquareMediaTypes mediaTypes = code.getMediaTypes(); + return mediaTypes != null && ObjectUtils.anyNotNull( + mediaTypes.getBanner(), mediaTypes.getVideo(), mediaTypes.getNativeRequest()); + } + + private MediasquareRequest makeRequest(BidRequest bidRequest, List codes) { + final User user = bidRequest.getUser(); + final Regs regs = bidRequest.getRegs(); + + return MediasquareRequest.builder() + .codes(codes) + .dsa(getDsa(regs)) + .gdpr(makeGdpr(user, regs)) + .type("pbs") + .support(MediasquareSupport.of(bidRequest.getDevice(), bidRequest.getApp())) + .test(Objects.equals(bidRequest.getTest(), 1)) + .build(); + } + + private static ExtRegsDsa getDsa(Regs regs) { + return Optional.ofNullable(regs) + .map(Regs::getExt) + .map(ExtRegs::getDsa) + .orElse(null); + } + + private static MediasquareGdpr makeGdpr(User user, Regs regs) { + final boolean gdprApplies = Optional.ofNullable(regs) + .map(Regs::getGdpr) + .map(gdpr -> gdpr == 1) + .orElse(false); + final String consent = user != null ? user.getConsent() : null; + return MediasquareGdpr.of(gdprApplies, consent); + } + + @Override + public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + try { + final MediasquareResponse response = mapper.decodeValue( + httpCall.getResponse().getBody(), + MediasquareResponse.class); + return Result.withValues(extractBids(response)); + } catch (DecodeException e) { + return Result.withError(BidderError.badServerResponse("Failed to decode response: " + e.getMessage())); + } + } + + private List extractBids(MediasquareResponse response) { + if (response == null || CollectionUtils.isEmpty(response.getResponses())) { + return Collections.emptyList(); + } + + return response.getResponses().stream() + .filter(Objects::nonNull) + .map(this::makeBidderBid) + .collect(Collectors.toList()); + } + + private BidderBid makeBidderBid(MediasquareBid bid) { + final BidType bidType = getBidType(bid); + return BidderBid.of(makeBid(bid, bidType), bidType, bid.getCurrency()); + } + + private static BidType getBidType(MediasquareBid bid) { + if (bid.getVideo() != null) { + return BidType.video; + } + if (bid.getNativeResponse() != null) { + return BidType.xNative; + } + return BidType.banner; + } + + private Bid makeBid(MediasquareBid bid, BidType bidType) { + return Bid.builder() + .id(bid.getId()) + .impid(bid.getBidId()) + .price(bid.getCpm()) + .adm(bid.getAd()) + .adomain(bid.getAdomain()) + .w(bid.getWidth()) + .h(bid.getHeight()) + .crid(bid.getCreativeId()) + .mtype(bidType.ordinal() + 1) + .burl(bid.getBurl()) + .ext(getBidExt(bid, bidType)) + .build(); + } + + private ObjectNode getBidExt(MediasquareBid bid, BidType bidType) { + final ExtBidPrebidMeta meta = ExtBidPrebidMeta.builder() + .advertiserDomains(bid.getAdomain() != null ? bid.getAdomain() : null) + .mediaType(bidType.getName()) + .build(); + + final ExtBidPrebid prebid = ExtBidPrebid.builder().meta(meta).build(); + + final ObjectNode bidExt = mapper.mapper().createObjectNode(); + if (bid.getDsa() != null) { + bidExt.set("dsa", bid.getDsa()); + } + bidExt.set("prebid", mapper.mapper().valueToTree(prebid)); + + return bidExt; + } +} diff --git a/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareBanner.java b/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareBanner.java new file mode 100644 index 00000000000..bb8af052d81 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareBanner.java @@ -0,0 +1,11 @@ +package org.prebid.server.bidder.mediasquare.request; + +import lombok.Value; + +import java.util.List; + +@Value(staticConstructor = "of") +public class MediasquareBanner { + + List> sizes; +} diff --git a/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareCode.java b/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareCode.java new file mode 100644 index 00000000000..ea7559dcab8 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareCode.java @@ -0,0 +1,30 @@ +package org.prebid.server.bidder.mediasquare.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Value; + +import java.util.Map; + +@Builder +@Value(staticConstructor = "of") +public class MediasquareCode { + + @JsonProperty("adunit") + String adUnit; + + @JsonProperty("auctionid") + String auctionId; + + @JsonProperty("bidid") + String bidId; + + String code; + + String owner; + + @JsonProperty("mediatypes") + MediasquareMediaTypes mediaTypes; + + Map floor; +} diff --git a/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareFloor.java b/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareFloor.java new file mode 100644 index 00000000000..18987f20f2a --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareFloor.java @@ -0,0 +1,13 @@ +package org.prebid.server.bidder.mediasquare.request; + +import lombok.Value; + +import java.math.BigDecimal; + +@Value(staticConstructor = "of") +public class MediasquareFloor { + + BigDecimal floor; + + String currency; +} diff --git a/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareGdpr.java b/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareGdpr.java new file mode 100644 index 00000000000..6dcf676a2db --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareGdpr.java @@ -0,0 +1,11 @@ +package org.prebid.server.bidder.mediasquare.request; + +import lombok.Value; + +@Value(staticConstructor = "of") +public class MediasquareGdpr { + + boolean consentRequired; + + String consentString; +} diff --git a/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareMediaTypes.java b/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareMediaTypes.java new file mode 100644 index 00000000000..da8e5ec59a7 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareMediaTypes.java @@ -0,0 +1,16 @@ +package org.prebid.server.bidder.mediasquare.request; + +import com.iab.openrtb.request.Video; +import lombok.Builder; +import lombok.Value; + +@Builder +@Value(staticConstructor = "of") +public class MediasquareMediaTypes { + + MediasquareBanner banner; + + Video video; + + String nativeRequest; +} diff --git a/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareRequest.java b/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareRequest.java new file mode 100644 index 00000000000..d236e55b80f --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareRequest.java @@ -0,0 +1,26 @@ +package org.prebid.server.bidder.mediasquare.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Value; +import org.prebid.server.proto.openrtb.ext.request.ExtRegsDsa; + +import java.util.List; + +@Builder +@Value(staticConstructor = "of") +public class MediasquareRequest { + + List codes; + + MediasquareGdpr gdpr; + + String type; + + ExtRegsDsa dsa; + + @JsonProperty("tech") + MediasquareSupport support; + + Boolean test; +} diff --git a/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareSupport.java b/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareSupport.java new file mode 100644 index 00000000000..33df240a732 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/mediasquare/request/MediasquareSupport.java @@ -0,0 +1,13 @@ +package org.prebid.server.bidder.mediasquare.request; + +import com.iab.openrtb.request.App; +import com.iab.openrtb.request.Device; +import lombok.Value; + +@Value(staticConstructor = "of") +public class MediasquareSupport { + + Device device; + + App app; +} diff --git a/src/main/java/org/prebid/server/bidder/mediasquare/response/MediasquareBid.java b/src/main/java/org/prebid/server/bidder/mediasquare/response/MediasquareBid.java new file mode 100644 index 00000000000..57301c8df2c --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/mediasquare/response/MediasquareBid.java @@ -0,0 +1,49 @@ +package org.prebid.server.bidder.mediasquare.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Builder; +import lombok.Value; + +import java.math.BigDecimal; +import java.util.List; + +@Value +@Builder(toBuilder = true) +public class MediasquareBid { + + String id; + + String ad; + + String bidId; + + String bidder; + + BigDecimal cpm; + + String currency; + + String creativeId; + + Integer height; + + Integer width; + + Boolean netRevenue; + + String transactionId; + + Integer ttl; + + ObjectNode video; + + @JsonProperty("native") + ObjectNode nativeResponse; + + List adomain; + + ObjectNode dsa; + + String burl; +} diff --git a/src/main/java/org/prebid/server/bidder/mediasquare/response/MediasquareResponse.java b/src/main/java/org/prebid/server/bidder/mediasquare/response/MediasquareResponse.java new file mode 100644 index 00000000000..8260fb633ee --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/mediasquare/response/MediasquareResponse.java @@ -0,0 +1,11 @@ +package org.prebid.server.bidder.mediasquare.response; + +import lombok.Value; + +import java.util.List; + +@Value(staticConstructor = "of") +public class MediasquareResponse { + + List responses; +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/mediasquare/ExtImpMediasquare.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/mediasquare/ExtImpMediasquare.java new file mode 100644 index 00000000000..1c71f176881 --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/mediasquare/ExtImpMediasquare.java @@ -0,0 +1,11 @@ +package org.prebid.server.proto.openrtb.ext.request.mediasquare; + +import lombok.Value; + +@Value(staticConstructor = "of") +public class ExtImpMediasquare { + + String owner; + + String code; +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/MediasquareConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/MediasquareConfiguration.java new file mode 100644 index 00000000000..bbdd5b6ed2b --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/MediasquareConfiguration.java @@ -0,0 +1,41 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.mediasquare.MediasquareBidder; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.spring.config.bidder.model.BidderConfigurationProperties; +import org.prebid.server.spring.config.bidder.util.BidderDepsAssembler; +import org.prebid.server.spring.config.bidder.util.UsersyncerCreator; +import org.prebid.server.spring.env.YamlPropertySourceFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +import jakarta.validation.constraints.NotBlank; + +@Configuration +@PropertySource(value = "classpath:/bidder-config/mediasquare.yaml", factory = YamlPropertySourceFactory.class) +public class MediasquareConfiguration { + + private static final String BIDDER_NAME = "mediasquare"; + + @Bean("mediasquareConfigurationProperties") + @ConfigurationProperties("adapters.mediasquare") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps mediasquareBidderDeps(BidderConfigurationProperties mediasquareConfigurationProperties, + @NotBlank @Value("${external-url}") String externalUrl, + JacksonMapper mapper) { + + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(mediasquareConfigurationProperties) + .usersyncerCreator(UsersyncerCreator.create(externalUrl)) + .bidderCreator(config -> new MediasquareBidder(config.getEndpoint(), mapper)) + .assemble(); + } +} diff --git a/src/main/resources/bidder-config/mediasquare.yaml b/src/main/resources/bidder-config/mediasquare.yaml new file mode 100644 index 00000000000..b8be22071d8 --- /dev/null +++ b/src/main/resources/bidder-config/mediasquare.yaml @@ -0,0 +1,13 @@ +adapters: + mediasquare: + endpoint: "https://pbs-front.mediasquare.fr/msq_prebid" + endpoint-compression: gzip + modifying-vast-xml-allowed: true + meta-info: + maintainer-email: tech@mediasquare.fr + app-media-types: + - banner + - video + - native + supported-vendors: + vendor-id: 791 diff --git a/src/main/resources/static/bidder-params/mediasquare.json b/src/main/resources/static/bidder-params/mediasquare.json new file mode 100644 index 00000000000..ee8d3c67d0d --- /dev/null +++ b/src/main/resources/static/bidder-params/mediasquare.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Mediasquare Adapter Params", + "description": "A schema which validates params accepted by the Mediasquare adapter", + "type": "object", + "properties": { + "owner": { + "type": "string", + "minLength": 1, + "description": "The owner provided for mediasquare." + }, + "code": { + "type": "string", + "minLength": 1, + "description": "The code provided for mediasquare." + } + }, + "required": [ + "owner", + "code" + ] +} diff --git a/src/test/java/org/prebid/server/bidder/mediasquare/MediasquareBidderTest.java b/src/test/java/org/prebid/server/bidder/mediasquare/MediasquareBidderTest.java new file mode 100644 index 00000000000..b0de11b61f0 --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/mediasquare/MediasquareBidderTest.java @@ -0,0 +1,415 @@ +package org.prebid.server.bidder.mediasquare; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.App; +import com.iab.openrtb.request.Banner; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Format; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Native; +import com.iab.openrtb.request.Regs; +import com.iab.openrtb.request.User; +import com.iab.openrtb.request.Video; +import com.iab.openrtb.response.Bid; +import org.junit.jupiter.api.Test; +import org.prebid.server.VertxTest; +import org.prebid.server.bidder.mediasquare.request.MediasquareBanner; +import org.prebid.server.bidder.mediasquare.request.MediasquareCode; +import org.prebid.server.bidder.mediasquare.request.MediasquareFloor; +import org.prebid.server.bidder.mediasquare.request.MediasquareGdpr; +import org.prebid.server.bidder.mediasquare.request.MediasquareMediaTypes; +import org.prebid.server.bidder.mediasquare.request.MediasquareRequest; +import org.prebid.server.bidder.mediasquare.request.MediasquareSupport; +import org.prebid.server.bidder.mediasquare.response.MediasquareBid; +import org.prebid.server.bidder.mediasquare.response.MediasquareResponse; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +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.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.mediasquare.ExtImpMediasquare; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebid; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebidMeta; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.UnaryOperator; + +import static java.util.Collections.singletonList; +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.prebid.server.util.HttpUtil.ACCEPT_HEADER; +import static org.prebid.server.util.HttpUtil.APPLICATION_JSON_CONTENT_TYPE; +import static org.prebid.server.util.HttpUtil.CONTENT_TYPE_HEADER; +import static org.springframework.util.MimeTypeUtils.APPLICATION_JSON_VALUE; + +public class MediasquareBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "https://test.endpoint.com"; + + private final MediasquareBidder target = new MediasquareBidder(ENDPOINT_URL, jacksonMapper); + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + assertThatIllegalArgumentException().isThrownBy(() -> new MediasquareBidder("invalid_url", jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldReturnErrorIfImpExtCouldNotBeParsed() { + // given + final BidRequest bidRequest = givenBidRequest( + imp -> imp.ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode())))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()).startsWith("can not parse imp.ext"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldHaveImpIds() { + // given + final BidRequest bidRequest = givenBidRequest(imp -> imp.id("givenImp1"), imp -> imp.id("givenImp2")); + + //when + final Result>> result = target.makeHttpRequests(bidRequest); + + //then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getImpIds) + .containsOnly(Set.of("givenImp1", "givenImp2")); + } + + @Test + public void makeHttpRequestsShouldReturnExpectedHeaders() { + // given + final BidRequest bidRequest = givenBidRequest(identity()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).hasSize(1).first() + .extracting(HttpRequest::getHeaders) + .satisfies(headers -> assertThat(headers.get(CONTENT_TYPE_HEADER)) + .isEqualTo(APPLICATION_JSON_CONTENT_TYPE)) + .satisfies(headers -> assertThat(headers.get(ACCEPT_HEADER)) + .isEqualTo(APPLICATION_JSON_VALUE)); + assertThat(result.getErrors()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldUseCorrectUri() { + // given + final BidRequest bidRequest = givenBidRequest(identity()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getUri) + .containsExactly("https://test.endpoint.com"); + } + + @Test + public void makeHttpRequestsShouldCreateCorrectRequestPayload() { + // given + final Device givenDevice = Device.builder().build(); + final App givenApp = App.builder().build(); + final ExtRegsDsa givenDsa = ExtRegsDsa.of(1, 2, 3, Collections.emptyList()); + + final BidRequest bidRequest = givenBidRequest( + imp -> imp + .id("imp_id_1") + .tagid("tag_id_1") + .bidfloor(BigDecimal.ONE) + .bidfloorcur("USD") + .banner(Banner.builder() + .format(singletonList(Format.builder().w(300).h(250).build())) + .build()) + .ext(givenImpExt("owner1", "code1")), + imp -> imp + .id("imp_id_2") + .tagid("tag_id_2") + .bidfloor(BigDecimal.TEN) + .bidfloorcur("EUR") + .banner(null) + .video(Video.builder().w(640).h(480).build()) + .ext(givenImpExt("owner2", "code2")), + imp -> imp + .id("imp_id_3") + .tagid("tag_id_3") + .bidfloor(BigDecimal.valueOf(0.5)) + .bidfloorcur("USD") + .banner(null) + .xNative(Native.builder().request("native_request_str").build()) + .ext(givenImpExt("owner3", "code3"))) + .toBuilder() + .id("request_id") + .test(1) + .user(User.builder().consent("consent_str").build()) + .regs(Regs.builder().gdpr(1).ext(ExtRegs.of(null, null, null, givenDsa)).build()) + .device(givenDevice) + .app(givenApp) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + final MediasquareFloor bannerFloor = MediasquareFloor.of(BigDecimal.ONE, "USD"); + final MediasquareFloor videoFloor = MediasquareFloor.of(BigDecimal.TEN, "EUR"); + final MediasquareFloor nativeFloor = MediasquareFloor.of(BigDecimal.valueOf(0.5), "USD"); + + final MediasquareCode expectedCode1 = MediasquareCode.builder() + .adUnit("tag_id_1") + .auctionId("request_id") + .bidId("imp_id_1") + .owner("owner1") + .code("code1") + .mediaTypes(MediasquareMediaTypes.builder() + .banner(MediasquareBanner.of(List.of(List.of(300, 250)))) + .build()) + .floor(Map.of("300x250", bannerFloor)) + .build(); + final MediasquareCode expectedCode2 = MediasquareCode.builder() + .adUnit("tag_id_2") + .auctionId("request_id") + .bidId("imp_id_2") + .owner("owner2") + .code("code2") + .mediaTypes(MediasquareMediaTypes.builder() + .video(Video.builder().w(640).h(480).build()) + .build()) + .floor(Map.of("640x480", videoFloor, "*", videoFloor)) + .build(); + final MediasquareCode expectedCode3 = MediasquareCode.builder() + .adUnit("tag_id_3") + .auctionId("request_id") + .bidId("imp_id_3") + .owner("owner3") + .code("code3") + .mediaTypes(MediasquareMediaTypes.builder() + .nativeRequest("native_request_str") + .build()) + .floor(Map.of("*", nativeFloor)) + .build(); + + final MediasquareRequest expectedRequest = MediasquareRequest.builder() + .codes(List.of(expectedCode1, expectedCode2, expectedCode3)) + .dsa(givenDsa) + .gdpr(MediasquareGdpr.of(true, "consent_str")) + .type("pbs") + .support(MediasquareSupport.of(givenDevice, givenApp)) + .test(true) + .build(); + + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .containsExactly(expectedRequest); + } + + @Test + public void makeBidsShouldReturnErrorWhenResponseBodyCouldNotBeParsed() { + // given + final BidderCall httpCall = givenHttpCall("invalid_json"); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response); + assertThat(error.getMessage()).startsWith( + "Failed to decode response: Failed to decode: Unrecognized token 'invalid_json'"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnBannerBidSuccessfully() throws JsonProcessingException { + // given + final MediasquareBid mediasquareBid = givenMediasquareBid(1); + final BidderCall httpCall = givenHttpCall( + mapper.writeValueAsString(MediasquareResponse.of(List.of(mediasquareBid)))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + final ObjectNode expectedExt = mapper.createObjectNode() + .set("prebid", mapper.valueToTree(ExtBidPrebid.builder().meta(ExtBidPrebidMeta.builder() + .mediaType("banner") + .advertiserDomains(List.of("adomain.com")) + .build()).build())); + expectedExt.set("dsa", mapper.createObjectNode().put("key", "value")); + + final Bid expectedBid = Bid.builder() + .id("bidId") + .impid("impId") + .price(BigDecimal.valueOf(1.23)) + .adm("ad-markup") + .adomain(List.of("adomain.com")) + .w(300) + .h(250) + .crid("crid") + .mtype(1) + .burl("burl") + .ext(expectedExt) + .build(); + + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .containsExactly(BidderBid.of(expectedBid, BidType.banner, "USD")); + } + + @Test + public void makeBidsShouldReturnVideoBidSuccessfully() throws JsonProcessingException { + // given + final MediasquareBid mediasquareBid = givenMediasquareBid(2); + final BidderCall httpCall = givenHttpCall( + mapper.writeValueAsString(MediasquareResponse.of(List.of(mediasquareBid)))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + final ObjectNode expectedExt = mapper.createObjectNode() + .set("prebid", mapper.valueToTree(ExtBidPrebid.builder().meta(ExtBidPrebidMeta.builder() + .mediaType("video") + .advertiserDomains(List.of("adomain.com")) + .build()).build())); + expectedExt.set("dsa", mapper.createObjectNode().put("key", "value")); + + final Bid expectedBid = Bid.builder() + .id("bidId") + .impid("impId") + .price(BigDecimal.valueOf(1.23)) + .adm("ad-markup") + .adomain(List.of("adomain.com")) + .w(300) + .h(250) + .crid("crid") + .mtype(2) + .burl("burl") + .ext(expectedExt) + .build(); + + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .containsExactly(BidderBid.of(expectedBid, BidType.video, "USD")); + } + + @Test + public void makeBidsShouldReturnNativeBidSuccessfully() throws JsonProcessingException { + // given + final MediasquareBid mediasquareBid = givenMediasquareBid(4); + final BidderCall httpCall = givenHttpCall( + mapper.writeValueAsString(MediasquareResponse.of(List.of(mediasquareBid)))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + final ObjectNode expectedExt = mapper.createObjectNode() + .set("prebid", mapper.valueToTree(ExtBidPrebid.builder().meta(ExtBidPrebidMeta.builder() + .mediaType("native") + .advertiserDomains(List.of("adomain.com")) + .build()).build())); + expectedExt.set("dsa", mapper.createObjectNode().put("key", "value")); + + final Bid expectedBid = Bid.builder() + .id("bidId") + .impid("impId") + .price(BigDecimal.valueOf(1.23)) + .adm("ad-markup") + .adomain(List.of("adomain.com")) + .w(300) + .h(250) + .crid("crid") + .mtype(4) + .burl("burl") + .ext(expectedExt) + .build(); + + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .containsExactly(BidderBid.of(expectedBid, BidType.xNative, "USD")); + } + + private static BidRequest givenBidRequest(UnaryOperator... impCustomizers) { + final List imps = Arrays.stream(impCustomizers) + .map(MediasquareBidderTest::givenImp) + .toList(); + return BidRequest.builder().imp(imps).build(); + } + + private static Imp givenImp(UnaryOperator impCustomizer) { + return impCustomizer.apply(Imp.builder() + .id("imp_id") + .tagid("tag_id") + .bidfloor(BigDecimal.ONE) + .bidfloorcur("USD") + .banner(Banner.builder() + .format(singletonList(Format.builder().w(300).h(250).build())) + .build()) + .ext(givenImpExt("owner", "code"))) + .build(); + } + + private static ObjectNode givenImpExt(String owner, String code) { + return mapper.valueToTree(ExtPrebid.of(null, ExtImpMediasquare.of(owner, code))); + } + + private static MediasquareBid givenMediasquareBid(Integer mtype) { + final MediasquareBid.MediasquareBidBuilder builder = MediasquareBid.builder() + .id("bidId") + .bidId("impId") + .cpm(BigDecimal.valueOf(1.23)) + .ad("ad-markup") + .adomain(List.of("adomain.com")) + .width(300) + .height(250) + .creativeId("crid") + .burl("burl") + .dsa(mapper.createObjectNode().put("key", "value")) + .currency("USD"); + + if (mtype == 2) { + builder.video(mapper.createObjectNode()); + } else if (mtype == 4) { + builder.nativeResponse(mapper.createObjectNode()); + } + return builder.build(); + } + + private static BidderCall givenHttpCall(String body) { + return BidderCall.succeededHttp( + HttpRequest.builder().build(), + HttpResponse.of(200, null, body), + null); + } +} diff --git a/src/test/java/org/prebid/server/it/MediasquareTest.java b/src/test/java/org/prebid/server/it/MediasquareTest.java new file mode 100644 index 00000000000..f246637921d --- /dev/null +++ b/src/test/java/org/prebid/server/it/MediasquareTest.java @@ -0,0 +1,37 @@ +package org.prebid.server.it; + +import io.restassured.response.Response; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.prebid.server.model.Endpoint; + +import java.io.IOException; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; + +public class MediasquareTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromMediasquare() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/mediasquare-exchange")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/mediasquare/test-mediasquare-bid-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/mediasquare/test-mediasquare-bid-response.json")))); + + // when + final Response response = responseFor( + "openrtb2/mediasquare/test-auction-mediasquare-request.json", + Endpoint.openrtb2_auction); + + // then + assertJsonEquals( + "openrtb2/mediasquare/test-auction-mediasquare-response.json", + response, + List.of("mediasquare")); + } + +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/mediasquare/test-auction-mediasquare-request.json b/src/test/resources/org/prebid/server/it/openrtb2/mediasquare/test-auction-mediasquare-request.json new file mode 100644 index 00000000000..100db7e81d2 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/mediasquare/test-auction-mediasquare-request.json @@ -0,0 +1,91 @@ +{ + "id": "70e5672c-515b-406e-967c-fcc2b04de04f", + "imp": [ + { + "id": "2c35e25e-e7d3-41bf-b810-06a449f456b9", + "bidfloor": 1, + "bidfloorcur": "USD", + "banner": { + "w": 970, + "h": 250 + }, + "ext": { + "mediasquare": { + "owner": "test", + "code": "publishername_atf_desktop_rg_pave" + } + } + }, + { + "id": "2059a3e6-71a3-43ea-8290-b5ceb13d35a8", + "bidfloor": 0.01, + "bidfloorcur": "USD", + "banner": { + "format": [ + { + "w": 300, + "h": 250 + }, + { + "w": 300, + "h": 600 + }, + { + "w": 120, + "h": 600 + } + ] + }, + "ext": { + "mediasquare": { + "owner": "test", + "code": "publishername_atf_desktop_rg_pave" + } + } + } + ], + "app": { + "content": { + }, + "domain": "debug.mediasquare.fr", + "id": "app-id-test", + "name": "debug.mediasquare.fr", + "publisher": { + "id": "MEDIA_SQUARE" + } + }, + "device": { + "devicetype": 1, + "geo": { + "country": "FRA", + "ipservice": 3 + }, + "ip": "92.154.6.0", + "language": "fr" + }, + "regs": { + "gdpr": 0, + "ext": { + "dsa": { + "dsarequired": 1, + "pubrender": 0, + "datatopub": 2, + "transparency": [ + { + "domain": "platform1domain.com", + "dsaparams": [ + 1 + ] + }, + { + "domain": "SSP2domain.com", + "dsaparams": [ + 1, + 2 + ] + } + ] + } + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/mediasquare/test-auction-mediasquare-response.json b/src/test/resources/org/prebid/server/it/openrtb2/mediasquare/test-auction-mediasquare-response.json new file mode 100644 index 00000000000..dcaacc45261 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/mediasquare/test-auction-mediasquare-response.json @@ -0,0 +1,87 @@ +{ + "id": "70e5672c-515b-406e-967c-fcc2b04de04f", + "seatbid": [ + { + "bid": [ + { + "id": "1", + "impid": "2c35e25e-e7d3-41bf-b810-06a449f456b9", + "price": 2, + "adm": "\u003c!-- This is an example --\u003e", + "adomain": [ + "mediasquare.fr" + ], + "crid": "msq_test|fakeCreative", + "w": 250, + "mtype": 1, + "exp": 300, + "ext": { + "dsa": { + "behalf": "Advertiser", + "paid": "Advertiser", + "transparency": { + "domain": "dsp1domain.com", + "dsaparams": [ + 1, + 2 + ] + }, + "adrender": 1 + }, + "prebid": { + "type": "banner", + "meta": { + "adaptercode": "mediasquare", + "advertiserDomains": [ + "mediasquare.fr" + ], + "mediaType": "banner" + } + }, + "origbidcpm": 2, + "origbidcur": "USD" + } + }, + { + "id": "2", + "impid": "2059a3e6-71a3-43ea-8290-b5ceb13d35a8", + "price": 0.02, + "adm": "\u003c!-- This is an example --\u003e", + "adomain": [ + "mediasquare.fr" + ], + "crid": "msq_test|fakeCreative", + "w": 250, + "mtype": 1, + "exp": 300, + "ext": { + "prebid": { + "type": "banner", + "meta": { + "adaptercode": "mediasquare", + "advertiserDomains": [ + "mediasquare.fr" + ], + "mediaType": "banner" + } + }, + "origbidcpm": 0.02, + "origbidcur": "USD" + } + } + ], + "seat": "mediasquare", + "group": 0 + } + ], + "cur": "USD", + "ext": { + "responsetimemillis": { + "mediasquare": "{{ mediasquare.response_time_ms }}" + }, + "prebid": { + "auctiontimestamp": 0 + }, + "tmaxrequest": 5000 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/mediasquare/test-mediasquare-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/mediasquare/test-mediasquare-bid-request.json new file mode 100644 index 00000000000..cf793100948 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/mediasquare/test-mediasquare-bid-request.json @@ -0,0 +1,110 @@ +{ + "codes": [ + { + "auctionid": "70e5672c-515b-406e-967c-fcc2b04de04f", + "bidid": "2c35e25e-e7d3-41bf-b810-06a449f456b9", + "code": "publishername_atf_desktop_rg_pave", + "owner": "test", + "mediatypes": { + "banner": { + "sizes": [ + [ + 970, + 250 + ] + ] + } + }, + "floor": { + "970x250": { + "floor": 1, + "currency": "USD" + } + } + }, + { + "auctionid": "70e5672c-515b-406e-967c-fcc2b04de04f", + "bidid": "2059a3e6-71a3-43ea-8290-b5ceb13d35a8", + "code": "publishername_atf_desktop_rg_pave", + "owner": "test", + "mediatypes": { + "banner": { + "sizes": [ + [ + 300, + 250 + ], + [ + 300, + 600 + ], + [ + 120, + 600 + ] + ] + } + }, + "floor": { + "120x600": { + "floor": 0.01, + "currency": "USD" + }, + "300x250": { + "floor": 0.01, + "currency": "USD" + }, + "300x600": { + "floor": 0.01, + "currency": "USD" + } + } + } + ], + "gdpr": { + "consent_required": false + }, + "dsa": { + "dsarequired": 1, + "pubrender": 0, + "datatopub": 2, + "transparency": [ + { + "domain": "platform1domain.com", + "dsaparams": [ + 1 + ] + }, + { + "domain": "SSP2domain.com", + "dsaparams": [ + 1, + 2 + ] + } + ] + }, + "tech": { + "device": { + "geo": { + "ipservice": 3, + "country": "FRA" + }, + "ip": "92.154.6.0", + "devicetype": 1, + "language": "fr", + "ua": "userAgent" + }, + "app": { + "id": "app-id-test", + "name": "debug.mediasquare.fr", + "domain": "debug.mediasquare.fr", + "publisher": { + "id": "MEDIA_SQUARE" + }, + "content": {} + } + }, + "type": "pbs", + "test": false +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/mediasquare/test-mediasquare-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/mediasquare/test-mediasquare-bid-response.json new file mode 100644 index 00000000000..c7140f29661 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/mediasquare/test-mediasquare-bid-response.json @@ -0,0 +1,54 @@ +{ + "responses": [ + { + "id": "1", + "ad": "\u003c!-- This is an example --\u003e", + "bid_id": "2c35e25e-e7d3-41bf-b810-06a449f456b9", + "bidder": "msq_test", + "code": "test/publishername_atf_desktop_rg_pave", + "cpm": 2, + "increment": 2, + "currency": "USD", + "creative_id": "msq_test|fakeCreative", + "width": 250, + "net_revenue": true, + "transaction_id": "2c35e25e-e7d3-41bf-b810-06a449f456b9", + "ttl": 20000, + "adomain": [ + "mediasquare.fr" + ], + "dsa": { + "behalf": "Advertiser", + "paid": "Advertiser", + "transparency": { + "domain": "dsp1domain.com", + "dsaparams": [ + 1, + 2 + ] + }, + "adrender": 1 + }, + "hasConsent": true + }, + { + "id": "2", + "ad": "\u003c!-- This is an example --\u003e", + "bid_id": "2059a3e6-71a3-43ea-8290-b5ceb13d35a8", + "bidder": "msq_test", + "code": "test/publishername_atf_desktop_rg_pave", + "cpm": 0.02, + "increment": 0.02, + "currency": "USD", + "creative_id": "msq_test|fakeCreative", + "width": 250, + "net_revenue": true, + "transaction_id": "2059a3e6-71a3-43ea-8290-b5ceb13d35a8", + "ttl": 20000, + "adomain": [ + "mediasquare.fr" + ], + "hasConsent": true + } + ] +} diff --git a/src/test/resources/org/prebid/server/it/test-application.properties b/src/test/resources/org/prebid/server/it/test-application.properties index 717b0c09445..34216849037 100644 --- a/src/test/resources/org/prebid/server/it/test-application.properties +++ b/src/test/resources/org/prebid/server/it/test-application.properties @@ -346,6 +346,8 @@ adapters.medianet.enabled=true adapters.medianet.endpoint=http://localhost:8090/medianet-exchange adapters.melozen.enabled=true adapters.melozen.endpoint=http://localhost:8090/melozen-exchange?pubId={{PublisherID}} +adapters.mediasquare.enabled=true +adapters.mediasquare.endpoint=http://localhost:8090/mediasquare-exchange adapters.metax.enabled=true adapters.metax.endpoint=http://localhost:8090/metax-exchange?publisher_id={{publisherId}}&adunit={{adUnit}} adapters.mgid.enabled=true