diff --git a/src/main/java/org/prebid/server/bidder/adtarget/AdtargetBidder.java b/src/main/java/org/prebid/server/bidder/adtarget/AdtargetBidder.java new file mode 100644 index 00000000000..427534a965e --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/adtarget/AdtargetBidder.java @@ -0,0 +1,207 @@ +package org.prebid.server.bidder.adtarget; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Video; +import com.iab.openrtb.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import io.vertx.core.MultiMap; +import io.vertx.core.http.HttpMethod; +import org.prebid.server.bidder.Bidder; +import org.prebid.server.bidder.adtarget.proto.AdtargetImpExt; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpCall; +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.adtarget.ExtImpAdtarget; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.util.HttpUtil; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Adtarget {@link Bidder} implementation. + */ +public class AdtargetBidder implements Bidder { + + private static final TypeReference> ADTARGET_EXT_TYPE_REFERENCE = + new TypeReference>() { + }; + + private final String endpointUrl; + private final JacksonMapper mapper; + + private final MultiMap headers; + + public AdtargetBidder(String endpointUrl, JacksonMapper mapper) { + this.endpointUrl = HttpUtil.validateUrl(Objects.requireNonNull(endpointUrl)); + this.mapper = Objects.requireNonNull(mapper); + headers = HttpUtil.headers(); + } + + /** + * Creates POST HTTP requests which should be made to fetch bids. + */ + @Override + public Result>> makeHttpRequests(BidRequest request) { + final Result>> sourceIdToImpsResult = mapSourceIdToImp(request.getImp()); + + final List> httpRequests = new ArrayList<>(); + for (Map.Entry> sourceIdToImps : sourceIdToImpsResult.getValue().entrySet()) { + final String url = String.format("%s?aid=%d", endpointUrl, sourceIdToImps.getKey()); + final BidRequest bidRequest = request.toBuilder().imp(sourceIdToImps.getValue()).build(); + final String bidRequestBody = mapper.encode(bidRequest); + httpRequests.add(HttpRequest.builder() + .method(HttpMethod.POST) + .uri(url) + .body(bidRequestBody) + .headers(headers) + .payload(bidRequest) + .build()); + } + return Result.of(httpRequests, sourceIdToImpsResult.getErrors()); + } + + /** + * Validates and creates {@link Map} where sourceId is used as key and {@link List} of {@link Imp} as value. + */ + private Result>> mapSourceIdToImp(List imps) { + final List errors = new ArrayList<>(); + final Map> sourceToImps = new HashMap<>(); + for (Imp imp : imps) { + final ExtImpAdtarget extImpAdtarget; + try { + validateImpression(imp); + extImpAdtarget = parseImpAdtarget(imp); + } catch (PreBidException ex) { + errors.add(BidderError.badInput(ex.getMessage())); + continue; + } + final Imp updatedImp = updateImp(imp, extImpAdtarget); + + final Integer sourceId = extImpAdtarget.getSourceId(); + sourceToImps.computeIfAbsent(sourceId, ignored -> new ArrayList<>()).add(updatedImp); + } + return Result.of(sourceToImps, errors); + } + + /** + * Extracts {@link ExtImpAdtarget} from imp.ext.bidder. + */ + private ExtImpAdtarget parseImpAdtarget(Imp imp) { + try { + return mapper.mapper().convertValue(imp.getExt(), ADTARGET_EXT_TYPE_REFERENCE).getBidder(); + } catch (IllegalArgumentException e) { + throw new PreBidException(String.format( + "ignoring imp id=%s, error while decoding impExt, err: %s", imp.getId(), e.getMessage())); + } + } + + /** + * Validates {@link Imp}s. Throws {@link PreBidException} in case of {@link Imp} is invalid. + */ + private void validateImpression(Imp imp) { + final String impId = imp.getId(); + if (imp.getBanner() == null && imp.getVideo() == null) { + throw new PreBidException(String.format( + "ignoring imp id=%s, Adtarget supports only Video and Banner", impId)); + } + + final ObjectNode impExt = imp.getExt(); + if (impExt == null || impExt.size() == 0) { + throw new PreBidException(String.format("ignoring imp id=%s, extImpBidder is empty", impId)); + } + } + + /** + * Updates {@link Imp} with bidfloor if it is present in imp.ext.bidder + */ + private Imp updateImp(Imp imp, ExtImpAdtarget extImpAdtarget) { + final AdtargetImpExt adtargetImpExt = AdtargetImpExt.of(extImpAdtarget); + final BigDecimal bidFloor = extImpAdtarget.getBidFloor(); + return imp.toBuilder() + .bidfloor(bidFloor != null && bidFloor.compareTo(BigDecimal.ZERO) > 0 ? bidFloor : imp.getBidfloor()) + .ext(mapper.mapper().valueToTree(adtargetImpExt)) + .build(); + } + + /** + * Converts response to {@link List} of {@link BidderBid}s with {@link List} of errors. + */ + @Override + public Result> makeBids(HttpCall httpCall, BidRequest bidRequest) { + try { + final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); + return extractBids(bidResponse, bidRequest.getImp()); + } catch (DecodeException e) { + return Result.emptyWithError(BidderError.badServerResponse(e.getMessage())); + } + } + + /** + * Extracts {@link Bid}s from response. + */ + private static Result> extractBids(BidResponse bidResponse, List imps) { + return bidResponse == null || bidResponse.getSeatbid() == null + ? Result.of(Collections.emptyList(), Collections.emptyList()) + : createBiddersBid(bidResponse, imps); + } + + /** + * Extracts {@link Bid}s from response and finds its type against matching {@link Imp}. In case matching {@link Imp} + * was not found, {@link Bid} is considered as not valid. + */ + private static Result> createBiddersBid(BidResponse bidResponse, List imps) { + + final Map idToImps = imps.stream().collect(Collectors.toMap(Imp::getId, Function.identity())); + final List bidderBids = new ArrayList<>(); + final List errors = new ArrayList<>(); + + bidResponse.getSeatbid().stream() + .filter(Objects::nonNull) + .map(SeatBid::getBid) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .forEach(bid -> addBidOrError(bid, idToImps, bidderBids, errors, bidResponse.getCur())); + + return Result.of(bidderBids, errors); + } + + /** + * Creates {@link BidderBid} from {@link Bid} if it has matching {@link Imp}, otherwise adds error to error list. + */ + private static void addBidOrError(Bid bid, Map idToImps, List bidderBids, + List errors, String currency) { + final String bidImpId = bid.getImpid(); + + if (idToImps.containsKey(bidImpId)) { + final Video video = idToImps.get(bidImpId).getVideo(); + bidderBids.add(BidderBid.of(bid, video != null ? BidType.video : BidType.banner, currency)); + } else { + errors.add(BidderError.badServerResponse(String.format( + "ignoring bid id=%s, request doesn't contain any impression with id=%s", bid.getId(), bidImpId))); + } + } + + @Override + public Map extractTargeting(ObjectNode ext) { + return Collections.emptyMap(); + } +} diff --git a/src/main/java/org/prebid/server/bidder/adtarget/proto/AdtargetImpExt.java b/src/main/java/org/prebid/server/bidder/adtarget/proto/AdtargetImpExt.java new file mode 100644 index 00000000000..4fc2177fbf0 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/adtarget/proto/AdtargetImpExt.java @@ -0,0 +1,14 @@ +package org.prebid.server.bidder.adtarget.proto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Value; +import org.prebid.server.proto.openrtb.ext.request.adtarget.ExtImpAdtarget; + +@AllArgsConstructor(staticName = "of") +@Value +public class AdtargetImpExt { + + @JsonProperty("adtarget") + ExtImpAdtarget extImpAdtarget; +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/adtarget/ExtImpAdtarget.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/adtarget/ExtImpAdtarget.java new file mode 100644 index 00000000000..04105dd73d8 --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/adtarget/ExtImpAdtarget.java @@ -0,0 +1,39 @@ +package org.prebid.server.proto.openrtb.ext.request.adtarget; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Value; + +import java.math.BigDecimal; + +/** + * Defines the contract for bidrequest.imp[i].ext.adtarget + */ +@AllArgsConstructor(staticName = "of") +@Value +public class ExtImpAdtarget { + + /** + * Defines the contract for bidrequest.imp[i].ext.adtarget.aid + */ + @JsonProperty("aid") + Integer sourceId; + + /** + * Defines the contract for bidrequest.imp[i].ext.adtarget.placementId + */ + @JsonProperty("placementId") + Integer placementId; + + /** + * Defines the contract for bidrequest.imp[i].ext.adtarget.siteId + */ + @JsonProperty("siteId") + Integer siteId; + + /** + * Defines the contract for bidrequest.imp[i].ext.adtarget.bidFloor + */ + @JsonProperty("bidFloor") + BigDecimal bidFloor; +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/AdtargetConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/AdtargetConfiguration.java new file mode 100644 index 00000000000..21b2af0b168 --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/AdtargetConfiguration.java @@ -0,0 +1,53 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.adtarget.AdtargetBidder; +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.BidderInfoCreator; +import org.prebid.server.spring.config.bidder.util.UsersyncerCreator; +import org.prebid.server.spring.env.YamlPropertySourceFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +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 javax.validation.constraints.NotBlank; + +@Configuration +@PropertySource(value = "classpath:/bidder-config/adtarget.yaml", factory = YamlPropertySourceFactory.class) +public class AdtargetConfiguration { + + private static final String BIDDER_NAME = "adtarget"; + + @Value("${external-url}") + @NotBlank + private String externalUrl; + + @Autowired + private JacksonMapper mapper; + + @Autowired + @Qualifier("adtargetConfigurationProperties") + private BidderConfigurationProperties configProperties; + + @Bean("adtargetConfigurationProperties") + @ConfigurationProperties("adapters.adtarget") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps adtargetBidderDeps() { + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(configProperties) + .bidderInfo(BidderInfoCreator.create(configProperties)) + .usersyncerCreator(UsersyncerCreator.create(configProperties.getUsersync(), externalUrl)) + .bidderCreator(() -> new AdtargetBidder(configProperties.getEndpoint(), mapper)) + .assemble(); + } +} diff --git a/src/main/resources/bidder-config/adtarget.yaml b/src/main/resources/bidder-config/adtarget.yaml new file mode 100644 index 00000000000..1de48a67e50 --- /dev/null +++ b/src/main/resources/bidder-config/adtarget.yaml @@ -0,0 +1,25 @@ +adapters: + adtarget: + enabled: false + endpoint: http://ghb.console.adtarget.com.tr/pbs/ortb + pbs-enforces-gdpr: true + pbs-enforces-ccpa: true + modifying-vast-xml-allowed: true + deprecated-names: + aliases: + meta-info: + maintainer-email: kamil@adtarget.com.tr + app-media-types: + - banner + - video + site-media-types: + - banner + - video + supported-vendors: + vendor-id: 0 + usersync: + url: https://sync.console.adtarget.com.tr/csync?t=p&ep=0&gdpr={{gdpr}}&gdpr_consent={{gdpr_consent}}&us_privacy={{us_privacy}}&redir= + redirect-url: /setuid?bidder=adtarget&gdpr={{gdpr}}&gdpr_consent={{gdpr_consent}}&uid={uid} + cookie-family-name: adtarget + type: redirect + support-cors: false \ No newline at end of file diff --git a/src/main/resources/static/bidder-params/adtarget.json b/src/main/resources/static/bidder-params/adtarget.json new file mode 100644 index 00000000000..195bf2dd430 --- /dev/null +++ b/src/main/resources/static/bidder-params/adtarget.json @@ -0,0 +1,26 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Adtarget Adapter Params", + "description": "A schema which validates params accepted by the Adtarget adapter", + + "type": "object", + "properties": { + "placementId": { + "type": "integer", + "description": "An ID which identifies this placement of the impression" + }, + "siteId": { + "type": "integer", + "description": "An ID which identifies the site selling the impression" + }, + "aid": { + "type": "integer", + "description": "An ID which identifies the channel" + }, + "bidFloor": { + "type": "number", + "description": "BidFloor, US Dollars" + } + }, + "required": ["aid"] +} diff --git a/src/test/java/org/prebid/server/bidder/adtarget/AdtargetBidderTest.java b/src/test/java/org/prebid/server/bidder/adtarget/AdtargetBidderTest.java new file mode 100644 index 00000000000..e32fa29657b --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/adtarget/AdtargetBidderTest.java @@ -0,0 +1,418 @@ +package org.prebid.server.bidder.adtarget; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.iab.openrtb.request.Banner; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Imp; +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 com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import io.netty.handler.codec.http.HttpHeaderValues; +import io.vertx.core.http.HttpMethod; +import org.junit.Before; +import org.junit.Test; +import org.prebid.server.VertxTest; +import org.prebid.server.bidder.adtarget.proto.AdtargetImpExt; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpCall; +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.ExtUser; +import org.prebid.server.proto.openrtb.ext.request.adtarget.ExtImpAdtarget; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.util.HttpUtil; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +public class AdtargetBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "http://adtelligent.com"; + + private AdtargetBidder adtargetBidder; + + @Before + public void setUp() { + adtargetBidder = new AdtargetBidder(ENDPOINT_URL, jacksonMapper); + } + + @Test + public void makeHttpRequestsShouldReturnHttpRequestWithCorrectBodyHeadersAndMethod() + throws JsonProcessingException { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(Imp.builder() + .banner(Banner.builder().build()) + .ext(mapper.valueToTree( + ExtPrebid.of(null, ExtImpAdtarget.of(15, 1, 2, BigDecimal.valueOf(3))))).build())) + .user(User.builder() + .ext(ExtUser.builder().consent("consent").build()) + .build()) + .regs(Regs.of(0, ExtRegs.of(1, null))) + .build(); + + // when + final Result>> result = adtargetBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1).extracting(HttpRequest::getMethod).containsExactly(HttpMethod.POST); + assertThat(result.getValue()).extracting(HttpRequest::getUri).containsExactly("http://adtelligent.com?aid=15"); + assertThat(result.getValue()).flatExtracting(httpRequest -> httpRequest.getHeaders().entries()) + .extracting(Map.Entry::getKey, Map.Entry::getValue) + .containsOnly( + tuple(HttpUtil.CONTENT_TYPE_HEADER.toString(), HttpUtil.APPLICATION_JSON_CONTENT_TYPE), + tuple(HttpUtil.ACCEPT_HEADER.toString(), HttpHeaderValues.APPLICATION_JSON.toString())); + assertThat(result.getValue()).extracting(HttpRequest::getBody).containsExactly(mapper.writeValueAsString( + BidRequest.builder() + .imp(singletonList( + Imp.builder() + .banner(Banner.builder().build()) + .bidfloor(BigDecimal.valueOf(3)) + .ext(mapper.valueToTree(AdtargetImpExt.of( + ExtImpAdtarget.of(15, 1, 2, BigDecimal.valueOf(3))))) + .build())) + .user(User.builder() + .ext(ExtUser.builder().consent("consent").build()) + .build()) + .regs(Regs.of(0, ExtRegs.of(1, null))) + .build())); + } + + @Test + public void makeHttpRequestShouldReturnErrorMessageWhenMediaTypeWasNotDefined() { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(Imp.builder() + .id("impId") + .ext(mapper.valueToTree( + ExtPrebid.of(null, ExtImpAdtarget.of(15, 1, 2, BigDecimal.valueOf(3))))).build())) + .build(); + + // when + final Result>> result = adtargetBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .containsExactly(BidderError.badInput( + "ignoring imp id=impId, Adtarget supports only Video and Banner")); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeHttpRequestShouldReturnErrorMessageWhenImpExtIsEmpty() { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(Imp.builder() + .id("impId") + .banner(Banner.builder().build()) + .ext(mapper.valueToTree(ExtPrebid.of(null, null))).build())) + .build(); + // when + final Result>> result = adtargetBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .containsExactly(BidderError.badInput("ignoring imp id=impId, extImpBidder is empty")); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeHttpRequestShouldReturnHttpRequestWithErrorMessage() { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(asList(Imp.builder() + .id("impId") + .banner(Banner.builder().build()) + .ext(mapper.valueToTree(ExtPrebid.of(null, null))).build(), + Imp.builder() + .id("impId2") + .banner(Banner.builder().build()) + .ext(mapper.valueToTree( + ExtPrebid.of(null, ExtImpAdtarget.of(15, 1, 2, BigDecimal.valueOf(3))))) + .build())) + .build(); + + // when + final Result>> result = adtargetBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .containsExactly(BidderError.badInput("ignoring imp id=impId, extImpBidder is empty")); + assertThat(result.getValue()).extracting(HttpRequest::getUri).containsExactly("http://adtelligent.com?aid=15"); + assertThat(result.getValue()).hasSize(1) + .extracting(httpRequest -> mapper.readValue(httpRequest.getBody(), BidRequest.class)) + .flatExtracting(BidRequest::getImp).hasSize(1) + .extracting(Imp::getId).containsExactly("impId2"); + } + + @Test + public void makeHttpRequestShouldReturnWithBidFloorPopulatedFromImpWhenIsMissedInImpExt() { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(Imp.builder() + .banner(Banner.builder().build()) + .bidfloor(BigDecimal.valueOf(16)) + .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpAdtarget.of(15, 1, 2, null)))) + .build())) + .build(); + + // when + final Result>> result = adtargetBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(httpRequest -> mapper.readValue(httpRequest.getBody(), BidRequest.class)) + .flatExtracting(BidRequest::getImp).hasSize(1) + .extracting(Imp::getBidfloor).containsExactly(BigDecimal.valueOf(16)); + } + + @Test + public void makeHttpRequestShouldReturnTwoHttpRequestsWhenTwoImpsHasDifferentSourceId() { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(asList(Imp.builder() + .banner(Banner.builder().build()) + .ext(mapper.valueToTree( + ExtPrebid.of(null, ExtImpAdtarget.of(15, 1, 2, BigDecimal.valueOf(3))))) + .build(), + Imp.builder() + .banner(Banner.builder().build()) + .ext(mapper.valueToTree( + ExtPrebid.of(null, ExtImpAdtarget.of(16, 1, 2, BigDecimal.valueOf(3))))) + .build())) + .build(); + + // when + final Result>> result = adtargetBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(2); + } + + @Test + public void makeHttpRequestShouldReturnOneHttpRequestForTowImpsWhenImpsHasSameSourceId() { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(asList(Imp.builder() + .banner(Banner.builder().build()) + .ext(mapper.valueToTree( + ExtPrebid.of(null, ExtImpAdtarget.of(15, 1, 2, BigDecimal.valueOf(3))))) + .build(), + Imp.builder() + .banner(Banner.builder().build()) + .ext(mapper.valueToTree( + ExtPrebid.of(null, ExtImpAdtarget.of(15, 1, 2, BigDecimal.valueOf(3))))) + .build())) + .build(); + + // when + final Result>> result = adtargetBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1); + } + + @Test + public void makeBidsShouldReturnBidWithoutErrors() throws JsonProcessingException { + // given + final String response = mapper.writeValueAsString(BidResponse.builder() + .cur("EUR") + .seatbid(singletonList(SeatBid.builder() + .bid(singletonList(Bid.builder().impid("impId").build())) + .build())) + .build()); + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(Imp.builder().id("impId").build())) + .build(); + + final HttpCall httpCall = givenHttpCall(response); + + // when + final Result> result = adtargetBidder.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .containsExactly(BidderBid.of(Bid.builder().impid("impId").build(), BidType.banner, "EUR")); + } + + @Test + public void makeBidsShouldReturnErrorMessageWhenMatchingToBidImpWasNotFound() throws JsonProcessingException { + // given + final String response = mapper.writeValueAsString(BidResponse.builder() + .seatbid(singletonList(SeatBid.builder() + .bid(singletonList(Bid.builder().id("bidId").impid("invalidId").build())) + .build())) + .build()); + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(Imp.builder().id("impId").build())) + .build(); + + final HttpCall httpCall = givenHttpCall(response); + + // when + final Result> result = adtargetBidder.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .containsExactly(BidderError.badServerResponse( + "ignoring bid id=bidId, request doesn't contain any impression with id=invalidId")); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnBidWithErrorMessage() throws JsonProcessingException { + // given + final String response = mapper.writeValueAsString(BidResponse.builder() + .seatbid(singletonList(SeatBid.builder() + .bid(asList(Bid.builder().id("bidId1").impid("invalidId").build(), + Bid.builder().id("bidId2").impid("impId").build())) + .build())) + .build()); + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(Imp.builder().id("impId").build())) + .build(); + + final HttpCall httpCall = givenHttpCall(response); + + // when + final Result> result = adtargetBidder.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .containsExactly(BidderError.badServerResponse( + "ignoring bid id=bidId1, request doesn't contain any impression with id=invalidId")); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getId).containsExactly("bidId2"); + } + + @Test + public void makeBidsShouldReturnBidsFromDifferentSeatBidsInResponse() throws JsonProcessingException { + // given + final String response = mapper.writeValueAsString(BidResponse.builder() + .seatbid(asList( + SeatBid.builder() + .bid(singletonList(Bid.builder().id("bidId1").impid("impId1").build())) + .build(), + SeatBid.builder() + .bid(singletonList(Bid.builder().id("bidId2").impid("impId2").build())) + .build())) + .build()); + final BidRequest bidRequest = BidRequest.builder() + .imp(asList(Imp.builder().id("impId1").build(), Imp.builder().id("impId2").build())) + .build(); + + final HttpCall httpCall = givenHttpCall(response); + + // when + final Result> result = adtargetBidder.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(2) + .extracting(BidderBid::getBid) + .extracting(Bid::getId).containsExactly("bidId1", "bidId2"); + } + + @Test + public void makeBidsShouldReturnBidderBidWithBannerBidTypeWhenMediaTypeInMatchedImpIsNotVideo() + throws JsonProcessingException { + // given + final String response = mapper.writeValueAsString(BidResponse.builder() + .seatbid(singletonList(SeatBid.builder() + .bid(singletonList(Bid.builder().impid("impId").build())) + .build())) + .build()); + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(Imp.builder().id("impId").build())) + .build(); + + final HttpCall httpCall = givenHttpCall(response); + + // when + final Result> result = adtargetBidder.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getType).containsExactly(BidType.banner); + } + + @Test + public void makeBidsShouldReturnBidderBidWithVideoBidTypeIfBannerAndVideoMediaTypesAreInMatchedImp() + throws JsonProcessingException { + // given + final String response = mapper.writeValueAsString(BidResponse.builder() + .seatbid(singletonList(SeatBid.builder() + .bid(singletonList(Bid.builder().impid("impId").build())) + .build())) + .build()); + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(Imp.builder().video(Video.builder().build()) + .banner(Banner.builder().build()).id("impId").build())) + .build(); + + final HttpCall httpCall = givenHttpCall(response); + + // when + final Result> result = adtargetBidder.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getType).containsExactly(BidType.video); + } + + @Test + public void makeBidsShouldReturnEmptyBidderBidAndErrorListsIfSeatBidIsNotPresentInResponse() + throws JsonProcessingException { + // given + final String response = mapper.writeValueAsString(BidResponse.builder().build()); + final HttpCall httpCall = givenHttpCall(response); + + // when + final Result> result = adtargetBidder.makeBids(httpCall, BidRequest.builder().build()); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnEmptyBidderWithErrorWhenResponseCantBeParsed() { + // given + final HttpCall httpCall = givenHttpCall("{"); + + // when + final Result> result = adtargetBidder.makeBids(httpCall, BidRequest.builder().build()); + + // then + assertThat(result.getErrors()).hasSize(1) + .containsExactly(BidderError.badServerResponse( + "Failed to decode: Unexpected end-of-input: expected close marker for Object (start marker at" + + " [Source: (String)\"{\"; line: 1, column: 1])\n at [Source: (String)\"{\"; line: 1, " + + "column: 3]")); + } + + private static HttpCall givenHttpCall(String body) { + return HttpCall.success(null, HttpResponse.of(200, null, body), null); + } +} diff --git a/src/test/java/org/prebid/server/it/AdtargetTest.java b/src/test/java/org/prebid/server/it/AdtargetTest.java new file mode 100644 index 00000000000..16d0dbab34f --- /dev/null +++ b/src/test/java/org/prebid/server/it/AdtargetTest.java @@ -0,0 +1,58 @@ +package org.prebid.server.it; + +import io.restassured.response.Response; +import org.json.JSONException; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.skyscreamer.jsonassert.JSONAssert; +import org.skyscreamer.jsonassert.JSONCompareMode; +import org.springframework.test.context.junit4.SpringRunner; + +import java.io.IOException; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +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; +import static io.restassured.RestAssured.given; +import static java.util.Collections.singletonList; + +@RunWith(SpringRunner.class) +public class AdtargetTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromAdtarget() throws IOException, JSONException { + // given + // Adtarget bid response for imp 14 + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/adtarget-exchange")) + .withQueryParam("aid", equalTo("1000")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/adtarget/test-adtarget-bid-request-1.json"))) + .willReturn(aResponse().withBody( + jsonFrom("openrtb2/adtarget/test-adtarget-bid-response-1.json")))); + + // pre-bid cache + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/cache")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/adtarget/test-cache-adtarget-request.json"))) + .willReturn(aResponse().withBody( + jsonFrom("openrtb2/adtarget/test-cache-adtarget-response.json")))); + + // when + final Response response = given(SPEC) + .header("Referer", "http://www.example.com") + .header("X-Forwarded-For", "193.168.244.1") + .header("User-Agent", "userAgent") + .header("Origin", "http://www.example.com") + // this uids cookie value stands for {"uids":{"adtarget":"AD-UID"}} + .cookie("uids", "eyJ1aWRzIjp7ImFkdGFyZ2V0IjoiQUQtVUlEIn19") + .body(jsonFrom("openrtb2/adtarget/test-auction-adtarget-request.json")) + .post("/openrtb2/auction"); + + // then + final String expectedAuctionResponse = openrtbAuctionResponseFrom( + "openrtb2/adtarget/test-auction-adtarget-response.json", + response, singletonList("adtarget")); + + JSONAssert.assertEquals(expectedAuctionResponse, response.asString(), JSONCompareMode.NON_EXTENSIBLE); + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-adtarget-bid-request-1.json b/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-adtarget-bid-request-1.json new file mode 100644 index 00000000000..7356ff346b7 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-adtarget-bid-request-1.json @@ -0,0 +1,92 @@ +{ + "id": "tid", + "imp": [ + { + "id": "impId14", + "banner": { + "format": [ + { + "w": 300, + "h": 250 + } + ] + }, + "bidfloor": 20, + "ext": { + "adtarget": { + "aid": 1000, + "placementId": 10, + "siteId": 1234, + "bidFloor": 20 + } + } + } + ], + "site": { + "domain": "example.com", + "page": "http://www.example.com", + "publisher": { + "id": "publisherId" + }, + "ext": { + "amp": 0 + } + }, + "device": { + "ua": "userAgent", + "dnt": 2, + "ip": "193.168.244.1", + "pxratio": 4.2, + "language": "en", + "ifa": "ifaId" + }, + "user": { + "buyeruid": "AD-UID", + "ext": { + "consent": "consentValue", + "digitrust": { + "id": "id", + "keyv": 123, + "pref": 0 + } + } + }, + "at": 1, + "tmax": 5000, + "cur": [ + "USD" + ], + "source": { + "fd": 1, + "tid": "tid" + }, + "regs": { + "ext": { + "gdpr": 0 + } + }, + "ext": { + "prebid": { + "targeting": { + "pricegranularity": { + "precision": 2, + "ranges": [ + { + "max": 20, + "increment": 0.1 + } + ] + }, + "includewinners": true, + "includebidderkeys": true + }, + "cache": { + "bids": {}, + "vastxml": { + "ttlseconds": 120 + } + }, + "auctiontimestamp": 1000 + } + } +} \ No newline at end of file diff --git a/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-adtarget-bid-response-1.json b/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-adtarget-bid-response-1.json new file mode 100644 index 00000000000..f396137f590 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-adtarget-bid-response-1.json @@ -0,0 +1,20 @@ +{ + "id": "tid", + "seatbid": [ + { + "bid": [ + { + "id": "620160380", + "impid": "impId14", + "price": 8.43, + "adm": "adm14", + "crid": "crid14", + "w": 300, + "h": 250 + } + ], + "seat": "seatId14", + "group": 0 + } + ] +} \ No newline at end of file diff --git a/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-auction-adtarget-request.json b/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-auction-adtarget-request.json new file mode 100644 index 00000000000..b3f96954db2 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-auction-adtarget-request.json @@ -0,0 +1,81 @@ +{ + "id": "tid", + "imp": [ + { + "id": "impId14", + "banner": { + "format": [ + { + "w": 300, + "h": 250 + } + ] + }, + "ext": { + "adtarget": { + "aid": 1000, + "siteId": 1234, + "bidFloor": 20, + "placementId": 10 + } + } + } + ], + "device": { + "pxratio": 4.2, + "dnt": 2, + "language": "en", + "ifa": "ifaId" + }, + "site": { + "publisher": { + "id": "publisherId" + } + }, + "at": 1, + "tmax": 5000, + "cur": [ + "USD" + ], + "source": { + "fd": 1, + "tid": "tid" + }, + "ext": { + "prebid": { + "targeting": { + "pricegranularity": { + "precision": 2, + "ranges": [ + { + "max": 20, + "increment": 0.1 + } + ] + } + }, + "cache": { + "bids": {}, + "vastxml": { + "ttlseconds": 120 + } + }, + "auctiontimestamp": 1000 + } + }, + "user": { + "ext": { + "consent": "consentValue", + "digitrust": { + "id": "id", + "keyv": 123, + "pref": 0 + } + } + }, + "regs": { + "ext": { + "gdpr": 0 + } + } +} \ No newline at end of file diff --git a/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-auction-adtarget-response.json b/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-auction-adtarget-response.json new file mode 100644 index 00000000000..c6f9798b729 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-auction-adtarget-response.json @@ -0,0 +1,56 @@ +{ + "id": "tid", + "seatbid": [ + { + "bid": [ + { + "id": "620160380", + "impid": "impId14", + "price": 8.43, + "adm": "adm14", + "crid": "crid14", + "w": 300, + "h": 250, + "ext": { + "prebid": { + "type": "banner", + "targeting": { + "hb_pb": "8.40", + "hb_bidder_adtarget": "adtarget", + "hb_size_adtarget": "300x250", + "hb_size": "300x250", + "hb_pb_adtarget": "8.40", + "hb_bidder": "adtarget", + "hb_cache_id": "f5c5f34c-ad41-4894-b42b-dd5c86978a4a", + "hb_cache_id_adtarget": "f5c5f34c-ad41-4894-b42b-dd5c86978a4a", + "hb_cache_host": "{{ cache.host }}", + "hb_cache_host_adtarget": "{{ cache.host }}", + "hb_cache_path": "{{ cache.path }}", + "hb_cache_path_adtarget": "{{ cache.path }}" + }, + "cache": { + "bids": { + "url": "{{ cache.resource_url }}f5c5f34c-ad41-4894-b42b-dd5c86978a4a", + "cacheId": "f5c5f34c-ad41-4894-b42b-dd5c86978a4a" + } + } + } + } + } + ], + "seat": "adtarget", + "group": 0 + } + ], + "cur": "USD", + "ext": { + "responsetimemillis": { + "adtarget": "{{ adtarget.response_time_ms }}", + "cache": "{{ cache.response_time_ms }}" + }, + "prebid": { + "auctiontimestamp": 1000 + }, + "tmaxrequest": 5000 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-cache-adtarget-request.json b/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-cache-adtarget-request.json new file mode 100644 index 00000000000..25d582fd85c --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-cache-adtarget-request.json @@ -0,0 +1,16 @@ +{ + "puts": [ + { + "type": "json", + "value": { + "id": "620160380", + "impid": "impId14", + "price": 8.43, + "adm": "adm14", + "crid": "crid14", + "w": 300, + "h": 250 + } + } + ] +} \ No newline at end of file diff --git a/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-cache-adtarget-response.json b/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-cache-adtarget-response.json new file mode 100644 index 00000000000..735d17ddc94 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/adtarget/test-cache-adtarget-response.json @@ -0,0 +1,7 @@ +{ + "responses": [ + { + "uuid": "f5c5f34c-ad41-4894-b42b-dd5c86978a4a" + } + ] +} \ No newline at end of file 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 aeb9330ea29..021e6f71a4d 100644 --- a/src/test/resources/org/prebid/server/it/test-application.properties +++ b/src/test/resources/org/prebid/server/it/test-application.properties @@ -34,6 +34,10 @@ adapters.adpone.enabled=true adapters.adpone.endpoint=http://localhost:8090/adpone-exchange adapters.adpone.pbs-enforces-gdpr=true adapters.adpone.usersync.url=//adpone-usersync +adapters.adtarget.enabled=true +adapters.adtarget.endpoint=http://localhost:8090/adtarget-exchange +adapters.adtarget.pbs-enforces-gdpr=true +adapters.adtarget.usersync.url=//adtarget-usersync adapters.adtelligent.enabled=true adapters.adtelligent.endpoint=http://localhost:8090/adtelligent-exchange adapters.adtelligent.pbs-enforces-gdpr=true