From 4b8d039d006aa21464be884c46b5a7a0907c8ee4 Mon Sep 17 00:00:00 2001 From: Serhii Nahornyi Date: Tue, 27 Apr 2021 13:48:32 +0300 Subject: [PATCH] Add Unicorn bidder (#1201) --- .../server/bidder/unicorn/UnicornBidder.java | 240 ++++++++++++ .../bidder/unicorn/model/UnicornImpExt.java | 16 + .../unicorn/model/UnicornImpExtContext.java | 12 + .../ext/request/unicorn/ExtImpUnicorn.java | 24 ++ .../config/bidder/UnicornConfiguration.java | 52 +++ src/main/resources/bidder-config/unicorn.yaml | 22 ++ .../static/bidder-params/unicorn.json | 25 ++ .../bidder/unicorn/UnicornBidderTest.java | 341 ++++++++++++++++++ .../org/prebid/server/it/UnicornTest.java | 58 +++ .../unicorn/test-auction-unicorn-request.json | 63 ++++ .../test-auction-unicorn-response.json | 59 +++ .../unicorn/test-cache-unicorn-request.json | 21 ++ .../unicorn/test-cache-unicorn-response.json | 7 + .../unicorn/test-unicorn-bid-request.json | 82 +++++ .../unicorn/test-unicorn-bid-response.json | 20 + .../server/it/test-application.properties | 4 + 16 files changed, 1046 insertions(+) create mode 100644 src/main/java/org/prebid/server/bidder/unicorn/UnicornBidder.java create mode 100644 src/main/java/org/prebid/server/bidder/unicorn/model/UnicornImpExt.java create mode 100644 src/main/java/org/prebid/server/bidder/unicorn/model/UnicornImpExtContext.java create mode 100644 src/main/java/org/prebid/server/proto/openrtb/ext/request/unicorn/ExtImpUnicorn.java create mode 100644 src/main/java/org/prebid/server/spring/config/bidder/UnicornConfiguration.java create mode 100644 src/main/resources/bidder-config/unicorn.yaml create mode 100644 src/main/resources/static/bidder-params/unicorn.json create mode 100644 src/test/java/org/prebid/server/bidder/unicorn/UnicornBidderTest.java create mode 100644 src/test/java/org/prebid/server/it/UnicornTest.java create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-auction-unicorn-request.json create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-auction-unicorn-response.json create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-cache-unicorn-request.json create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-cache-unicorn-response.json create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-unicorn-bid-request.json create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-unicorn-bid-response.json diff --git a/src/main/java/org/prebid/server/bidder/unicorn/UnicornBidder.java b/src/main/java/org/prebid/server/bidder/unicorn/UnicornBidder.java new file mode 100644 index 00000000000..adb56aab087 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/unicorn/UnicornBidder.java @@ -0,0 +1,240 @@ +package org.prebid.server.bidder.unicorn; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.IntNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Regs; +import com.iab.openrtb.request.Source; +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.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.bidder.Bidder; +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.bidder.unicorn.model.UnicornImpExt; +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.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtSource; +import org.prebid.server.proto.openrtb.ext.request.unicorn.ExtImpUnicorn; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.util.HttpUtil; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Unicorn {@link Bidder} implementation. + */ +public class UnicornBidder implements Bidder { + + private static final TypeReference> UNICORN_EXT_TYPE_REFERENCE = + new TypeReference>() { + }; + + private final String endpointUrl; + private final JacksonMapper mapper; + + public UnicornBidder(String endpointUrl, JacksonMapper mapper) { + this.endpointUrl = HttpUtil.validateUrl(Objects.requireNonNull(endpointUrl)); + this.mapper = Objects.requireNonNull(mapper); + } + + @Override + public Result>> makeHttpRequests(BidRequest request) { + final List requestImps = request.getImp(); + final List modifiedImps; + final Source source; + final Integer firstImpAccountId; + try { + validateRegs(request.getRegs()); + modifiedImps = modifyImps(requestImps); + source = updateSource(request.getSource()); + firstImpAccountId = parseImpExtBidder(requestImps.get(0)).getAccountId(); + } catch (PreBidException e) { + return Result.withError(BidderError.badInput(e.getMessage())); + } + final ExtRequest modifiedExtRequest = modifyExtRequest(request.getExt(), firstImpAccountId); + return Result.withValue(createRequest(request, modifiedImps, source, modifiedExtRequest)); + } + + private void validateRegs(Regs regs) { + if (regs != null) { + if (Objects.equals(regs.getCoppa(), 1)) { + throw new PreBidException("COPPA is not supported"); + } + final ExtRegs extRegs = regs.getExt(); + if (extRegs != null) { + if (Objects.equals(extRegs.getGdpr(), 1)) { + throw new PreBidException("GDPR is not supported"); + } + if (StringUtils.isNotEmpty(extRegs.getUsPrivacy())) { + throw new PreBidException("CCPA is not supported"); + } + } + } + } + + private List modifyImps(List imps) { + final List modifiedImps = new ArrayList<>(); + for (Imp imp : imps) { + final UnicornImpExt unicornImpExt = parseImpExt(imp); + final ExtImpUnicorn extImpBidder = unicornImpExt.getBidder(); + final Imp.ImpBuilder impBuilder = imp.toBuilder().secure(1); + final String placementId = extImpBidder.getPlacementId(); + + if (StringUtils.isEmpty(placementId)) { + final String resolvedPlacementId = getStoredRequestImpId(imp); + final UnicornImpExt updatedExt = unicornImpExt.toBuilder() + .bidder(extImpBidder.toBuilder().placementId(resolvedPlacementId).build()) + .build(); + impBuilder + .tagid(resolvedPlacementId) + .ext(mapper.mapper().convertValue(updatedExt, ObjectNode.class)); + } else { + impBuilder + .tagid(placementId) + .ext(mapper.mapper().convertValue(unicornImpExt, ObjectNode.class)); + } + + modifiedImps.add(impBuilder.build()); + + } + return modifiedImps; + } + + private UnicornImpExt parseImpExt(Imp imp) { + try { + return mapper.mapper().convertValue(imp.getExt(), UnicornImpExt.class); + } catch (IllegalArgumentException e) { + throw new PreBidException(String.format( + "Error while decoding ext of imp with id: %s, error: %s ", imp.getId(), e.getMessage())); + } + } + + private String getStoredRequestImpId(Imp imp) { + final JsonNode extPrebid = imp.getExt().get("prebid"); + final JsonNode storedRequestNode = isNotEmptyNode(extPrebid) ? extPrebid.get("storedrequest") : null; + final JsonNode storedRequestIdNode = isNotEmptyNode(storedRequestNode) ? storedRequestNode.get("id") : null; + final String storedRequestId = storedRequestIdNode != null && storedRequestIdNode.isTextual() + ? storedRequestIdNode.textValue() + : null; + if (StringUtils.isNotEmpty(storedRequestId)) { + return storedRequestId; + } else { + throw new PreBidException(String.format("stored request id not found in imp: %s", imp.getId())); + } + } + + private static boolean isNotEmptyNode(JsonNode node) { + return node != null && !node.isEmpty(); + } + + private Source updateSource(Source source) { + return source != null + ? source.toBuilder().ext(createExtSource()).build() + : Source.builder().ext(createExtSource()).build(); + } + + final ExtSource createExtSource() { + final ExtSource extSource = ExtSource.of(null); + extSource.addProperty("stype", new TextNode("prebid_server_uncn")); + extSource.addProperty("bidder", new TextNode("unicorn")); + + return extSource; + } + + private ExtImpUnicorn parseImpExtBidder(Imp imp) { + try { + return mapper.mapper().convertValue(imp.getExt(), UNICORN_EXT_TYPE_REFERENCE).getBidder(); + } catch (IllegalArgumentException e) { + throw new PreBidException(e.getMessage()); + } + } + + private ExtRequest modifyExtRequest(ExtRequest extRequest, Integer accountId) { + final ExtRequest modifiedRequest = extRequest != null + ? ExtRequest.of(extRequest.getPrebid()) + : ExtRequest.of(null); + final int resolvedAccountId = accountId == null ? 0 : accountId; + modifiedRequest.addProperty("accountId", new IntNode(resolvedAccountId)); + + return modifiedRequest; + } + + private HttpRequest createRequest(BidRequest request, + List imps, Source source, + ExtRequest extRequest) { + final BidRequest outgoingRequest = request.toBuilder() + .imp(imps) + .source(source) + .ext(extRequest) + .build(); + + return HttpRequest.builder() + .method(HttpMethod.POST) + .uri(endpointUrl) + .headers(resolveHeaders(request.getDevice())) + .payload(outgoingRequest) + .body(mapper.encode(outgoingRequest)) + .build(); + } + + private static MultiMap resolveHeaders(Device device) { + final MultiMap headers = HttpUtil.headers(); + HttpUtil.addHeaderIfValueIsNotEmpty(headers, HttpUtil.X_OPENRTB_VERSION_HEADER, "2.5"); + + if (device != null) { + HttpUtil.addHeaderIfValueIsNotEmpty(headers, HttpUtil.USER_AGENT_HEADER, device.getUa()); + HttpUtil.addHeaderIfValueIsNotEmpty(headers, HttpUtil.X_FORWARDED_FOR_HEADER, device.getIpv6()); + HttpUtil.addHeaderIfValueIsNotEmpty(headers, HttpUtil.X_FORWARDED_FOR_HEADER, device.getIp()); + } + + return headers; + } + + @Override + public final Result> makeBids(HttpCall httpCall, BidRequest bidRequest) { + try { + final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); + return Result.of(extractBids(bidResponse), Collections.emptyList()); + } catch (DecodeException | PreBidException e) { + return Result.withError(BidderError.badServerResponse(e.getMessage())); + } + } + + private static List extractBids(BidResponse bidResponse) { + if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) { + return Collections.emptyList(); + } + return bidsFromResponse(bidResponse); + } + + private static List bidsFromResponse(BidResponse bidResponse) { + return bidResponse.getSeatbid().stream() + .filter(Objects::nonNull) + .map(SeatBid::getBid) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .map(bid -> BidderBid.of(bid, BidType.banner, bidResponse.getCur())) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/org/prebid/server/bidder/unicorn/model/UnicornImpExt.java b/src/main/java/org/prebid/server/bidder/unicorn/model/UnicornImpExt.java new file mode 100644 index 00000000000..b4f534d1aad --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/unicorn/model/UnicornImpExt.java @@ -0,0 +1,16 @@ +package org.prebid.server.bidder.unicorn.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Value; +import org.prebid.server.proto.openrtb.ext.request.unicorn.ExtImpUnicorn; + +@AllArgsConstructor(staticName = "of") +@Value +@Builder(toBuilder = true) +public class UnicornImpExt { + + UnicornImpExtContext context; + + ExtImpUnicorn bidder; +} diff --git a/src/main/java/org/prebid/server/bidder/unicorn/model/UnicornImpExtContext.java b/src/main/java/org/prebid/server/bidder/unicorn/model/UnicornImpExtContext.java new file mode 100644 index 00000000000..05e6f9c36a6 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/unicorn/model/UnicornImpExtContext.java @@ -0,0 +1,12 @@ +package org.prebid.server.bidder.unicorn.model; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.AllArgsConstructor; +import lombok.Value; + +@AllArgsConstructor(staticName = "of") +@Value +public class UnicornImpExtContext { + + ObjectNode data; +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/unicorn/ExtImpUnicorn.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/unicorn/ExtImpUnicorn.java new file mode 100644 index 00000000000..83f12303fc4 --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/unicorn/ExtImpUnicorn.java @@ -0,0 +1,24 @@ +package org.prebid.server.proto.openrtb.ext.request.unicorn; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Value; + +@AllArgsConstructor(staticName = "of") +@Value +@Builder(toBuilder = true) +public class ExtImpUnicorn { + + @JsonProperty("placementId") + String placementId; + + @JsonProperty("publisherId") + Integer publisherId; + + @JsonProperty("mediaId") + String mediaId; + + @JsonProperty("accountId") + Integer accountId; +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/UnicornConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/UnicornConfiguration.java new file mode 100644 index 00000000000..fcd9754f1c2 --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/UnicornConfiguration.java @@ -0,0 +1,52 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.unicorn.UnicornBidder; +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.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/unicorn.yaml", factory = YamlPropertySourceFactory.class) +public class UnicornConfiguration { + + private static final String BIDDER_NAME = "unicorn"; + + @Value("${external-url}") + @NotBlank + private String externalUrl; + + @Autowired + private JacksonMapper mapper; + + @Autowired + @Qualifier("unicornConfigurationProperties") + private BidderConfigurationProperties configProperties; + + @Bean("unicornConfigurationProperties") + @ConfigurationProperties("adapters.unicorn") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps unicornBidderDeps() { + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(configProperties) + .usersyncerCreator(UsersyncerCreator.create(externalUrl)) + .bidderCreator(config -> new UnicornBidder(config.getEndpoint(), mapper)) + .assemble(); + } +} + diff --git a/src/main/resources/bidder-config/unicorn.yaml b/src/main/resources/bidder-config/unicorn.yaml new file mode 100644 index 00000000000..116ea7d0244 --- /dev/null +++ b/src/main/resources/bidder-config/unicorn.yaml @@ -0,0 +1,22 @@ +adapters: + unicorn: + enabled: false + endpoint: https://ds.uncn.jp/pb/0/bid.json + pbs-enforces-gdpr: true + pbs-enforces-ccpa: true + modifying-vast-xml-allowed: true + deprecated-names: + aliases: {} + meta-info: + maintainer-email: prebid@unicorn.inc + app-media-types: + - banner + site-media-types: + supported-vendors: + vendor-id: 0 + usersync: + url: + redirect-url: + cookie-family-name: unicorn + type: iframe + support-cors: false diff --git a/src/main/resources/static/bidder-params/unicorn.json b/src/main/resources/static/bidder-params/unicorn.json new file mode 100644 index 00000000000..90ff919e4fa --- /dev/null +++ b/src/main/resources/static/bidder-params/unicorn.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "UNICORN Adapter Params", + "description": "A schema which validates params accepted by the UNICORN adapter", + "type": "object", + "properties": { + "placementId": { + "type": "string", + "description": "In Application, if placementId is empty, prebid server configuration id will be used as placementId." + }, + "publisherId": { + "type": "integer", + "description": "Account specific publisher id" + }, + "mediaId": { + "type": "string", + "description": "Publisher specific media id" + }, + "accountId": { + "type": "integer", + "description": "Account ID for charge request" + } + }, + "required" : ["mediaId", "accountId"] +} diff --git a/src/test/java/org/prebid/server/bidder/unicorn/UnicornBidderTest.java b/src/test/java/org/prebid/server/bidder/unicorn/UnicornBidderTest.java new file mode 100644 index 00000000000..0483d32c8de --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/unicorn/UnicornBidderTest.java @@ -0,0 +1,341 @@ +package org.prebid.server.bidder.unicorn; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.TextNode; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Regs; +import com.iab.openrtb.request.Source; +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 org.junit.Before; +import org.junit.Test; +import org.prebid.server.VertxTest; +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.ExtImpPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtRegs; +import org.prebid.server.proto.openrtb.ext.request.ExtSource; +import org.prebid.server.proto.openrtb.ext.request.ExtStoredRequest; +import org.prebid.server.proto.openrtb.ext.request.unicorn.ExtImpUnicorn; +import org.prebid.server.util.HttpUtil; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static java.util.Collections.singletonList; +import static java.util.function.Function.identity; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.tuple; +import static org.prebid.server.proto.openrtb.ext.response.BidType.banner; + +public class UnicornBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "https://127.0.0.1/test"; + + private UnicornBidder unicornBidder; + + @Before + public void setUp() { + unicornBidder = new UnicornBidder(ENDPOINT_URL, jacksonMapper); + } + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + assertThatIllegalArgumentException().isThrownBy(() -> new UnicornBidder("invalid_url", jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldCorrectlyAddHeaders() { + // given + final BidRequest bidRequest = givenBidRequest(bidRequestBuilder -> + bidRequestBuilder.device(Device.builder().ua("someUa").ip("someIp").build()), + identity()); + + // when + final Result>> result = unicornBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()) + .flatExtracting(res -> res.getHeaders().entries()) + .extracting(Map.Entry::getKey, Map.Entry::getValue) + .containsExactlyInAnyOrder( + tuple(HttpUtil.CONTENT_TYPE_HEADER.toString(), HttpUtil.APPLICATION_JSON_CONTENT_TYPE), + tuple(HttpUtil.ACCEPT_HEADER.toString(), HttpHeaderValues.APPLICATION_JSON.toString()), + tuple(HttpUtil.USER_AGENT_HEADER.toString(), "someUa"), + tuple(HttpUtil.X_FORWARDED_FOR_HEADER.toString(), "someIp"), + tuple(HttpUtil.X_OPENRTB_VERSION_HEADER.toString(), "2.5")); + } + + @Test + public void makeHttpRequestsShouldReturnErrorForNotValidImpExt() { + // given + final BidRequest bidRequest = givenBidRequest( + impBuilder -> impBuilder.ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode())))); + // when + final Result>> result = unicornBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors()) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()) + .startsWith("Error while decoding ext of imp with id: 123, error: Cannot deserialize"); + }); + } + + @Test + public void makeHttpRequestsShouldReturnErrorIfCoppaIsOne() { + // given + final BidRequest bidRequest = givenBidRequest( + bidRequestBuilder -> bidRequestBuilder.regs(Regs.of(1, null)), identity()); + // when + final Result>> result = unicornBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).containsExactly(BidderError.badInput("COPPA is not supported")); + } + + @Test + public void makeHttpRequestsShouldReturnErrorIfGdprIsOne() { + // given + final BidRequest bidRequest = givenBidRequest( + bidRequestBuilder -> bidRequestBuilder.regs(Regs.of(0, ExtRegs.of(1, null))), identity()); + // when + final Result>> result = unicornBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).containsExactly(BidderError.badInput("GDPR is not supported")); + } + + @Test + public void makeHttpRequestsShouldReturnErrorIfUsPrivacyIsPresent() { + // given + final BidRequest bidRequest = givenBidRequest( + bidRequestBuilder -> bidRequestBuilder.regs(Regs.of(0, ExtRegs.of(0, "privacy"))), identity()); + // when + final Result>> result = unicornBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).containsExactly(BidderError.badInput("CCPA is not supported")); + } + + @Test + public void makeHttpRequestsShouldNotModifyEndpointURL() { + // given + final BidRequest bidRequest = givenBidRequest(identity()); + + // when + final Result>> result = unicornBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1); + assertThat(result.getValue()) + .extracting(HttpRequest::getUri) + .containsExactly("https://127.0.0.1/test"); + } + + @Test + public void makeHttpRequestsShouldEnrichEveryImpWithSecureAndTagIdParams() { + // given + final BidRequest bidRequest = givenBidRequest(identity()); + + // when + final Result>> result = unicornBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getSecure, Imp::getTagid) + .containsExactly(tuple(1, "placementId")); + } + + @Test + public void makeHttpRequestsShouldSetTagIdAndUpdateBidderPlacementIdPropertyWithStoredRequestProperty() { + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> + impBuilder.ext(mapper.valueToTree(ExtPrebid.of(ExtImpPrebid.builder() + .storedrequest(ExtStoredRequest.of("storedRequestId")) + .build(), ExtImpUnicorn.of("", 123, "mediaId", 456))))); + + // when + final Result>> result = unicornBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getTagid) + .containsExactly("storedRequestId"); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getExt) + .extracting(node -> node.get("bidder")) + .extracting(bidder -> mapper.convertValue(bidder, ExtImpUnicorn.class)) + .extracting(ExtImpUnicorn::getPlacementId) + .containsExactly("storedRequestId"); + } + + @Test + public void makeHttpRequestsShouldSetAddAccountIdPropertyToRequestExt() { + // given + final BidRequest bidRequest = givenBidRequest(identity()); + + // when + final Result>> result = unicornBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getExt) + .extracting(requestExt -> requestExt.getProperty("accountId").intValue()) + .containsExactly(456); + } + + @Test + public void makeHttpRequestsShouldUpdateSourceValue() { + // given + final BidRequest bidRequest = givenBidRequest(bidRequestBuilder -> + bidRequestBuilder.source(Source.builder().tid("someTid").build()), + identity()); + + // when + final Result>> result = unicornBidder.makeHttpRequests(bidRequest); + + // then + final ExtSource extSource = ExtSource.of(null); + extSource.addProperty("stype", new TextNode("prebid_server_uncn")); + extSource.addProperty("bidder", new TextNode("unicorn")); + final Source expectedSource = Source.builder().tid("someTid").ext(extSource).build(); + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getSource) + .containsExactly(expectedSource); + } + + @Test + public void makeHttpRequestsShouldReturnErrorForNotFoundStoredRequestId() { + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> + impBuilder.ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpUnicorn.of("", 123, "mediaId", 456))))); + + // when + final Result>> result = unicornBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).containsExactly(BidderError.badInput("stored request id not found in imp: 123")); + } + + @Test + public void makeBidsShouldReturnErrorIfResponseBodyCouldNotBeParsed() { + // given + final HttpCall httpCall = givenHttpCall(null, "invalid"); + + // when + final Result> result = unicornBidder.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: Unrecognized token"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnEmptyListIfBidResponseIsNull() throws JsonProcessingException { + // given + final HttpCall httpCall = givenHttpCall(null, mapper.writeValueAsString(null)); + + // when + final Result> result = unicornBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnEmptyListIfBidResponseSeatBidIsNull() throws JsonProcessingException { + // given + final HttpCall httpCall = givenHttpCall(null, + mapper.writeValueAsString(BidResponse.builder().build())); + + // when + final Result> result = unicornBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnBannerBidByDefault() throws JsonProcessingException { + // given + final HttpCall httpCall = givenHttpCall( + givenBidRequest(identity()), + mapper.writeValueAsString( + givenBidResponse(identity()))); + + // when + final Result> result = unicornBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .containsOnly(BidderBid.of(Bid.builder().build(), banner, null)); + } + + private static BidRequest givenBidRequest( + Function bidRequestCustomizer, + Function impCustomizer) { + + return bidRequestCustomizer.apply(BidRequest.builder() + .imp(singletonList(givenImp(impCustomizer)))) + .build(); + } + + private static BidRequest givenBidRequest(Function impCustomizer) { + return givenBidRequest(identity(), impCustomizer); + } + + private static Imp givenImp(Function impCustomizer) { + return impCustomizer.apply(Imp.builder() + .id("123") + .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpUnicorn.of("placementId", 123, "mediaId", 456))))) + .build(); + } + + private static BidResponse givenBidResponse(Function bidCustomizer) { + return BidResponse.builder() + .seatbid(singletonList(SeatBid.builder().bid(singletonList(bidCustomizer.apply(Bid.builder()).build())) + .build())) + .build(); + } + + private static HttpCall givenHttpCall(BidRequest bidRequest, String body) { + return HttpCall.success( + HttpRequest.builder().payload(bidRequest).build(), + HttpResponse.of(200, null, body), + null); + } +} diff --git a/src/test/java/org/prebid/server/it/UnicornTest.java b/src/test/java/org/prebid/server/it/UnicornTest.java new file mode 100644 index 00000000000..8d0ec0c2646 --- /dev/null +++ b/src/test/java/org/prebid/server/it/UnicornTest.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 UnicornTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromUnicorn() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/unicorn-exchange")) + .withHeader("Accept", equalTo("application/json")) + .withHeader("Content-Type", equalTo("application/json;charset=UTF-8")) + .withHeader("User-Agent", equalTo("userAgent")) + .withHeader("X-Forwarded-For", equalTo("193.168.244.1")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/unicorn/test-unicorn-bid-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/unicorn/test-unicorn-bid-response.json")))); + + // pre-bid cache + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/cache")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/unicorn/test-cache-unicorn-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/unicorn/test-cache-unicorn-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":{"unicorn":"UC-UID"}} + .cookie("uids", "eyJ1aWRzIjp7InVuaWNvcm4iOiJVQy1VSUQifX0=") + .body(jsonFrom("openrtb2/unicorn/test-auction-unicorn-request.json")) + .post("/openrtb2/auction"); + + // then + final String expectedAuctionResponse = openrtbAuctionResponseFrom( + "openrtb2/unicorn/test-auction-unicorn-response.json", + response, singletonList("unicorn")); + + JSONAssert.assertEquals(expectedAuctionResponse, response.asString(), JSONCompareMode.NON_EXTENSIBLE); + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-auction-unicorn-request.json b/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-auction-unicorn-request.json new file mode 100644 index 00000000000..4ebebc60f5b --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-auction-unicorn-request.json @@ -0,0 +1,63 @@ +{ + "id": "tid", + "imp": [ + { + "id": "impId001", + "banner": { + "w": 300, + "h": 250 + }, + "ext": { + "unicorn": { + "placementId": "placementId", + "publisherId": 123, + "mediaId": "mediaTestId", + "accountId": 456 + } + } + } + ], + "device": { + "ua" : "userAgent", + "ifa": "ifaId", + "ip": "193.168.244.1" + }, + "site": { + "page": "awesomePage", + "publisher": { + "id": "publisherId" + } + }, + "source": { + "tid": "tidValue" + }, + "at": 1, + "tmax": 5000, + "cur": [ + "USD" + ], + "ext": { + "prebid": { + "targeting": { + "pricegranularity": { + "precision": 2, + "ranges": [ + { + "max": 20, + "increment": 0.1 + } + ] + } + }, + "cache": { + "bids": {} + }, + "auctiontimestamp": 1000 + } + }, + "regs": { + "ext": { + "gdpr": 0 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-auction-unicorn-response.json b/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-auction-unicorn-response.json new file mode 100644 index 00000000000..6d3c35163e0 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-auction-unicorn-response.json @@ -0,0 +1,59 @@ +{ + "id": "tid", + "seatbid": [ + { + "bid": [ + { + "id": "bid001", + "impid": "impId001", + "price": 3.33, + "adm": "adm001", + "adid": "adid001", + "cid": "cid001", + "crid": "crid001", + "w": 300, + "h": 250, + "ext": { + "prebid": { + "type": "banner", + "targeting": { + "hb_pb": "3.30", + "hb_size_unicorn": "300x250", + "hb_bidder_unicorn": "unicorn", + "hb_cache_path": "{{ cache.path }}", + "hb_size": "300x250", + "hb_cache_host_unicorn": "{{ cache.host }}", + "hb_cache_path_unicorn": "{{ cache.path }}", + "hb_cache_id_unicorn": "f0ab9105-cb21-4e59-b433-70f5ad6671cb", + "hb_bidder": "unicorn", + "hb_cache_id": "f0ab9105-cb21-4e59-b433-70f5ad6671cb", + "hb_pb_unicorn": "3.30", + "hb_cache_host": "{{ cache.host }}" + }, + "cache": { + "bids": { + "url": "{{ cache.resource_url }}f0ab9105-cb21-4e59-b433-70f5ad6671cb", + "cacheId": "f0ab9105-cb21-4e59-b433-70f5ad6671cb" + } + } + }, + "origbidcpm" : 3.33 + } + } + ], + "seat": "unicorn", + "group": 0 + } + ], + "cur": "USD", + "ext": { + "responsetimemillis": { + "unicorn": "{{ unicorn.response_time_ms }}", + "cache": "{{ cache.response_time_ms }}" + }, + "prebid": { + "auctiontimestamp": 1000 + }, + "tmaxrequest": 5000 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-cache-unicorn-request.json b/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-cache-unicorn-request.json new file mode 100644 index 00000000000..63d1e4fd66a --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-cache-unicorn-request.json @@ -0,0 +1,21 @@ +{ + "puts": [ + { + "type": "json", + "value": { + "id": "bid001", + "impid": "impId001", + "price": 3.33, + "adm": "adm001", + "adid": "adid001", + "cid": "cid001", + "crid": "crid001", + "w": 300, + "h": 250, + "ext": { + "origbidcpm" : 3.33 + } + } + } + ] +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-cache-unicorn-response.json b/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-cache-unicorn-response.json new file mode 100644 index 00000000000..93d0b8de2cd --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-cache-unicorn-response.json @@ -0,0 +1,7 @@ +{ + "responses": [ + { + "uuid": "f0ab9105-cb21-4e59-b433-70f5ad6671cb" + } + ] +} \ No newline at end of file diff --git a/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-unicorn-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-unicorn-bid-request.json new file mode 100644 index 00000000000..a5d9b9b0821 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-unicorn-bid-request.json @@ -0,0 +1,82 @@ +{ + "id": "tid", + "imp": [ + { + "id": "impId001", + "banner": { + "w": 300, + "h": 250 + }, + "tagid": "placementId", + "secure": 1, + "ext": { + "bidder": { + "placementId": "placementId", + "publisherId": 123, + "mediaId": "mediaTestId", + "accountId": 456 + } + } + } + ], + "site": { + "page": "awesomePage", + "publisher": { + "id": "publisherId" + }, + "ext": { + "amp": 0 + } + }, + "device": { + "ua": "userAgent", + "ip": "193.168.244.1", + "ifa": "ifaId" + }, + "user": { + "buyeruid": "UC-UID" + }, + "at": 1, + "tmax": 5000, + "cur": [ + "USD" + ], + "source": { + "tid": "tidValue", + "ext": { + "bidder": "unicorn", + "stype": "prebid_server_uncn" + } + }, + "regs": { + "ext": { + "gdpr": 0 + } + }, + "ext": { + "prebid": { + "targeting": { + "pricegranularity": { + "precision": 2, + "ranges": [ + { + "max": 20, + "increment": 0.1 + } + ] + }, + "includewinners": true, + "includebidderkeys": true + }, + "cache": { + "bids": {} + }, + "auctiontimestamp": 1000, + "channel": { + "name": "web" + } + }, + "accountId": 456 + } +} + diff --git a/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-unicorn-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-unicorn-bid-response.json new file mode 100644 index 00000000000..95a93284e04 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/unicorn/test-unicorn-bid-response.json @@ -0,0 +1,20 @@ +{ + "id": "tid", + "seatbid": [ + { + "bid": [ + { + "id": "bid001", + "impid": "impId001", + "price": 3.33, + "adid": "adid001", + "crid": "crid001", + "cid": "cid001", + "adm": "adm001", + "h": 250, + "w": 300 + } + ] + } + ] +} \ 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 cc34e4325f3..5702b974c6c 100644 --- a/src/test/resources/org/prebid/server/it/test-application.properties +++ b/src/test/resources/org/prebid/server/it/test-application.properties @@ -354,6 +354,10 @@ adapters.ucfunnel.enabled=true adapters.ucfunnel.endpoint=http://localhost:8090/ucfunnel-exchange adapters.ucfunnel.pbs-enforces-gdpr=true adapters.ucfunnel.usersync.url=//ucfunnel-usersync +adapters.unicorn.enabled=true +adapters.unicorn.endpoint=http://localhost:8090/unicorn-exchange +adapters.unicorn.pbs-enforces-gdpr=true +adapters.unicorn.usersync.url=//unicorn-usersync adapters.between.enabled=true adapters.between.endpoint=http://localhost:8090/between-exchange?host={{Host}}&pubId={{PublisherId}} adapters.between.pbs-enforces-gdpr=true