diff --git a/src/main/java/org/prebid/server/bidder/invibes/InvibesBidder.java b/src/main/java/org/prebid/server/bidder/invibes/InvibesBidder.java new file mode 100644 index 00000000000..0524ccf77c6 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/invibes/InvibesBidder.java @@ -0,0 +1,326 @@ +package org.prebid.server.bidder.invibes; + +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.Device; +import com.iab.openrtb.request.Format; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Regs; +import com.iab.openrtb.request.Site; +import com.iab.openrtb.request.User; +import io.netty.handler.codec.http.HttpResponseStatus; +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.invibes.model.InvibesBidParams; +import org.prebid.server.bidder.invibes.model.InvibesBidRequest; +import org.prebid.server.bidder.invibes.model.InvibesBidderResponse; +import org.prebid.server.bidder.invibes.model.InvibesInternalParams; +import org.prebid.server.bidder.invibes.model.InvibesPlacementProperty; +import org.prebid.server.bidder.invibes.model.InvibesTypedBid; +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.ExtRegs; +import org.prebid.server.proto.openrtb.ext.request.ExtUser; +import org.prebid.server.proto.openrtb.ext.request.invibes.ExtImpInvibes; +import org.prebid.server.proto.openrtb.ext.request.invibes.model.InvibesDebug; +import org.prebid.server.proto.openrtb.ext.response.BidType; +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.stream.Collectors; + +public class InvibesBidder implements Bidder { + + private static final TypeReference> INVIBES_EXT_TYPE_REFERENCE = + new TypeReference>() { + }; + private static final String INVIBES_BID_VERSION = "4"; + private static final String ADAPTER_VERSION = "prebid_1.0.0"; + private static final String URL_HOST_MACRO = "{{Host}}"; + + private final String endpointUrl; + private final JacksonMapper mapper; + + public InvibesBidder(String endpointUrl, JacksonMapper mapper) { + this.endpointUrl = HttpUtil.validateUrl(Objects.requireNonNull(endpointUrl)); + this.mapper = Objects.requireNonNull(mapper); + } + + @Override + public Result>> makeHttpRequests(BidRequest request) { + if (request.getSite() == null) { + return Result.emptyWithError(BidderError.badInput("Site not specified")); + } + + final List errors = new ArrayList<>(); + + final String consentString = resolveConsentString(request.getUser()); + final Boolean gdprApplies = resolveGDPRApplies(request.getRegs()); + + InvibesInternalParams invibesInternalParams = new InvibesInternalParams(); + invibesInternalParams.setBidParams(InvibesBidParams.builder() + .properties(new HashMap<>()) + .placementIds(new ArrayList<>()) + .bidVersion(INVIBES_BID_VERSION) + .build()); + + for (Imp imp : request.getImp()) { + final ExtImpInvibes extImpInvibes; + try { + extImpInvibes = parseImpExt(imp); + validateImp(imp); + } catch (PreBidException e) { + errors.add(BidderError.badInput(e.getMessage())); + continue; + } + updateInvibesInternalParams(invibesInternalParams, extImpInvibes, imp); + } + //TODO add AMP parameter to invibesInternalParams, after reqInfo will be implemented + + final List placementIds = invibesInternalParams.getBidParams().getPlacementIds(); + if (CollectionUtils.isEmpty(placementIds)) { + return Result.of(Collections.emptyList(), errors); + } + + invibesInternalParams.setGdpr(gdprApplies); + invibesInternalParams.setGdprConsent(consentString); + + try { + final HttpRequest httpRequest = makeRequest(invibesInternalParams, request); + return Result.of(Collections.singletonList(httpRequest), errors); + } catch (PreBidException e) { + return Result.emptyWithError(BidderError.badInput(e.getMessage())); + } + } + + private ExtImpInvibes parseImpExt(Imp imp) { + try { + return mapper.mapper().convertValue(imp.getExt(), INVIBES_EXT_TYPE_REFERENCE).getBidder(); + } catch (IllegalArgumentException e) { + throw new PreBidException( + String.format("Error parsing invibesExt parameters in impression with id: %s", imp.getId())); + } + } + + private void validateImp(Imp imp) { + if (imp.getBanner() == null) { + throw new PreBidException(String.format("Banner not specified in impression with id: %s", imp.getId())); + } + } + + private String resolveConsentString(User user) { + final ExtUser extUser = user != null ? user.getExt() : null; + return extUser != null ? extUser.getConsent() : ""; + } + + private Boolean resolveGDPRApplies(Regs regs) { + final ExtRegs extRegs = regs != null ? regs.getExt() : null; + final Integer gdpr = extRegs != null ? extRegs.getGdpr() : null; + + return gdpr == null || gdpr == 1; + } + + private void updateInvibesInternalParams(InvibesInternalParams invibesInternalParams, + ExtImpInvibes invibesExt, + Imp imp) { + final String impExtPlacementId = invibesExt.getPlacementId(); + final InvibesBidParams bidParams = invibesInternalParams.getBidParams(); + final List updatedPlacementIds = bidParams.getPlacementIds(); + + if (StringUtils.isNotBlank(impExtPlacementId)) { + updatedPlacementIds.add(impExtPlacementId.trim()); + } + final Banner banner = imp.getBanner(); + final List adFormats = resolveAdFormats(banner); + + bidParams.getProperties() + .put(impExtPlacementId, InvibesPlacementProperty.builder() + .impId(imp.getId()) + .formats(adFormats) + .build()); + + final InvibesBidParams updatedBidParams = bidParams.toBuilder() + .placementIds(updatedPlacementIds) + .build(); + + invibesInternalParams.setDomainId(invibesExt.getDomainId()); + invibesInternalParams.setBidParams(updatedBidParams); + + final InvibesDebug invibesDebug = invibesExt.getDebug(); + final String invibesDebugTestBvid = invibesDebug != null ? invibesDebug.getTestBvid() : null; + if (StringUtils.isNotBlank(invibesDebugTestBvid)) { + invibesInternalParams.setTestBvid(invibesDebugTestBvid); + } + + if (invibesDebug != null) { + invibesInternalParams.setTestLog(invibesDebug.getTestLog()); + } + } + + private List resolveAdFormats(Banner currentBanner) { + if (currentBanner.getFormat() != null) { + return currentBanner.getFormat(); + } else { + final Integer formatW = currentBanner.getW(); + final Integer formatH = currentBanner.getH(); + return formatW != null && formatH != null + ? Collections.singletonList(Format.builder().w(formatW).h(formatH).build()) + : Collections.emptyList(); + } + } + + private HttpRequest makeRequest(InvibesInternalParams invibesParams, + BidRequest request) { + final String host = resolveHost(invibesParams.getDomainId()); + final String url = endpointUrl.replace(URL_HOST_MACRO, host); + final InvibesBidRequest parameter = resolveParameter(invibesParams, request); + + final Device device = request.getDevice(); + final Site site = request.getSite(); + final MultiMap headers = resolveHeaders(device, site); + + final String body = mapper.encode(parameter); + + return HttpRequest.builder() + .method(HttpMethod.POST) + .uri(url) + .headers(headers) + .payload(parameter) + .body(body) + .build(); + } + + private InvibesBidRequest resolveParameter(InvibesInternalParams invibesParams, BidRequest request) { + final User user = request.getUser(); + final String buyeruid = user != null ? user.getBuyeruid() : null; + final String lid = StringUtils.isNotBlank(buyeruid) ? buyeruid : ""; + + return createRequest(invibesParams, lid, request.getDevice(), request.getSite()); + } + + private InvibesBidRequest createRequest(InvibesInternalParams invibesParams, String lid, + Device device, Site site) { + final String testBvid = invibesParams.getTestBvid(); + final Boolean testLog = invibesParams.getTestLog(); + + return InvibesBidRequest.builder() + .isTestBid(StringUtils.isNotBlank(testBvid)) + .bidParamsJson(mapper.encode(invibesParams.getBidParams())) + .location(site.getPage()) + .lid(lid) + .kw(site.getKeywords()) + .isAmp(invibesParams.getIsAmp()) + .width(resolveWidth(device)) + .height(resolveHeight(device)) + .gdprConsent(invibesParams.getGdprConsent()) + .gdpr(invibesParams.getGdpr()) + .bvid(testBvid) + .invibBVLog(testLog) + .videoAdDebug(testLog) + .build(); + } + + private static String resolveHeight(Device device) { + final Integer height = device != null ? device.getH() : null; + + return height != null && height > 0 ? height.toString() : null; + } + + private static String resolveWidth(Device device) { + final Integer width = device != null ? device.getW() : null; + + return width != null && width > 0 ? width.toString() : null; + } + + private static String resolveHost(Integer domainId) { + if (domainId == null) { + return "bid.videostep.com"; + } else if (domainId >= 1002) { + return String.format("bid%s.videostep.com", domainId - 1000); + } else if (domainId == 1) { + return "adweb.videostepstage.com"; + } else if (domainId == 2) { + return "adweb.invibesstage.com"; + } else { + return "bid.videostep.com"; + } + } + + private static MultiMap resolveHeaders(Device device, Site site) { + final MultiMap headers = HttpUtil.headers(); + if (device != null) { + addHeader(headers, "X-Forwarded-For", device.getIp()); + addHeader(headers, "X-Forwarded-For", device.getIpv6()); + } + if (site != null) { + headers.add("Referer", site.getPage()); + addHeader(headers, "Referer", site.getPage()); + } + addHeader(headers, "Aver", ADAPTER_VERSION); + return headers; + } + + private static void addHeader(MultiMap headers, String header, String value) { + if (StringUtils.isNotBlank(value)) { + headers.add(header, value); + } + } + + @Override + public final Result> makeBids(HttpCall httpCall, BidRequest bidRequest) { + final int statusCode = httpCall.getResponse().getStatusCode(); + if (statusCode == HttpResponseStatus.NO_CONTENT.code()) { + return Result.of(Collections.emptyList(), Collections.emptyList()); + } + + try { + final InvibesBidderResponse bidResponse = + mapper.decodeValue(httpCall.getResponse().getBody(), InvibesBidderResponse.class); + if (bidResponse != null && StringUtils.isNotBlank(bidResponse.getError())) { + return Result.emptyWithError( + BidderError.badServerResponse(String.format("Server error: %s.", bidResponse.getError()))); + } + return Result.of(extractBids(bidResponse), Collections.emptyList()); + } catch (DecodeException | PreBidException e) { + return Result.emptyWithError(BidderError.badServerResponse(e.getMessage())); + } + } + + private List extractBids(InvibesBidderResponse bidResponse) { + return bidResponse == null || CollectionUtils.isEmpty(bidResponse.getTypedBids()) + ? Collections.emptyList() + : bidsFromResponse(bidResponse); + } + + private List bidsFromResponse(InvibesBidderResponse bidResponse) { + return bidResponse.getTypedBids().stream() + .filter(Objects::nonNull) + .map(InvibesTypedBid::getBid) + .filter(Objects::nonNull) + //TODO add DealPriority + .map(bid -> BidderBid.of(bid, BidType.banner, bidResponse.getCurrency())) + .collect(Collectors.toList()); + } + + @Override + public Map extractTargeting(ObjectNode ext) { + return Collections.emptyMap(); + } +} diff --git a/src/main/java/org/prebid/server/bidder/invibes/model/InvibesBidParams.java b/src/main/java/org/prebid/server/bidder/invibes/model/InvibesBidParams.java new file mode 100644 index 00000000000..1cb4437063b --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/invibes/model/InvibesBidParams.java @@ -0,0 +1,22 @@ +package org.prebid.server.bidder.invibes.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Value; + +import java.util.List; +import java.util.Map; + +@Builder(toBuilder = true) +@Value +public class InvibesBidParams { + + @JsonProperty("PlacementIds") + List placementIds; + + @JsonProperty("BidVersion") + String bidVersion; + + @JsonProperty("Properties") + Map properties; +} diff --git a/src/main/java/org/prebid/server/bidder/invibes/model/InvibesBidRequest.java b/src/main/java/org/prebid/server/bidder/invibes/model/InvibesBidRequest.java new file mode 100644 index 00000000000..b00a5129334 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/invibes/model/InvibesBidRequest.java @@ -0,0 +1,49 @@ +package org.prebid.server.bidder.invibes.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Value; + +@Builder(toBuilder = true) +@Value +public class InvibesBidRequest { + + @JsonProperty("BidParamsJson") + String bidParamsJson; + + @JsonProperty("Location") + String location; + + @JsonProperty("Lid") + String lid; + + @JsonProperty("IsTestBid") + Boolean isTestBid; + + @JsonProperty("Kw") + String kw; + + @JsonProperty("IsAMP") + Boolean isAmp; + + @JsonProperty("Width") + String width; + + @JsonProperty("Height") + String height; + + @JsonProperty("GdprConsent") + String gdprConsent; + + @JsonProperty("Gdpr") + Boolean gdpr; + + @JsonProperty("Bvid") + String bvid; + + @JsonProperty("InvibBVLog") + Boolean invibBVLog; + + @JsonProperty("VideoAdDebug") + Boolean videoAdDebug; +} diff --git a/src/main/java/org/prebid/server/bidder/invibes/model/InvibesBidderResponse.java b/src/main/java/org/prebid/server/bidder/invibes/model/InvibesBidderResponse.java new file mode 100644 index 00000000000..74c8f899701 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/invibes/model/InvibesBidderResponse.java @@ -0,0 +1,19 @@ +package org.prebid.server.bidder.invibes.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Value; + +import java.util.List; + +@Builder +@Value +public class InvibesBidderResponse { + + String currency; + + @JsonProperty("typedBids") + List typedBids; + + String error; +} diff --git a/src/main/java/org/prebid/server/bidder/invibes/model/InvibesInternalParams.java b/src/main/java/org/prebid/server/bidder/invibes/model/InvibesInternalParams.java new file mode 100644 index 00000000000..7c81a480e87 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/invibes/model/InvibesInternalParams.java @@ -0,0 +1,23 @@ +package org.prebid.server.bidder.invibes.model; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class InvibesInternalParams { + + InvibesBidParams bidParams; + + Integer domainId; + + Boolean isAmp; + + Boolean gdpr; + + String gdprConsent; + + String testBvid; + + Boolean testLog; +} diff --git a/src/main/java/org/prebid/server/bidder/invibes/model/InvibesPlacementProperty.java b/src/main/java/org/prebid/server/bidder/invibes/model/InvibesPlacementProperty.java new file mode 100644 index 00000000000..916d8079b16 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/invibes/model/InvibesPlacementProperty.java @@ -0,0 +1,19 @@ +package org.prebid.server.bidder.invibes.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.iab.openrtb.request.Format; +import lombok.Builder; +import lombok.Value; + +import java.util.List; + +@Builder +@Value +public class InvibesPlacementProperty { + + @JsonProperty("Formats") + List formats; + + @JsonProperty("ImpID") + String impId; +} diff --git a/src/main/java/org/prebid/server/bidder/invibes/model/InvibesTypedBid.java b/src/main/java/org/prebid/server/bidder/invibes/model/InvibesTypedBid.java new file mode 100644 index 00000000000..14dedddf3ae --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/invibes/model/InvibesTypedBid.java @@ -0,0 +1,17 @@ +package org.prebid.server.bidder.invibes.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Value; +import com.iab.openrtb.response.Bid; + +@Builder +@Value +public class InvibesTypedBid { + + Bid bid; + + @JsonProperty("dealPriority") + Integer dealPriority; + +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/invibes/ExtImpInvibes.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/invibes/ExtImpInvibes.java new file mode 100644 index 00000000000..1de204b5755 --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/invibes/ExtImpInvibes.java @@ -0,0 +1,20 @@ +package org.prebid.server.proto.openrtb.ext.request.invibes; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Value; +import org.prebid.server.proto.openrtb.ext.request.invibes.model.InvibesDebug; + +@AllArgsConstructor(staticName = "of") +@Value +public class ExtImpInvibes { + + @JsonProperty("placementId") + String placementId; + + @JsonProperty("domainId") + Integer domainId; + + @JsonProperty("debug") + InvibesDebug debug; +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/invibes/model/InvibesDebug.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/invibes/model/InvibesDebug.java new file mode 100644 index 00000000000..f3df563e03f --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/invibes/model/InvibesDebug.java @@ -0,0 +1,16 @@ +package org.prebid.server.proto.openrtb.ext.request.invibes.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Value; + +@AllArgsConstructor(staticName = "of") +@Value +public class InvibesDebug { + + @JsonProperty("testBvid") + String testBvid; + + @JsonProperty("testLog") + Boolean testLog; +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/InvibesConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/InvibesConfiguration.java new file mode 100644 index 00000000000..d1694a86dce --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/InvibesConfiguration.java @@ -0,0 +1,56 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.invibes.InvibesBidder; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.spring.config.bidder.model.BidderConfigurationProperties; +import org.prebid.server.spring.config.bidder.model.UsersyncConfigurationProperties; +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/invibes.yaml", factory = YamlPropertySourceFactory.class) +public class InvibesConfiguration { + + private static final String BIDDER_NAME = "invibes"; + + @Value("${external-url}") + @NotBlank + private String externalUrl; + + @Autowired + private JacksonMapper mapper; + + @Autowired + @Qualifier("invibesConfigurationProperties") + private BidderConfigurationProperties configProperties; + + @Bean("invibesConfigurationProperties") + @ConfigurationProperties("adapters.invibes") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps invibesBidderDeps() { + final UsersyncConfigurationProperties usersync = configProperties.getUsersync(); + + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(configProperties) + .bidderInfo(BidderInfoCreator.create(configProperties)) + .usersyncerCreator(UsersyncerCreator.create(usersync, externalUrl)) + .bidderCreator(() -> new InvibesBidder(configProperties.getEndpoint(), mapper)) + .assemble(); + } +} diff --git a/src/main/resources/bidder-config/invibes.yaml b/src/main/resources/bidder-config/invibes.yaml new file mode 100644 index 00000000000..677164c1a18 --- /dev/null +++ b/src/main/resources/bidder-config/invibes.yaml @@ -0,0 +1,21 @@ +adapters: + invibes: + enabled: false + endpoint: https://{{Host}}/bid/ServerBidAdContent + pbs-enforces-gdpr: true + pbs-enforces-ccpa: true + modifying-vast-xml-allowed: true + deprecated-names: + aliases: + meta-info: + maintainer-email: system_operations@invibes.com + site-media-types: + - banner + supported-vendors: + vendor-id: 436 + usersync: + url: + redirect-url: + cookie-family-name: invibes + type: redirect + support-cors: false diff --git a/src/main/resources/static/bidder-params/invibes.json b/src/main/resources/static/bidder-params/invibes.json new file mode 100644 index 00000000000..11d276f8d3e --- /dev/null +++ b/src/main/resources/static/bidder-params/invibes.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Invibes Adapter Params", + "description": "A schema which validates params accepted by the Invibes adapter", + "type": "object", + "properties": { + "placementId": { + "type": "string", + "minLength": 1, + "description": "An ID which identifies the site selling the impression" + }, + "domainId": { + "type": "integer", + "description": "Ad domain id" + }, + "debug": { + "type": "object", + "properties": { + "testBvid": { + "type": "string" + }, + "testLog": { + "type": "boolean" + } + }, + "description": "Parameters used for debugging porposes" + } + }, + "required": ["placementId"] +} diff --git a/src/test/java/org/prebid/server/bidder/invibes/InvibesBidderTest.java b/src/test/java/org/prebid/server/bidder/invibes/InvibesBidderTest.java new file mode 100644 index 00000000000..b29aa91e115 --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/invibes/InvibesBidderTest.java @@ -0,0 +1,341 @@ +package org.prebid.server.bidder.invibes; + +import com.fasterxml.jackson.core.JsonProcessingException; +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.Site; +import com.iab.openrtb.request.User; +import com.iab.openrtb.response.Bid; +import org.apache.commons.lang3.StringUtils; +import org.assertj.core.api.Assertions; +import org.junit.Before; +import org.junit.Test; +import org.prebid.server.VertxTest; +import org.prebid.server.bidder.invibes.model.InvibesBidParams; +import org.prebid.server.bidder.invibes.model.InvibesBidRequest; +import org.prebid.server.bidder.invibes.model.InvibesBidderResponse; +import org.prebid.server.bidder.invibes.model.InvibesPlacementProperty; +import org.prebid.server.bidder.invibes.model.InvibesTypedBid; +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.invibes.ExtImpInvibes; +import org.prebid.server.proto.openrtb.ext.request.invibes.model.InvibesDebug; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static java.util.Collections.emptyMap; +import static java.util.Collections.singletonList; +import static java.util.function.Function.identity; +import static org.assertj.core.api.Assertions.assertThat; +import static org.prebid.server.proto.openrtb.ext.response.BidType.banner; + +public class InvibesBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "https://{{Host}}/test"; + private static final int BANNER_H = 12; + private static final int BANNER_W = 15; + private static final String PAGE_URL = "www.test.com"; + private static final int SECOND_BANNER_H = 23; + private static final int SECOND_BANNER_W = 24; + private static final String FIRST_PLACEMENT_ID = "12"; + private static final String SECOND_PLACEMENT_ID = "15"; + private static final int DEVICE_W = 77; + private static final int DEVICE_H = 88; + private static final String BUYER_UID = "someUid"; + private static final String IMP_ID = "123"; + private static final String CURRENCY = "EUR"; + private static final String BID_VERSION = "4"; + + private InvibesBidder invibesBidder; + + @Before + public void setUp() { + invibesBidder = new InvibesBidder(ENDPOINT_URL, jacksonMapper); + } + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + Assertions.assertThatIllegalArgumentException().isThrownBy(() -> + new InvibesBidder("invalid_url", jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldCreateCorrectURL() { + // given + final BidRequest bidRequest = givenBidRequest( + identity(), + impBuilder -> impBuilder.banner(Banner.builder().h(BANNER_H).w(BANNER_W).build()), + ExtImpInvibes.of("12", 1003, InvibesDebug.of("test", true))); + + // when + final Result>> result = invibesBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1); + assertThat(result.getValue().get(0).getUri()).isEqualTo("https://bid3.videostep.com/test"); + } + + @Test + public void makeHttpRequestsShouldReturnErrorWhenImpExtCouldNotBeParsed() { + // given + final BidRequest bidRequest = BidRequest.builder() + .site(Site.builder().page(PAGE_URL).build()) + .imp(singletonList(Imp.builder() + .ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode()))) + .build())) + .build(); + + // when + final Result>> result = invibesBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()).startsWith("Error parsing invibesExt parameters"); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldReturnErrorWhenBannerIsNull() { + // given + final BidRequest bidRequest = givenBidRequest( + bidRequestBuilder -> bidRequestBuilder.site(Site.builder().page(PAGE_URL).build()), + impBuilder -> impBuilder.id(IMP_ID).banner(null)); + + // when + final Result>> result = + invibesBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .containsOnly( + BidderError.badInput(String.format("Banner not specified in impression with id: %s", IMP_ID))); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldReturnErrorWhenSiteIsNotPresent() { + // given + final BidRequest bidRequest = givenBidRequest(identity(), + impBuilder -> impBuilder.banner(Banner.builder().h(BANNER_H).w(BANNER_W).build())); + + // when + final Result>> result = invibesBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1) + .containsOnly(BidderError.badInput("Site not specified")); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void shouldCreateRequestWithDataFromEveryImpression() throws JsonProcessingException { + // given + final List imps = Arrays.asList(givenImp( + impBuilder -> impBuilder + .banner(Banner.builder().h(BANNER_H).w(BANNER_W).build()), + ExtImpInvibes.of(FIRST_PLACEMENT_ID, 15, InvibesDebug.of("test1", true))), + givenImp(impBuilder -> impBuilder + .banner(Banner.builder().h(SECOND_BANNER_H).w(SECOND_BANNER_W).build()), + ExtImpInvibes.of(SECOND_PLACEMENT_ID, 1001, InvibesDebug.of("test2", false)))); + final BidRequest bidRequest = BidRequest.builder() + .site(Site.builder().page(PAGE_URL).build()) + .imp(imps) + .build(); + + // when + final Result>> result = invibesBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + final Format firstExpectedFormat = Format.builder().w(BANNER_W).h(BANNER_H).build(); + final Format secondExpectedFormat = Format.builder().w(SECOND_BANNER_W).h(SECOND_BANNER_H).build(); + final Map bidProperties = new HashMap<>(); + bidProperties.put(FIRST_PLACEMENT_ID, InvibesPlacementProperty.builder() + .formats(Collections.singletonList(firstExpectedFormat)) + .build()); + bidProperties.put(SECOND_PLACEMENT_ID, InvibesPlacementProperty.builder() + .formats(Collections.singletonList(secondExpectedFormat)) + .build()); + + InvibesBidParams expectedBidParams = InvibesBidParams.builder() + .placementIds(Arrays.asList(FIRST_PLACEMENT_ID, SECOND_PLACEMENT_ID)) + .bidVersion(BID_VERSION) + .properties(bidProperties) + .build(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(invibesBidRequest -> + mapper.readValue(invibesBidRequest.getBidParamsJson(), InvibesBidParams.class)) + .containsOnly(expectedBidParams); + } + + @Test + public void makeHttpRequestsShouldCreateInvibesBidRequestWithCorrectParams() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + bidRequestBuilder -> bidRequestBuilder + .device(Device.builder().w(DEVICE_W).h(DEVICE_H).build()) + .user(User.builder().buyeruid(BUYER_UID).build()) + .site(Site.builder().page(PAGE_URL).build()), + impBuilder -> impBuilder.banner(Banner.builder().h(BANNER_H).w(BANNER_W).build())); + + // when + final Result>> result = invibesBidder.makeHttpRequests(bidRequest); + + // then + final Map properties = new HashMap<>(); + properties.put(FIRST_PLACEMENT_ID, InvibesPlacementProperty.builder() + .formats(Collections.singletonList(Format.builder().w(BANNER_W).h(BANNER_H).build())).build()); + + final InvibesBidParams invibesBidParams = InvibesBidParams.builder() + .placementIds(Collections.singletonList(FIRST_PLACEMENT_ID)) + .bidVersion(BID_VERSION) + .properties(properties) + .build(); + + final InvibesBidRequest expectedRequest = InvibesBidRequest.builder() + .bidParamsJson(mapper.writeValueAsString(invibesBidParams)) + .isTestBid(true) + .location(PAGE_URL) + .gdpr(true) + .gdprConsent(StringUtils.EMPTY) + .invibBVLog(true) + .videoAdDebug(true) + .lid(BUYER_UID) + .bvid("test") + .width(String.valueOf(DEVICE_W)) + .height(String.valueOf(DEVICE_H)) + .build(); + + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .containsOnly(expectedRequest); + } + + @Test + public void makeBidsShouldReturnErrorWhenResponseBodyCouldNotBeParsed() { + // given + final HttpCall httpCall = givenHttpCall(null, "invalid"); + + // when + final Result> result = invibesBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getMessage()).startsWith("Failed to decode: Unrecognized token"); + assertThat(result.getErrors().get(0).getType()).isEqualTo(BidderError.Type.bad_server_response); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnEmptyListWhenBidResponseIsNull() throws JsonProcessingException { + // given + final HttpCall httpCall = givenHttpCall(null, mapper.writeValueAsString(null)); + + // when + final Result> result = invibesBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnBannerBid() throws JsonProcessingException { + // given + final HttpCall httpCall = givenHttpCall( + InvibesBidRequest.builder().build(), + mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.impid(IMP_ID)))); + + // when + final Result> result = invibesBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .containsOnly(BidderBid.of(Bid.builder().impid(IMP_ID).build(), banner, CURRENCY)); + } + + @Test + public void makeBidsShouldReturnErrorIdBidResponseContainsError() throws JsonProcessingException { + // given + final HttpCall httpCall = givenHttpCall( + InvibesBidRequest.builder().build(), + mapper.writeValueAsString(InvibesBidderResponse.builder().error("someError").build())); + + // when + final Result> result = invibesBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).hasSize(1) + .extracting(BidderError::getMessage) + .containsOnly("Server error: someError."); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void extractTargetingShouldReturnEmptyMap() { + assertThat(invibesBidder.extractTargeting(mapper.createObjectNode())).isEqualTo(emptyMap()); + } + + private static InvibesBidderResponse givenBidResponse(Function bidCustomizer) { + return InvibesBidderResponse.builder() + .typedBids(singletonList(InvibesTypedBid.builder() + .bid(bidCustomizer.apply(Bid.builder()).build()) + .dealPriority(12) + .build())) + .currency(CURRENCY) + .build(); + } + + private static HttpCall givenHttpCall(InvibesBidRequest bidRequest, String body) { + return HttpCall.success( + HttpRequest.builder().payload(bidRequest).build(), + HttpResponse.of(200, null, body), + null); + } + + private static BidRequest givenBidRequest( + Function bidRequestCustomizer, + Function impCustomizer, + ExtImpInvibes extImpInvibes) { + + return bidRequestCustomizer.apply(BidRequest.builder() + .site(Site.builder().page(PAGE_URL).build()) + .imp(singletonList(givenImp(impCustomizer, extImpInvibes)))) + .build(); + } + + private static BidRequest givenBidRequest( + Function bidRequestCustomizer, + Function impCustomizer) { + + return bidRequestCustomizer.apply(BidRequest.builder() + .imp(singletonList( + givenImp(impCustomizer, ExtImpInvibes.of("12", 15, + InvibesDebug.of("test", true)))))) + .build(); + } + + private static Imp givenImp(Function impCustomizer, + ExtImpInvibes extImpInvibes) { + return impCustomizer.apply(Imp.builder() + .ext(mapper.valueToTree(ExtPrebid.of(null, extImpInvibes)))) + .build(); + } +} diff --git a/src/test/java/org/prebid/server/it/InvibesTest.java b/src/test/java/org/prebid/server/it/InvibesTest.java new file mode 100644 index 00000000000..bef8a174bea --- /dev/null +++ b/src/test/java/org/prebid/server/it/InvibesTest.java @@ -0,0 +1,60 @@ +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 InvibesTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromInvibes() throws IOException, JSONException { + // given + // InvibesBidder bid response for imp 001 + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/invibes-exchange")) + .withHeader("Accept", equalTo("application/json")) + .withHeader("Content-Type", equalTo("application/json;charset=UTF-8")) + .withHeader("Referer", equalTo("http://www.example.com")) + .withHeader("Aver", equalTo("prebid_1.0.0")) + .withHeader("X-Forwarded-For", equalTo("193.168.244.1")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/invibes/test-invibes-bid-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/invibes/test-invibes-bid-response.json")))); + + // pre-bid cache + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/cache")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/invibes/test-cache-invibes-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/invibes/test-cache-invibes-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":{"invibes":"IV-UID"}} + .cookie("uids", "eyJ1aWRzIjp7ImludmliZXMiOiJJVi1VSUQifX0=") + .body(jsonFrom("openrtb2/invibes/test-auction-invibes-request.json")) + .post("/openrtb2/auction"); + + // then + final String expectedAuctionResponse = openrtbAuctionResponseFrom( + "openrtb2/invibes/test-auction-invibes-response.json", + response, singletonList("invibes")); + + JSONAssert.assertEquals(expectedAuctionResponse, response.asString(), JSONCompareMode.NON_EXTENSIBLE); + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/invibes/test-auction-invibes-request.json b/src/test/resources/org/prebid/server/it/openrtb2/invibes/test-auction-invibes-request.json new file mode 100644 index 00000000000..77da3269f4f --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/invibes/test-auction-invibes-request.json @@ -0,0 +1,90 @@ +{ + "id": "tid", + "imp": [ + { + "id": "impId001", + "banner": { + "format": [ + { + "w": 300, + "h": 250 + } + ] + }, + "tagid": "2eb6bd58-865c-47ce-af7f-a918108c3fd2", + "ext": { + "invibes": { + "placementId": "plcId", + "domainId": 1001, + "debug": { + "testBvid": "", + "testLog": false + } + } + } + } + ], + "device": { + "pxratio": 4.2, + "dnt": 2, + "ip": "193.168.244.1", + "language": "en", + "ifa": "ifaId", + "ua": "userAgent" + }, + "site": { + "domain": "example.com", + "ext": { + "amp": 0 + }, + "page": "http://www.example.com", + "publisher": { + "id": "publisherId" + } + }, + "at": 1, + "tmax": 3000, + "cur": [ + "USD" + ], + "source": { + "fd": 1, + "tid": "tid" + }, + "user": { + "ext": { + "consent": "consentValue", + "digitrust": { + "id": "id", + "keyv": 123, + "pref": 0 + } + } + }, + "regs": { + "ext": { + "gdpr": 0 + } + }, + "ext": { + "prebid": { + "targeting": { + "includebidderkeys": true, + "includewinners": true, + "pricegranularity": { + "precision": 2, + "ranges": [ + { + "max": 20, + "increment": 0.1 + } + ] + } + }, + "cache": { + "bids": {} + }, + "auctiontimestamp": 1000 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/invibes/test-auction-invibes-response.json b/src/test/resources/org/prebid/server/it/openrtb2/invibes/test-auction-invibes-response.json new file mode 100644 index 00000000000..f013f8816aa --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/invibes/test-auction-invibes-response.json @@ -0,0 +1,58 @@ +{ + "id":"tid", + "seatbid":[ + { + "bid":[ + { + "id":"bid001", + "impid":"impId001", + "price":1.3, + "adm":"adm001", + "adid":"adid001", + "cid":"cid001", + "crid":"crid001", + "w":300, + "h":250, + "ext":{ + "prebid":{ + "type":"banner", + "targeting":{ + "hb_pb_invibes":"1.30", + "hb_cache_id_invibes":"f0ab9105-cb21-4e59-b433-70f5ad6671cb", + "hb_bidder_invibes":"invibes", + "hb_cache_id":"f0ab9105-cb21-4e59-b433-70f5ad6671cb", + "hb_pb":"1.30", + "hb_cache_path":"/cache", + "hb_size":"300x250", + "hb_bidder":"invibes", + "hb_size_invibes":"300x250", + "hb_cache_host":"{{ cache.host }}", + "hb_cache_path_invibes":"{{ cache.path }}", + "hb_cache_host_invibes": "{{ cache.host }}" + }, + "cache":{ + "bids":{ + "url":"http://localhost:8090/cache?uuid=f0ab9105-cb21-4e59-b433-70f5ad6671cb", + "cacheId":"f0ab9105-cb21-4e59-b433-70f5ad6671cb" + } + } + } + } + } + ], + "seat":"invibes", + "group":0 + } + ], + "cur":"USD", + "ext":{ + "responsetimemillis":{ + "cache":"{{ cache.response_time_ms }}", + "invibes":"{{ invibes.response_time_ms }}" + }, + "tmaxrequest":3000, + "prebid":{ + "auctiontimestamp":1000 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/invibes/test-cache-invibes-request.json b/src/test/resources/org/prebid/server/it/openrtb2/invibes/test-cache-invibes-request.json new file mode 100644 index 00000000000..7007994992c --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/invibes/test-cache-invibes-request.json @@ -0,0 +1,18 @@ +{ + "puts": [ + { + "type": "json", + "value": { + "id": "bid001", + "impid": "impId001", + "price": 1.3, + "adm": "adm001", + "adid" : "adid001", + "cid" : "cid001", + "crid" : "crid001", + "w" : 300, + "h" : 250 + } + } + ] +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/invibes/test-cache-invibes-response.json b/src/test/resources/org/prebid/server/it/openrtb2/invibes/test-cache-invibes-response.json new file mode 100644 index 00000000000..93d0b8de2cd --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/invibes/test-cache-invibes-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/invibes/test-invibes-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/invibes/test-invibes-bid-request.json new file mode 100644 index 00000000000..5f5a1df81d2 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/invibes/test-invibes-bid-request.json @@ -0,0 +1,10 @@ +{ + "BidParamsJson" :"{\"PlacementIds\":[\"plcId\"],\"BidVersion\":\"4\",\"Properties\":{\"plcId\":{\"Formats\":[{\"w\":300,\"h\":250}],\"ImpID\":\"impId001\"}}}", + "Location" :"http://www.example.com", + "Lid" : "IV-UID", + "IsTestBid" : false, + "GdprConsent" :"consentValue", + "Gdpr" : false, + "InvibBVLog" : false, + "VideoAdDebug" : false +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/invibes/test-invibes-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/invibes/test-invibes-bid-response.json new file mode 100644 index 00000000000..bee6af29e05 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/invibes/test-invibes-bid-response.json @@ -0,0 +1,19 @@ +{ + "currency": "USD", + "typedBids": [ + { + "bid": { + "id": "bid001", + "impid": "impId001", + "price": 1.3, + "adid": "adid001", + "crid": "crid001", + "cid": "cid001", + "adm": "adm001", + "h": 250, + "w": 300 + }, + "dealPriority": 12 + } + ] +} 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 f4e330e4203..530ba198696 100644 --- a/src/test/resources/org/prebid/server/it/test-application.properties +++ b/src/test/resources/org/prebid/server/it/test-application.properties @@ -326,6 +326,10 @@ adapters.inmobi.enabled=true adapters.inmobi.endpoint=http://localhost:8090/inmobi-exchange adapters.inmobi.pbs-enforces-gdpr=true adapters.inmobi.usersync.url=//inmobi-usersync +adapters.invibes.enabled=true +adapters.invibes.endpoint=http://localhost:8090/invibes-exchange +adapters.invibes.pbs-enforces-gdpr=true +adapters.invibes.usersync.url=//invibes-usersync adapters.yeahmobi.enabled=true adapters.yeahmobi.endpoint=http://localhost:8090/yeahmobi-exchange adapters.yeahmobi.pbs-enforces-gdpr=true