From 320f4f769c5b9919b306882a57c21b8eb3987360 Mon Sep 17 00:00:00 2001 From: Alex Maltsev Date: Thu, 27 Aug 2026 12:51:52 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Added=20account.ACCOUNT=5FID.requests.gotbi?= =?UTF-8?q?ds=20metric=20that=20will=20be=20increme=E2=80=A6=20(#4619)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/auction/ExchangeService.java | 1 + .../org/prebid/server/metric/Metrics.java | 4 ++ .../functional/tests/MetricsSpec.groovy | 45 +++++++++++++++++-- .../org/prebid/server/metric/MetricsTest.java | 9 ++++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/prebid/server/auction/ExchangeService.java b/src/main/java/org/prebid/server/auction/ExchangeService.java index c7258a8faa7..afd05e3a1c4 100644 --- a/src/main/java/org/prebid/server/auction/ExchangeService.java +++ b/src/main/java/org/prebid/server/auction/ExchangeService.java @@ -1473,6 +1473,7 @@ private List updateResponsesMetrics(List.* metric shouldn't be populated" def metrics = defaultPbsService.sendCollectedMetricsRequest() - assert !metrics.find { it.key.startsWith("account.${accountId}") } + def filteredMetrics = metrics.findAll { it.key != "account.${accountId}.requests.gotbids" } + assert !filteredMetrics.find { it.key.startsWith("account.${accountId}") } } def "PBS should update account..requests metric when verbosity level is basic"() { @@ -94,9 +96,44 @@ class MetricsSpec extends BaseSpec { assert metrics["account.${accountId}.adapter.generic.request_time" as String] == 1 assert metrics["account.${accountId}.adapter.generic.requests.gotbids" as String] == 1 assert metrics["account.${accountId}.requests" as String] == 1 + assert metrics["account.${accountId}.requests.gotbids" as String] == 1 assert metrics["account.${accountId}.requests.type.openrtb2-web" as String] == 1 } + def "PBS should not populate bid-related account metrics for failed bidder response when verbosity level is detailed"() { + given: "Default basic BidRequest with generic bidder" + def bidRequest = BidRequest.defaultBidRequest + + and: "Default basic BidResponse with bid price" + def bidResponse = BidResponse.getDefaultBidResponse(bidRequest) + + and: "Set bidder response to internal server error" + bidder.setResponse(bidRequest.id, bidResponse, SC_INTERNAL_SERVER_ERROR) + + and: "Account in the DB" + def accountId = bidRequest.site.publisher.id + def accountMetricsConfig = new AccountConfig(metrics: new AccountMetricsConfig(verbosityLevel: DETAILED)) + def account = new Account(uuid: accountId, config: accountMetricsConfig) + accountDao.save(account) + + when: "PBS processes auction request" + defaultPbsService.sendAuctionRequest(bidRequest) + + then: "General request metrics should be populated" + def metrics = defaultPbsService.sendCollectedMetricsRequest() + assert metrics["account.${accountId}.requests" as String] == 1 + assert metrics["account.${accountId}.requests.type.openrtb2-web" as String] == 1 + + and: "Adapter request time should be populated" + assert metrics["account.${accountId}.adapter.generic.request_time" as String] == 1 + + and: "Bid-related metrics should not be populated due to bidder error" + assert !metrics["account.${accountId}.requests.gotbids" as String] + assert !metrics["account.${accountId}.adapter.generic.requests.gotbids" as String] + assert !metrics["account.${accountId}.adapter.generic.bids_received" as String] + assert !metrics["account.${accountId}.adapter.generic.prices" as String] + } + def "PBS should update hood metrics when bid request contains hood channel type and verbosity level is detailed"() { given: "Default basic BidRequest with generic bidder" def bidRequest = BidRequest.getDefaultBidRequest(DOOH) @@ -183,8 +220,8 @@ class MetricsSpec extends BaseSpec { where: bidRequest << [BidRequest.getDefaultBidRequest(APP).tap { - it.dooh = Dooh.defaultDooh - }, + it.dooh = Dooh.defaultDooh + }, BidRequest.getDefaultBidRequest(APP).tap { it.site = Site.defaultSite }, diff --git a/src/test/java/org/prebid/server/metric/MetricsTest.java b/src/test/java/org/prebid/server/metric/MetricsTest.java index 0eed97eab2d..79b434ec4d4 100644 --- a/src/test/java/org/prebid/server/metric/MetricsTest.java +++ b/src/test/java/org/prebid/server/metric/MetricsTest.java @@ -601,6 +601,15 @@ public void updateAdapterRequestGotbidsMetricsShouldIncrementMetrics() { .isEqualTo(2); } + @Test + public void updateAccountRequestGotbidsMetricsShouldIncrementMetric() { + // when + metrics.updateAccountRequestGotbidsMetric(ACCOUNT_ID); + + // then + assertThat(metricRegistry.counter("account.accountId.requests.gotbids").getCount()).isOne(); + } + @Test public void updateAdapterBidMetricsShouldUpdateMetrics() { // when From 4592dc507d36158e3d4b4e8ab3a52fc2d96bc1c5 Mon Sep 17 00:00:00 2001 From: osulzhenko <125548596+osulzhenko@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:52:04 +0300 Subject: [PATCH 2/3] Port Scalibur: New Adapter (#4596) --- .../bidder/scalibur/ScaliburBidder.java | 319 ++++++ .../ext/request/scalibur/ExtImpScalibur.java | 21 + .../config/bidder/ScaliburConfiguration.java | 37 + .../resources/bidder-config/scalibur.yaml | 22 + .../static/bidder-params/scalibur.json | 27 + .../bidder/scalibur/ScaliburBidderTest.java | 971 ++++++++++++++++++ .../org/prebid/server/it/ScaliburTest.java | 33 + .../test-auction-scalibur-request.json | 25 + .../test-auction-scalibur-response.json | 41 + .../scalibur/test-scalibur-bid-request.json | 40 + .../scalibur/test-scalibur-bid-response.json | 19 + .../server/it/test-application.properties | 2 + 12 files changed, 1557 insertions(+) create mode 100644 src/main/java/org/prebid/server/bidder/scalibur/ScaliburBidder.java create mode 100644 src/main/java/org/prebid/server/proto/openrtb/ext/request/scalibur/ExtImpScalibur.java create mode 100644 src/main/java/org/prebid/server/spring/config/bidder/ScaliburConfiguration.java create mode 100644 src/main/resources/bidder-config/scalibur.yaml create mode 100644 src/main/resources/static/bidder-params/scalibur.json create mode 100644 src/test/java/org/prebid/server/bidder/scalibur/ScaliburBidderTest.java create mode 100644 src/test/java/org/prebid/server/it/ScaliburTest.java create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-auction-scalibur-request.json create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-auction-scalibur-response.json create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-scalibur-bid-request.json create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-scalibur-bid-response.json diff --git a/src/main/java/org/prebid/server/bidder/scalibur/ScaliburBidder.java b/src/main/java/org/prebid/server/bidder/scalibur/ScaliburBidder.java new file mode 100644 index 00000000000..f6ae3d0bee7 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/scalibur/ScaliburBidder.java @@ -0,0 +1,319 @@ +package org.prebid.server.bidder.scalibur; + +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.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 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.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.Price; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.currency.CurrencyConversionService; +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.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.scalibur.ExtImpScalibur; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.util.BidderUtil; +import org.prebid.server.util.Uri; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class ScaliburBidder implements Bidder { + + private static final TypeReference> TYPE_REFERENCE = new TypeReference<>() { + }; + private static final String DEFAULT_HOST_LABEL = "srv"; + private static final String HOST_MACRO = "Host"; + private static final String DEFAULT_BID_CURRENCY = "USD"; + private static final String VAST_XML = """ + + """; + + private final Uri endpointUrl; + private final CurrencyConversionService currencyConversionService; + private final JacksonMapper mapper; + + public ScaliburBidder(String endpointUrl, + CurrencyConversionService currencyConversionService, + JacksonMapper mapper) { + + this.endpointUrl = Uri.of(endpointUrl); + this.currencyConversionService = Objects.requireNonNull(currencyConversionService); + this.mapper = Objects.requireNonNull(mapper); + } + + @Override + public Result>> makeHttpRequests(BidRequest bidRequest) { + final List errors = new ArrayList<>(); + final Map> hostLabelToImps = new HashMap<>(); + final List> httpRequests = new ArrayList<>(); + + for (Imp imp : bidRequest.getImp()) { + try { + final ExtImpScalibur scaliburExt = parseImpExt(imp); + final String hostLabel = StringUtils.defaultIfEmpty(scaliburExt.getHost(), DEFAULT_HOST_LABEL); + final Imp modifiedImp = modifyImp(imp, scaliburExt, bidRequest); + + hostLabelToImps.computeIfAbsent(hostLabel, ignored -> new ArrayList<>()).add(modifiedImp); + } catch (PreBidException e) { + errors.add(BidderError.badInput(e.getMessage())); + } + } + + if (hostLabelToImps.isEmpty()) { + return Result.withErrors(errors); + } + + for (Map.Entry> entry : hostLabelToImps.entrySet()) { + final BidRequest modifiedBidRequest = bidRequest.toBuilder() + .imp(entry.getValue()) + .cur(null) + .ext(isDebugEnabled(bidRequest) ? createDebugExt() : null) + .build(); + httpRequests.add(BidderUtil.defaultRequest( + modifiedBidRequest, + resolveUrl(endpointUrl, entry.getKey()), + mapper)); + } + + return Result.of(httpRequests, errors); + } + + private ExtImpScalibur parseImpExt(Imp imp) { + try { + return mapper.mapper().convertValue(imp.getExt(), TYPE_REFERENCE).getBidder(); + } catch (IllegalArgumentException e) { + throw new PreBidException(e.getMessage()); + } + } + + private Imp modifyImp(Imp imp, + ExtImpScalibur extImpScalibur, + BidRequest bidRequest) { + + final String tagId = StringUtils.defaultIfEmpty(imp.getTagid(), extImpScalibur.getPlacementId()); + + if (StringUtils.isBlank(tagId)) { + throw new PreBidException("imp %s: missing placement; set imp.tagid or the placementId param" + .formatted(imp.getId())); + } + + final Price resolvedBidFloor = resolveBidFloor(imp, extImpScalibur, bidRequest); + + return imp.toBuilder() + .tagid(tagId) + .bidfloor(resolvedBidFloor.getValue()) + .bidfloorcur(resolvedBidFloor.getCurrency()) + .video(resolveVideo(imp.getVideo())) + .ext(resolveImpExt(resolvedBidFloor, imp.getExt())) + .build(); + } + + private Price resolveBidFloor(Imp imp, + ExtImpScalibur extImpScalibur, + BidRequest bidRequest) { + + final BigDecimal extPrice = extImpScalibur.getBidFloor(); + final Price price = BidderUtil.isValidPrice(extPrice) + ? Price.of(StringUtils.defaultIfBlank(extImpScalibur.getBidFloorCur(), imp.getBidfloorcur()), extPrice) + : Price.of(imp.getBidfloorcur(), imp.getBidfloor()); + if (BidderUtil.shouldConvertBidFloor(price, DEFAULT_BID_CURRENCY)) { + return convertBidFloor(price, bidRequest); + } + return Price.of( + StringUtils.defaultIfBlank(price.getCurrency(), DEFAULT_BID_CURRENCY), + price.getValue()); + } + + private Price convertBidFloor(Price bidFloorPrice, BidRequest bidRequest) { + final BigDecimal convertedPrice = currencyConversionService.convertCurrency(bidFloorPrice.getValue(), + bidRequest, + bidFloorPrice.getCurrency(), + DEFAULT_BID_CURRENCY); + + return Price.of(DEFAULT_BID_CURRENCY, convertedPrice); + } + + private ObjectNode resolveImpExt(Price bidFloor, ObjectNode impExt) { + final ObjectNode ext = impExt.get("bidder").deepCopy(); + ext.remove("placementId"); + ext.remove("host"); + if (BidderUtil.isValidPrice(bidFloor)) { + ext.put("bidfloor", bidFloor.getValue()); + ext.put("bidfloorcur", bidFloor.getCurrency()); + } else { + ext.remove("bidfloor"); + ext.remove("bidfloorcur"); + } + final JsonNode gpidNode = impExt.get("gpid"); + if (gpidNode != null && !gpidNode.isNull()) { + ext.set("gpid", gpidNode); + } + return ext; + } + + private Video resolveVideo(Video video) { + if (video == null) { + return null; + } + + final Video.VideoBuilder builder = video.toBuilder(); + + if (CollectionUtils.isEmpty(video.getMimes())) { + builder.mimes(Collections.singletonList("video/mp4")); + } + if (BidderUtil.isNullOrZero(video.getMinduration())) { + builder.minduration(1); + } + if (BidderUtil.isNullOrZero(video.getMaxduration())) { + builder.maxduration(180); + } + if (BidderUtil.isNullOrZero(video.getMaxbitrate())) { + builder.maxbitrate(30_000); + } + if (CollectionUtils.isEmpty(video.getProtocols())) { + builder.protocols(List.of(2, 3, 5, 6)); + } + if (BidderUtil.isNullOrZero(video.getW())) { + builder.w(640); + } + if (BidderUtil.isNullOrZero(video.getH())) { + builder.h(480); + } + if (BidderUtil.isNullOrZero(video.getPlacement())) { + builder.placement(1); + } + if (BidderUtil.isNullOrZero(video.getLinearity())) { + builder.linearity(1); + } + + return builder.build(); + } + + private boolean isDebugEnabled(BidRequest bidRequest) { + if (Objects.equals(bidRequest.getTest(), 1)) { + return true; + } + final ExtRequest ext = bidRequest.getExt(); + if (ext == null || ext.getPrebid() == null) { + return false; + } + return Objects.equals(ext.getPrebid().getDebug(), 1); + } + + private ExtRequest createDebugExt() { + final ExtRequest extRequest = ExtRequest.empty(); + extRequest.addProperty("isDebug", IntNode.valueOf(1)); + return extRequest; + } + + private String resolveUrl(Uri configEndpoint, String externalUrl) { + return configEndpoint.replaceMacro(HOST_MACRO, externalUrl).expand(); + } + + @Override + public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + try { + final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); + return extractBids(httpCall.getRequest().getPayload(), bidResponse); + } catch (DecodeException | PreBidException e) { + return Result.withError(BidderError.badServerResponse(e.getMessage())); + } + } + + private Result> extractBids(BidRequest bidRequest, BidResponse bidResponse) { + if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) { + return Result.empty(); + } + final List errors = new ArrayList<>(); + final List bidderBids = new ArrayList<>(); + for (SeatBid seatBid : bidResponse.getSeatbid()) { + if (CollectionUtils.isEmpty(seatBid.getBid())) { + continue; + } + + for (Bid bid : seatBid.getBid()) { + try { + bidderBids.add(createBidderBid(bid, bidRequest, bidResponse)); + } catch (PreBidException e) { + errors.add(BidderError.badServerResponse(e.getMessage())); + } + } + } + + return Result.of(bidderBids, errors); + } + + private BidderBid createBidderBid(Bid bid, BidRequest bidRequest, BidResponse bidResponse) { + final Imp imp = bidRequest.getImp().stream() + .filter(candidate -> Objects.equals(candidate.getId(), bid.getImpid())) + .findFirst() + .orElseThrow(() -> new PreBidException("Invalid bid imp ID %s".formatted(bid.getImpid()))); + + final BidType bidType = resolveBidType(bid, imp); + final Bid resolvedBid = bidType == BidType.video ? resolveVideoBid(bid) : bid; + final String resolvedCurrency = StringUtils.defaultIfEmpty(bidResponse.getCur(), DEFAULT_BID_CURRENCY); + + return BidderBid.of(resolvedBid, bidType, resolvedCurrency); + } + + private Bid resolveVideoBid(Bid bid) { + if (bid.getExt() == null) { + return bid; + } + + final JsonNode vastXml = bid.getExt().get("vastXml"); + if (isNonEmptyText(vastXml)) { + return bid.toBuilder().adm(vastXml.asText()).build(); + } + + final JsonNode vastUrl = bid.getExt().get("vastUrl"); + if (isNonEmptyText(vastUrl) && StringUtils.isEmpty(bid.getAdm())) { + return bid.toBuilder() + .adm(VAST_XML.formatted(vastUrl.asText()).trim()) + .build(); + } + return bid; + } + + private boolean isNonEmptyText(JsonNode node) { + return node != null && node.isTextual() && StringUtils.isNotEmpty(node.asText()); + } + + private BidType resolveBidType(Bid bid, Imp imp) { + if (Objects.equals(bid.getMtype(), 1)) { + return BidType.banner; + } + if (Objects.equals(bid.getMtype(), 2)) { + return BidType.video; + } + if (imp.getBanner() != null && imp.getVideo() == null) { + return BidType.banner; + } + if (imp.getVideo() != null && imp.getBanner() == null) { + return BidType.video; + } + throw new PreBidException( + "Unsupported or ambiguous media type for bid id=%s".formatted(bid.getId())); + } +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/scalibur/ExtImpScalibur.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/scalibur/ExtImpScalibur.java new file mode 100644 index 00000000000..38f7964df30 --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/scalibur/ExtImpScalibur.java @@ -0,0 +1,21 @@ +package org.prebid.server.proto.openrtb.ext.request.scalibur; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Value; + +import java.math.BigDecimal; + +@Value(staticConstructor = "of") +public class ExtImpScalibur { + + @JsonProperty("placementId") + String placementId; + + @JsonProperty("bidfloor") + BigDecimal bidFloor; + + @JsonProperty("bidfloorcur") + String bidFloorCur; + + String host; +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/ScaliburConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/ScaliburConfiguration.java new file mode 100644 index 00000000000..4e69427e3ef --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/ScaliburConfiguration.java @@ -0,0 +1,37 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.scalibur.ScaliburBidder; +import org.prebid.server.currency.CurrencyConversionService; +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.env.YamlPropertySourceFactory; +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; + +@Configuration +@PropertySource(value = "classpath:/bidder-config/scalibur.yaml", factory = YamlPropertySourceFactory.class) +public class ScaliburConfiguration { + + private static final String BIDDER_NAME = "scalibur"; + + @Bean("scaliburConfigurationProperties") + @ConfigurationProperties("adapters.scalibur") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps scaliburBidderDeps(BidderConfigurationProperties scaliburConfigurationProperties, + CurrencyConversionService currencyConversionService, + JacksonMapper mapper) { + + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(scaliburConfigurationProperties) + .bidderCreator(config -> new ScaliburBidder(config.getEndpoint(), currencyConversionService, mapper)) + .assemble(); + } +} diff --git a/src/main/resources/bidder-config/scalibur.yaml b/src/main/resources/bidder-config/scalibur.yaml new file mode 100644 index 00000000000..fadbea062f7 --- /dev/null +++ b/src/main/resources/bidder-config/scalibur.yaml @@ -0,0 +1,22 @@ +adapters: + scalibur: + endpoint: http://{Host}.scalibur.io/adserver/ortb?type=prebid-server + geoscope: + - global + ortb-version: "2.6" + endpoint-compression: gzip + meta-info: + maintainer-email: support@scalibur.io + app-media-types: + - banner + - video + site-media-types: + - banner + - video + supported-vendors: + vendor-id: 1471 + usersync: + cookie-family-name: scalibur + iframe: + url: https://srv.scalibur.io/adserver/sync?type=iframe&gdpr={gdpr}&gdpr_consent={gdpr_consent}&us_privacy={us_privacy}&gpp={gpp}&&gpp_sid={gpp_sid}&redirect={redirect_url} + uid-macro: '[PBS_UID]' diff --git a/src/main/resources/static/bidder-params/scalibur.json b/src/main/resources/static/bidder-params/scalibur.json new file mode 100644 index 00000000000..56037ad7e0f --- /dev/null +++ b/src/main/resources/static/bidder-params/scalibur.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Scalibur Adapter Params", + "description": "A valid BidderParams object for the Scalibur adapter. Every impression must resolve a placement: either set placementId here, or set imp.tagid on the ad unit. An impression with neither is not served.", + "type": "object", + "properties": { + "placementId": { + "type": "string", + "description": "Placement id, applied to imp.tagid. Optional: may instead be supplied per ad unit as imp.tagid. At least one source must resolve or the imp is not served.", + "minLength": 1 + }, + "bidfloor": { + "type": "number", + "description": "The minimum price acceptable for a bid", + "minimum": 0 + }, + "bidfloorcur": { + "type": "string", + "description": "currency floor price" + }, + "host": { + "type": "string", + "description": "Subdomain label of the scalibur.io endpoint, for account or environment specific servers. A single RFC 1123 DNS label only, no dots or port; the domain itself is fixed. Defaults to srv. Impressions naming different subdomains are sent as separate requests, one per subdomain.", + "pattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$" + } + } +} diff --git a/src/test/java/org/prebid/server/bidder/scalibur/ScaliburBidderTest.java b/src/test/java/org/prebid/server/bidder/scalibur/ScaliburBidderTest.java new file mode 100644 index 00000000000..68bff4aaaa6 --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/scalibur/ScaliburBidderTest.java @@ -0,0 +1,971 @@ +package org.prebid.server.bidder.scalibur; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.iab.openrtb.request.Audio; +import com.iab.openrtb.request.Banner; +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 lombok.SneakyThrows; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.prebid.server.VertxTest; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.HttpResponse; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.proto.openrtb.ext.request.scalibur.ExtImpScalibur; +import org.prebid.server.currency.CurrencyConversionService; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.ExtRequestPrebid; +import org.prebid.server.proto.openrtb.ext.response.BidType; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.UnaryOperator; + +import static java.util.Collections.singletonList; +import static java.util.function.UnaryOperator.identity; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.tuple; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class ScaliburBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "https://{Host}.test.endpoint.com"; + private static final String DEFAULT_BID_CURRENCY = "USD"; + private static final String VAST_XML = """ + """; + + @Mock + private CurrencyConversionService currencyConversionService; + + private ScaliburBidder target; + + @BeforeEach + public void setUp() { + target = new ScaliburBidder(ENDPOINT_URL, currencyConversionService, jacksonMapper); + } + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + assertThatIllegalArgumentException().isThrownBy(() -> + new ScaliburBidder("invalid_url", currencyConversionService, jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldReturnErrorsWithoutRequestWhenAllImpsAreInvalid() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode()))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1); + } + + @Test + public void makeHttpRequestsShouldAddErrorsOnInvalidImps() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode())))), + givenImp(imp -> imp.ext( + givenImpExt(ExtImpScalibur.of(null, null, DEFAULT_BID_CURRENCY, null)))), + givenImp(imp -> imp.ext( + givenImpExt(ExtImpScalibur.of("", null, DEFAULT_BID_CURRENCY, null)))), + // valid imps + givenImp(identity()), + givenImp(imp -> imp.tagid("test-tag").ext( + givenImpExt(ExtImpScalibur.of(null, null, DEFAULT_BID_CURRENCY, null)))), + givenImp(imp -> imp.tagid("test-tag").ext( + givenImpExt(ExtImpScalibur.of("", null, DEFAULT_BID_CURRENCY, null)))), + givenImp(imp -> imp.ext( + givenImpExt(ExtImpScalibur.of("placementId", null, null, null))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .hasSize(4); + assertThat(result.getErrors()) + .extracting(BidderError::getMessage) + .hasSize(3) + .satisfies(errors -> { + assertThat(errors.get(0)).startsWith("Cannot deserialize value of type"); + assertThat(errors.get(1)).isEqualTo( + "imp 123: missing placement; set imp.tagid or the placementId param"); + assertThat(errors.get(2)).isEqualTo( + "imp 123: missing placement; set imp.tagid or the placementId param"); + }); + } + + @Test + public void makeHttpRequestsShouldPreferImpTagIdOverPlacementId() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .tagid("tagId") + .ext(givenImpExt(ExtImpScalibur.of("placementId", null, null, null))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .singleElement() + .extracting(Imp::getTagid) + .isEqualTo("tagId"); + } + + @Test + public void makeHttpRequestsShouldUsePlacementIdWhenImpTagIdIsMissing() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .tagid(null) + .ext(givenImpExt(ExtImpScalibur.of("placementId", null, null, null))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .singleElement() + .extracting(Imp::getTagid) + .isEqualTo("placementId"); + } + + @Test + public void makeHttpRequestsShouldConvertBidFloorIfCurrencyIsDifferent() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.bidfloor(BigDecimal.TEN).bidfloorcur("EUR"))); + + given(currencyConversionService.convertCurrency(BigDecimal.TEN, bidRequest, "EUR", DEFAULT_BID_CURRENCY)) + .willReturn(BigDecimal.ONE); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBidfloor, Imp::getBidfloorcur) + .containsExactly(tuple(BigDecimal.ONE, DEFAULT_BID_CURRENCY)); + } + + @Test + public void makeHttpRequestsShouldNotConvertBidfloorAndAssignUSDCurrencyWhenBidfloorHasEmptyCurrency() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.bidfloor(BigDecimal.TEN).bidfloorcur(null))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBidfloor, Imp::getBidfloorcur) + .containsExactly(tuple(BigDecimal.TEN, DEFAULT_BID_CURRENCY)); + } + + @Test + public void makeHttpRequestsShouldOverrideBidfloorAndCurrencyWhenScaliburBidFloorIsValid() { + // given + + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .bidfloor(BigDecimal.TEN) + .bidfloorcur("EUR") + .ext(givenImpExt( + ExtImpScalibur.of("placementId", BigDecimal.ONE, DEFAULT_BID_CURRENCY, null))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBidfloor, Imp::getBidfloorcur) + .containsExactly(tuple(BigDecimal.ONE, DEFAULT_BID_CURRENCY)); + } + + @Test + public void makeHttpRequestsShouldNotOverrideBidfloorAndCurrencyWhenScaliburBidFloorIsInvalid() { + // given + + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.bidfloor(BigDecimal.TEN).bidfloorcur(DEFAULT_BID_CURRENCY) + .ext(givenImpExt(ExtImpScalibur.of("placementId", null, "EUR", null))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBidfloor, Imp::getBidfloorcur) + .containsExactly(tuple(BigDecimal.TEN, DEFAULT_BID_CURRENCY)); + } + + @Test + public void makeHttpRequestsShouldFallbackToImpCurrencyWhenScaliburCurrencyIsMissing() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.bidfloor(BigDecimal.TEN).bidfloorcur("EUR") + .ext(givenImpExt(ExtImpScalibur.of("placementId", BigDecimal.ONE, null, null))))); + + given(currencyConversionService.convertCurrency(BigDecimal.ONE, bidRequest, "EUR", DEFAULT_BID_CURRENCY)) + .willReturn(BigDecimal.valueOf(2)); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBidfloor, Imp::getBidfloorcur) + .containsExactly(tuple(BigDecimal.valueOf(2), DEFAULT_BID_CURRENCY)); + } + + @Test + public void makeHttpRequestsShouldNotSetPlacementIdInImpExt() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .ext(givenImpExt(ExtImpScalibur.of("placementId", null, null, null))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getExt) + .containsExactly(mapper.valueToTree(ExtImpScalibur.of(null, null, null, null))); + } + + @Test + public void makeHttpRequestsShouldExtractBidderParamsAndKeepFloorAndGpid() { + // given + final ObjectNode impExt = givenImpExt( + ExtImpScalibur.of("placementId", BigDecimal.ONE, DEFAULT_BID_CURRENCY, "host")); + ((ObjectNode) impExt.get("bidder")).put("extraField", "test-custom"); + impExt.put("gpid", "test-gpid"); + impExt.put("random", "test-random"); + + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.ext(impExt))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getExt) + .containsExactly( + mapper.createObjectNode() + .put("bidfloor", BigDecimal.ONE) + .put("bidfloorcur", DEFAULT_BID_CURRENCY) + .put("extraField", "test-custom") + .put("gpid", "test-gpid")); + } + + @Test + public void makeHttpRequestsShouldFillVideoDefaultValues() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .banner(null) + .video(Video.builder().build()))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(HttpRequest::getPayload) + .satisfies(request -> assertThat(request.getImp()) + .singleElement() + .extracting(Imp::getVideo) + .satisfies(video -> { + assertThat(video.getMimes()).containsExactly("video/mp4"); + assertThat(video.getMinduration()).isEqualTo(1); + assertThat(video.getMaxduration()).isEqualTo(180); + assertThat(video.getMaxbitrate()).isEqualTo(30_000); + assertThat(video.getProtocols()).containsExactly(2, 3, 5, 6); + assertThat(video.getW()).isEqualTo(640); + assertThat(video.getH()).isEqualTo(480); + assertThat(video.getPlacement()).isEqualTo(1); + assertThat(video.getLinearity()).isEqualTo(1); + })); + } + + @Test + public void makeHttpRequestsShouldPreserveProvidedVideoValues() { + // given + final Video video = Video.builder() + .mimes(List.of("video/webm")) + .minduration(10) + .maxduration(30) + .maxbitrate(5000) + .protocols(List.of(1, 2)) + .w(1280) + .h(720) + .placement(2) + .linearity(2) + .build(); + + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .banner(null) + .video(video))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(HttpRequest::getPayload) + .satisfies() + .satisfies(request -> assertThat(request.getImp()) + .singleElement() + .extracting(Imp::getVideo) + .isEqualTo(video)); + } + + @Test + public void makeHttpRequestsShouldSetIsDebugWhenTestIsEnabled() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(identity())) + .toBuilder() + .test(1) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getExt) + .satisfies(ext -> assertThat(ext.getProperty("isDebug").asInt()).isEqualTo(1)); + } + + @Test + public void makeHttpRequestsShouldSetIsDebugWhenPrebidDebugIsEnabled() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(identity())) + .toBuilder() + .ext(ExtRequest.of(ExtRequestPrebid.builder().debug(1).build())) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getExt) + .satisfies(ext -> assertThat(ext.getProperty("isDebug").asInt()).isEqualTo(1)); + } + + @Test + public void makeHttpRequestsShouldRemoveRequestExtWhenDebugIsDisabled() { + // given + final ExtRequest extRequest = ExtRequest.empty(); + extRequest.addProperty("random", TextNode.valueOf("test-random")); + + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp)) + .toBuilder() + .ext(extRequest) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getExt) + .isNull(); + } + + @Test + public void makeHttpRequestsShouldUseDefaultHostWhenHostIsMissing() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(identity()), + givenImp(imp -> imp.ext(givenImpExtWithHost(""))) + ); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(HttpRequest::getUri) + .isEqualTo("https://srv.test.endpoint.com"); + } + + @Test + public void makeHttpRequestsShouldGroupMultipleImpsUnderSameHost() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.id("123").ext(givenImpExtWithHost("host1"))), + givenImp(imp -> imp.id("456").ext(givenImpExtWithHost("host2"))), + givenImp(imp -> imp.id("789").ext(givenImpExtWithHost("host1")))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(2); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getImp) + .extracting(imps -> imps.stream().map(Imp::getId).toList()) + .containsExactlyInAnyOrder(List.of("123", "789"), List.of("456")); + } + + @Test + public void makeBidsShouldReturnBidsFromAllSeatBids() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(identity()), + givenImp(imp -> imp.id("456"))); + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder.id("bid-1").impid("123"), + bidBuilder -> bidBuilder.id("bid-2").impid("456")))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getBid) + .extracting(Bid::getId) + .containsExactly("bid-1", "bid-2"); + } + + @Test + public void makeBidsShouldUseUsdCurrencyWhenResponseCurrencyIsMissing() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(identity())); + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse(bidBuilder -> bidBuilder.id("bid-1").impid("123")))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getBidCurrency) + .isEqualTo(DEFAULT_BID_CURRENCY); + } + + @Test + public void makeBidsShouldUseResponseCurrency() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(identity())); + final BidResponse bidResponse = givenBidResponse(bidBuilder -> bidBuilder.id("bid-1").impid("123")).toBuilder() + .cur("EUR") + .build(); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString(bidResponse)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getBidCurrency) + .isEqualTo("EUR"); + } + + @Test + public void makeBidsShouldRejectInvalidBidImpId() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(identity())); + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("invalid-imp")))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()) + .singleElement() + .extracting(BidderError::getMessage) + .isEqualTo("Invalid bid imp ID invalid-imp"); + } + + @Test + public void makeBidsShouldResolveBannerBidTypeFromMtype() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(identity())); + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .mtype(1)))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getType) + .isEqualTo(BidType.banner); + } + + @Test + public void makeBidsShouldResolveVideoBidTypeFromMtype() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(imp -> imp.banner(null).video(Video.builder().build()))); + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .mtype(2)))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getType) + .isEqualTo(BidType.video); + } + + @Test + public void makeBidsShouldResolveBidTypeFromImpWhenMtypeIsMissing() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.banner(Banner.builder().w(1).h(1).build()).video(null))); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .mtype(null)))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getType) + .isEqualTo(BidType.banner); + } + + @Test + public void makeBidsShouldResolveVideoBidTypeFromVideoImpWhenMtypeIsMissing() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.banner(null).video(Video.builder().build()))); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .mtype(null)))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getType) + .isEqualTo(BidType.video); + } + + @Test + public void makeBidsShouldRecoverMediaTypeFromImpWhenMtypeIsInvalid() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(identity())); + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .mtype(3)))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getType) + .isEqualTo(BidType.banner); + } + + @Test + public void makeBidsShouldRejectAmbiguousImpMediaType() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .banner(Banner.builder().w(1).h(1).build()) + .video(Video.builder().build()))); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .mtype(null)))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()) + .singleElement() + .extracting(BidderError::getMessage) + .isEqualTo("Unsupported or ambiguous media type for bid id=bid-1"); + } + + @Test + public void makeBidsShouldRejectInvalidMediaType() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp + .banner(null) + .audio(Audio.builder().build()))); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .mtype(3)))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()) + .singleElement() + .extracting(BidderError::getMessage) + .isEqualTo("Unsupported or ambiguous media type for bid id=bid-1"); + } + + @Test + public void makeBidsShouldReplaceAdmWithVastXml() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.banner(null).video(Video.builder().build()))); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .ext(mapper.createObjectNode().put("vastXml", "test"))))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getBid) + .extracting(Bid::getAdm) + .isEqualTo("test"); + } + + @Test + public void makeBidsShouldReplaceNotPresentAdmWithVastUrlWrapper() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.banner(null).video(Video.builder().build()))); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .ext(mapper.createObjectNode().put("vastUrl", "https://test.com"))))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getBid) + .extracting(Bid::getAdm) + .isEqualTo(VAST_XML.formatted("https://test.com")); + } + + @Test + public void makeBidsShouldPrioritizeVastXmlOverVastUrlWhenBothArePresent() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.banner(null).video(Video.builder().build()))); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .ext(mapper.createObjectNode() + .put("vastXml", "test") + .put("vastUrl", "https://test.com"))))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getBid) + .extracting(Bid::getAdm) + .isEqualTo("test"); + } + + @Test + public void makeBidsShouldPreserveAdmWhenVastUrlIsPresent() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp(imp -> imp.banner(null).video(Video.builder().build()))); + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder + .id("bid-1") + .impid("123") + .adm("test-adm") + .ext(mapper.createObjectNode().put("vastUrl", "https://test.com"))))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .singleElement() + .extracting(BidderBid::getBid) + .extracting(Bid::getAdm) + .isEqualTo("test-adm"); + } + + @Test + public void makeBidsShouldReturnEmptyResultWhenResponseHasNoSeatBids() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(identity())); + final BidResponse bidResponse = BidResponse.builder() + .seatbid(Collections.emptyList()) + .build(); + + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString(bidResponse)); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnErrorIfResponseBodyCouldNotBeParsed() { + // given + final BidderCall httpCall = givenHttpCall(givenBidRequest(givenImp(identity())), "invalid"); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).allMatch(error -> error.getType() == BidderError.Type.bad_server_response + && error.getMessage().startsWith("Failed to decode: Unrecognized token")); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnValidBidsAndErrorsForInvalidBids() throws JsonProcessingException { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(identity())); + final BidderCall httpCall = givenHttpCall( + bidRequest, + mapper.writeValueAsString( + givenBidResponse( + bidBuilder -> bidBuilder.id("valid-bid").impid("123"), + bidBuilder -> bidBuilder.id("invalid-bid").impid("invalid-imp")))); + + // when + final Result> result = target.makeBids(httpCall, bidRequest); + + // then + assertThat(result.getValue()) + .extracting(BidderBid::getBid) + .extracting(Bid::getId) + .containsExactly("valid-bid"); + + assertThat(result.getErrors()) + .singleElement() + .extracting(BidderError::getMessage) + .isEqualTo("Invalid bid imp ID invalid-imp"); + } + + private static BidRequest givenBidRequest(Imp... imps) { + return BidRequest.builder() + .cur(List.of("USD")) + .imp(Arrays.stream(imps).toList()) + .build(); + } + + private static Imp givenImp(UnaryOperator impCustomizer) { + return impCustomizer.apply(Imp.builder() + .id("123") + .banner(Banner.builder().w(1).h(1).build()) + .ext(givenImpExt(ExtImpScalibur.of("placementId", null, null, null)))) + .build(); + } + + private static ObjectNode givenImpExtWithHost(String host) { + return givenImpExt(ExtImpScalibur.of("placementId", null, null, host)); + } + + private static ObjectNode givenImpExt(ExtImpScalibur extImpScalibur) { + return mapper.valueToTree(ExtPrebid.of(null, extImpScalibur)); + } + + @SafeVarargs + @SneakyThrows + private BidResponse givenBidResponse(UnaryOperator... bidCustomizers) { + return BidResponse.builder() + .seatbid(singletonList(SeatBid.builder() + .bid(Arrays.stream(bidCustomizers) + .map(bidCustomizer -> bidCustomizer.apply(Bid.builder()).build()) + .toList()) + .build())) + .build(); + } + + private static BidderCall givenHttpCall(BidRequest bidRequest, String body) { + return BidderCall.succeededHttp( + HttpRequest.builder().payload(bidRequest).build(), + HttpResponse.of(200, null, body), + null); + } +} diff --git a/src/test/java/org/prebid/server/it/ScaliburTest.java b/src/test/java/org/prebid/server/it/ScaliburTest.java new file mode 100644 index 00000000000..cd33f769458 --- /dev/null +++ b/src/test/java/org/prebid/server/it/ScaliburTest.java @@ -0,0 +1,33 @@ +package org.prebid.server.it; + +import io.restassured.response.Response; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.prebid.server.model.Endpoint; + +import java.io.IOException; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; + +public class ScaliburTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromScalibur() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/scalibur-exchange")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/scalibur/test-scalibur-bid-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/scalibur/test-scalibur-bid-response.json")))); + + // when + final Response response = responseFor( + "openrtb2/scalibur/test-auction-scalibur-request.json", + Endpoint.openrtb2_auction); + + // then + assertJsonEquals("openrtb2/scalibur/test-auction-scalibur-response.json", response, List.of("scalibur")); + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-auction-scalibur-request.json b/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-auction-scalibur-request.json new file mode 100644 index 00000000000..2435fe56373 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-auction-scalibur-request.json @@ -0,0 +1,25 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "banner": { + "w": 320, + "h": 250 + }, + "bidfloorcur": "USD", + "ext": { + "scalibur": { + "placementId": "placement_id", + "extraField": "extra_field" + } + } + } + ], + "tmax": 5000, + "regs": { + "ext": { + "gdpr": 0 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-auction-scalibur-response.json b/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-auction-scalibur-response.json new file mode 100644 index 00000000000..37da829cd13 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-auction-scalibur-response.json @@ -0,0 +1,41 @@ +{ + "id": "request_id", + "seatbid": [ + { + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "exp": 300, + "price": 0.01, + "adid": "2068416", + "cid": "8048", + "crid": "24080", + "mtype": 1, + "ext": { + "prebid": { + "type": "banner", + "meta": { + "adaptercode": "scalibur" + } + }, + "origbidcpm": 0.01, + "origbidcur": "USD" + } + } + ], + "seat": "scalibur", + "group": 0 + } + ], + "cur": "USD", + "ext": { + "responsetimemillis": { + "scalibur": "{{ scalibur.response_time_ms }}" + }, + "prebid": { + "auctiontimestamp": 0 + }, + "tmaxrequest": 5000 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-scalibur-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-scalibur-bid-request.json new file mode 100644 index 00000000000..96eb65c1e2e --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-scalibur-bid-request.json @@ -0,0 +1,40 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "banner": { + "w": 320, + "h": 250 + }, + "tagid" : "placement_id", + "bidfloorcur": "USD", + "secure" : 1, + "ext" : { + "extraField": "extra_field" + } + } + ], + "site": { + "domain": "www.example.com", + "page": "http://www.example.com", + "publisher": { + "domain": "example.com" + }, + "ext": { + "amp": 0 + } + }, + "device": { + "ua": "userAgent", + "ip": "193.168.244.1" + }, + "at": 1, + "tmax": "${json-unit.any-number}", + "source": { + "tid": "${json-unit.any-string}" + }, + "regs": { + "gdpr": 0 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-scalibur-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-scalibur-bid-response.json new file mode 100644 index 00000000000..47d4f8718ea --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/scalibur/test-scalibur-bid-response.json @@ -0,0 +1,19 @@ +{ + "id": "tid", + "seatbid": [ + { + "bid": [ + { + "crid": "24080", + "adid": "2068416", + "price": 0.01, + "id": "bid_id", + "impid": "imp_id", + "cid": "8048", + "mtype": 1 + } + ], + "type": "banner" + } + ] +} 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 e8fc7ba04cd..3aef0d8f20e 100644 --- a/src/test/resources/org/prebid/server/it/test-application.properties +++ b/src/test/resources/org/prebid/server/it/test-application.properties @@ -511,6 +511,8 @@ adapters.magnite.usersync.redirect.url=http://localhost:8090/magnite-usersync?gd adapters.magnite.usersync.redirect.uid-macro=magnite-macro adapters.salunamedia.enabled=true adapters.salunamedia.endpoint=http://localhost:8090/salunamedia-exchange +adapters.scalibur.enabled=true +adapters.scalibur.endpoint=http://localhost:8090/scalibur-exchange adapters.screencore.enabled=true adapters.screencore.endpoint=http://localhost:8090/screencore-exchange adapters.seedingAlliance.enabled=true From 9397712a6827828ef109db1bd51a6d23c5d1cfdd Mon Sep 17 00:00:00 2001 From: osulzhenko <125548596+osulzhenko@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:03:17 +0300 Subject: [PATCH 3/3] New Adapter: Synapse HX (#4620) --- .../bidder/synapsehx/SynapseHXBidder.java | 160 +++++++++++ .../request/synapsehx/ExtImpSynapseHX.java | 11 + .../bidder/SynapseHXBidderConfiguration.java | 35 +++ .../resources/bidder-config/synapsehx.yaml | 21 ++ .../static/bidder-params/synapsehx.json | 14 + .../bidder/synapsehx/SynapseHXBidderTest.java | 261 ++++++++++++++++++ .../org/prebid/server/it/SynapseHXTest.java | 33 +++ .../test-auction-synapsehx-request.json | 23 ++ .../test-auction-synapsehx-response.json | 40 +++ .../synapsehx/test-synapsehx-bid-request.json | 57 ++++ .../test-synapsehx-bid-response.json | 18 ++ .../server/it/test-application.properties | 2 + 12 files changed, 675 insertions(+) create mode 100644 src/main/java/org/prebid/server/bidder/synapsehx/SynapseHXBidder.java create mode 100644 src/main/java/org/prebid/server/proto/openrtb/ext/request/synapsehx/ExtImpSynapseHX.java create mode 100644 src/main/java/org/prebid/server/spring/config/bidder/SynapseHXBidderConfiguration.java create mode 100644 src/main/resources/bidder-config/synapsehx.yaml create mode 100644 src/main/resources/static/bidder-params/synapsehx.json create mode 100644 src/test/java/org/prebid/server/bidder/synapsehx/SynapseHXBidderTest.java create mode 100644 src/test/java/org/prebid/server/it/SynapseHXTest.java create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-auction-synapsehx-request.json create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-auction-synapsehx-response.json create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-synapsehx-bid-request.json create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-synapsehx-bid-response.json diff --git a/src/main/java/org/prebid/server/bidder/synapsehx/SynapseHXBidder.java b/src/main/java/org/prebid/server/bidder/synapsehx/SynapseHXBidder.java new file mode 100644 index 00000000000..3f232210cf7 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/synapsehx/SynapseHXBidder.java @@ -0,0 +1,160 @@ +package org.prebid.server.bidder.synapsehx; + +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.response.Bid; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import io.vertx.core.MultiMap; +import org.apache.commons.collections4.CollectionUtils; +import org.prebid.server.bidder.Bidder; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.exception.PreBidException; +import org.prebid.server.json.DecodeException; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.synapsehx.ExtImpSynapseHX; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebid; +import org.prebid.server.util.BidderUtil; +import org.prebid.server.util.HttpUtil; +import org.prebid.server.util.Uri; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public class SynapseHXBidder implements Bidder { + + private static final TypeReference> SYNAPSE_HX_EXT_TYPE_REFERENCE = + new TypeReference<>() { + }; + + private static final TypeReference> EXT_PREBID_TYPE_REFERENCE = + new TypeReference<>() { + }; + + private static final String OPENRTB_VERSION = "2.6"; + + private final Uri endpoint; + private final JacksonMapper mapper; + + public SynapseHXBidder(String endpoint, JacksonMapper mapper) { + this.endpoint = Uri.of(endpoint); + this.mapper = Objects.requireNonNull(mapper); + } + + @Override + public final Result>> makeHttpRequests(BidRequest bidRequest) { + final Imp firstImp = bidRequest.getImp().getFirst(); + final String uri; + + try { + uri = makeUri(getTenantId(firstImp)); + } catch (PreBidException e) { + return Result.withError(BidderError.badInput(e.getMessage())); + } + + final MultiMap headers = HttpUtil.headers(); + headers.add(HttpUtil.X_OPENRTB_VERSION_HEADER, OPENRTB_VERSION); + + return Result.withValue(BidderUtil.defaultRequest(bidRequest, headers, uri, mapper)); + } + + private String getTenantId(Imp firstImp) { + try { + return mapper.mapper() + .convertValue(firstImp.getExt(), SYNAPSE_HX_EXT_TYPE_REFERENCE) + .getBidder() + .getTenantId(); + } catch (IllegalArgumentException e) { + throw new PreBidException("Failed to parse bidder parameters: %s".formatted(e.getMessage())); + } + } + + private String makeUri(String tenantId) { + return endpoint.addQueryParam("pid", tenantId).expand(); + } + + @Override + public final Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + try { + final List bidderErrors = new ArrayList<>(); + final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); + return Result.of(extractBids(bidResponse, bidderErrors), bidderErrors); + } catch (DecodeException e) { + return Result.withError(BidderError.badServerResponse(e.getMessage())); + } + } + + private List extractBids(BidResponse bidResponse, List bidderErrors) { + if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) { + return Collections.emptyList(); + } + return bidsFromResponse(bidResponse, bidderErrors); + } + + private List bidsFromResponse(BidResponse bidResponse, List bidderErrors) { + return bidResponse.getSeatbid().stream() + .filter(Objects::nonNull) + .map(SeatBid::getBid) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .filter(Objects::nonNull) + .map(bid -> makeBidderBid(bid, bidResponse, bidderErrors)) + .filter(Objects::nonNull) + .toList(); + } + + private BidderBid makeBidderBid(Bid bid, BidResponse bidResponse, List bidderErrors) { + final BidType bidType = getBidType(bid, bidderErrors); + return bidType == null + ? null + : BidderBid.of(bid, bidType, bidResponse.getCur()); + } + + private BidType getBidType(Bid bid, List errors) { + final Integer mType = bid.getMtype(); + return mType != null + ? getBidTypeFromMType(mType, errors) + : getBidTypeFromExt(bid.getExt(), errors); + } + + private static BidType getBidTypeFromMType(Integer mType, List errors) { + return switch (mType) { + case 1 -> BidType.banner; + case 2 -> BidType.video; + default -> { + errors.add(BidderError.badServerResponse("Unsupported media type: %d".formatted(mType))); + yield null; + } + }; + } + + private BidType getBidTypeFromExt(ObjectNode bidExt, List errors) { + final BidType bidType = Optional.ofNullable(bidExt) + .map(ext -> mapper.mapper().convertValue(ext, EXT_PREBID_TYPE_REFERENCE)) + .map(ExtPrebid::getPrebid) + .map(ExtBidPrebid::getType) + .orElse(null); + + return switch (bidType) { + case banner -> BidType.banner; + case video -> BidType.video; + case null, default -> { + errors.add(BidderError.badServerResponse("Unsupported media type: %s".formatted(bidType))); + yield null; + } + }; + } + +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/synapsehx/ExtImpSynapseHX.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/synapsehx/ExtImpSynapseHX.java new file mode 100644 index 00000000000..83a9d08d9e5 --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/synapsehx/ExtImpSynapseHX.java @@ -0,0 +1,11 @@ +package org.prebid.server.proto.openrtb.ext.request.synapsehx; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Value; + +@Value(staticConstructor = "of") +public class ExtImpSynapseHX { + + @JsonProperty("tenantId") + String tenantId; +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/SynapseHXBidderConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/SynapseHXBidderConfiguration.java new file mode 100644 index 00000000000..d5d87bce0b0 --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/SynapseHXBidderConfiguration.java @@ -0,0 +1,35 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.synapsehx.SynapseHXBidder; +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.env.YamlPropertySourceFactory; +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; + +@Configuration +@PropertySource(value = "classpath:/bidder-config/synapsehx.yaml", factory = YamlPropertySourceFactory.class) +public class SynapseHXBidderConfiguration { + + private static final String BIDDER_NAME = "synapsehx"; + + @Bean("synapsehxConfigurationProperties") + @ConfigurationProperties("adapters.synapsehx") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps synapsehxBidderDeps(BidderConfigurationProperties synapsehxConfigurationProperties, + JacksonMapper mapper) { + + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(synapsehxConfigurationProperties) + .bidderCreator(config -> new SynapseHXBidder(config.getEndpoint(), mapper)) + .assemble(); + } +} diff --git a/src/main/resources/bidder-config/synapsehx.yaml b/src/main/resources/bidder-config/synapsehx.yaml new file mode 100644 index 00000000000..ede5ccf6469 --- /dev/null +++ b/src/main/resources/bidder-config/synapsehx.yaml @@ -0,0 +1,21 @@ +adapters: + synapsehx: + endpoint: https://rtb.hx.compasonline.com/pbs + openrtb-version: 2.6 + meta-info: + maintainer-email: prebid@compas-inc.com + app-media-types: + - banner + - video + site-media-types: + - banner + - video + vendor-id: 0 + usersync: + cookie-family-name: synapsehx + redirect: + url: https://sync.hx.compasonline.com/pbserver/image?gdpr={gdpr}&gdpr_consent={gdpr_consent}&ccpa={us_privacy}&gpp={gpp}&gpp_sid={gpp_sid}&redirect={redirect_url} + uid-macro: "${VISITOR_ID}" + iframe: + url: https://sync.hx.compasonline.com/pbserver/iframe?gdpr={gdpr}&gdpr_consent={gdpr_consent}&ccpa={us_privacy}&gpp={gpp}&gpp_sid={gpp_sid}&redirect={redirect_url} + uid-macro: "${VISITOR_ID}" diff --git a/src/main/resources/static/bidder-params/synapsehx.json b/src/main/resources/static/bidder-params/synapsehx.json new file mode 100644 index 00000000000..167a0b8f070 --- /dev/null +++ b/src/main/resources/static/bidder-params/synapsehx.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Synapse HX Adapter Params", + "description": "A schema which validates params accepted by the Synapse HX adapter", + "type": "object", + "properties": { + "tenantId": { + "type": "string", + "description": "Synapse HX tenant identifier", + "minLength": 1 + } + }, + "required": ["tenantId"] +} diff --git a/src/test/java/org/prebid/server/bidder/synapsehx/SynapseHXBidderTest.java b/src/test/java/org/prebid/server/bidder/synapsehx/SynapseHXBidderTest.java new file mode 100644 index 00000000000..f1b81ec0f06 --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/synapsehx/SynapseHXBidderTest.java @@ -0,0 +1,261 @@ +package org.prebid.server.bidder.synapsehx; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.Banner; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Imp; +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.MultiMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.prebid.server.VertxTest; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.HttpResponse; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.synapsehx.ExtImpSynapseHX; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.util.HttpUtil; + +import java.util.List; +import java.util.Map; +import java.util.function.UnaryOperator; + +import static java.util.Arrays.asList; +import static java.util.Collections.singletonList; +import static java.util.function.UnaryOperator.identity; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.tuple; + +public class SynapseHXBidderTest extends VertxTest { + + private SynapseHXBidder target; + + @BeforeEach + public void setUp() { + target = new SynapseHXBidder("http://test.endpoint.com", jacksonMapper); + } + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + assertThatIllegalArgumentException().isThrownBy( + () -> new SynapseHXBidder("invalid_url", jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldCorrectlyAddHeaders() { + // given + final BidRequest bidRequest = givenBidRequest(identity()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()) + .extracting(HttpRequest::getHeaders) + .flatExtracting(MultiMap::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.X_OPENRTB_VERSION_HEADER.toString(), "2.6")); + } + + @Test + public void makeHttpRequestsShouldReturnErrorWhenImpressionContainsInvalidBidderParameters() { + // given + final ObjectNode invalidExt = mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode())); + + final BidRequest bidRequest = givenBidRequest(identity(), + impBuilder -> impBuilder.id("Imp01").ext(invalidExt)); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).satisfiesExactly( + error -> assertThat(error.getMessage()).startsWith("Failed to parse bidder parameters")); + } + + @Test + public void makeBidsShouldReturnErrorIfResponseBodyCouldNotBeParsed() { + // given + final BidderCall httpCall = givenHttpCall(null, "invalid"); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response); + assertThat(error.getMessage()).startsWith("Failed to decode: Unrecognized token"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnEmptyListIfBidResponseIsNull() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(null, mapper.writeValueAsString(null)); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnEmptyListIfBidResponseSeatBidIsNull() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(null, + mapper.writeValueAsString(BidResponse.builder().build())); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldSkipBidWithUnsupportedBidTypeAndKeepValidBid() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(null, + givenBidResponse( + givenBidWithMType("Imp1", BidType.banner), + givenBidWithMType("Imp2", BidType.audio))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).satisfiesExactly( + error -> assertThat(error.getMessage()).startsWith("Unsupported media type")); + assertThat(result.getValue()).satisfiesExactly( + bid -> assertThat(bid.getBid().getImpid()).isEqualTo("Imp1")); + } + + @Test + public void makeBidsShouldReportErrorsSkipForBidsWithUnsupportedOrMissingBidType() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(null, + givenBidResponse( + givenBidWithMType("Imp1", BidType.xNative), + givenBidWithMType("Imp2", BidType.audio), + givenBid("Imp3", BidType.xNative), + givenBid("Imp4", BidType.audio), + givenBid("Imp9", BidType.banner).toBuilder().ext(null).build())); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).hasSize(5).allSatisfy( + error -> assertThat(error.getMessage()).startsWith("Unsupported media type")); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsCorrectlyRecogniseBidTypeWithFallback() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(null, + givenBidResponse( + givenBidWithMType("Imp1", BidType.banner), + givenBidWithMType("Imp2", BidType.video), + givenBid("Imp3", BidType.banner), + givenBid("Imp4", BidType.video))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).satisfiesExactlyInAnyOrder( + bid -> { + assertThat(bid.getBid().getImpid()).isEqualTo("Imp1"); + assertThat(bid.getType()).isEqualTo(BidType.banner); + }, + bid -> { + assertThat(bid.getBid().getImpid()).isEqualTo("Imp2"); + assertThat(bid.getType()).isEqualTo(BidType.video); + }, + bid -> { + assertThat(bid.getBid().getImpid()).isEqualTo("Imp3"); + assertThat(bid.getType()).isEqualTo(BidType.banner); + }, + bid -> { + assertThat(bid.getBid().getImpid()).isEqualTo("Imp4"); + assertThat(bid.getType()).isEqualTo(BidType.video); + } + ); + } + + private static BidRequest givenBidRequest(UnaryOperator impCustomizer) { + return givenBidRequest(identity(), impCustomizer); + } + + private static BidRequest givenBidRequest( + UnaryOperator bidRequestCustomizer, + UnaryOperator impCustomizer) { + + return bidRequestCustomizer.apply(BidRequest.builder() + .imp(singletonList(givenImp(impCustomizer)))) + .build(); + } + + private static Imp givenImp(UnaryOperator impCustomizer) { + return givenImp(impCustomizer, ExtImpSynapseHX.of("tenant-id")); + } + + private static Imp givenImp(UnaryOperator impCustomizer, ExtImpSynapseHX extImp) { + return impCustomizer.apply(Imp.builder() + .id("345") + .banner(Banner.builder().build()) + .ext(mapper.valueToTree(ExtPrebid.of(null, extImp)))) + .build(); + } + + private static BidderCall givenHttpCall(BidRequest bidRequest, String body) { + return BidderCall.succeededHttp( + HttpRequest.builder().payload(bidRequest).build(), + HttpResponse.of(200, null, body), + null); + } + + private BidderCall givenHttpCall(BidRequest bidRequest, BidResponse bidResponse) + throws JsonProcessingException { + return givenHttpCall(bidRequest, mapper.writeValueAsString(bidResponse)); + } + + private static BidResponse givenBidResponse(Bid... bids) { + return BidResponse.builder().seatbid(singletonList( + SeatBid.builder().bid(asList(bids)).build())).build(); + } + + private static Bid givenBid(String impid, BidType bidType) { + return Bid.builder() + .impid(impid) + .ext(mapper.createObjectNode() + .set("prebid", mapper.createObjectNode() + .put("type", bidType.getName()))) + .build(); + } + + private static Bid givenBidWithMType(String impid, BidType bidType) { + return Bid.builder().impid(impid).mtype(bidType.ordinal() + 1).build(); + } + +} diff --git a/src/test/java/org/prebid/server/it/SynapseHXTest.java b/src/test/java/org/prebid/server/it/SynapseHXTest.java new file mode 100644 index 00000000000..8d7d51957e4 --- /dev/null +++ b/src/test/java/org/prebid/server/it/SynapseHXTest.java @@ -0,0 +1,33 @@ +package org.prebid.server.it; + +import io.restassured.response.Response; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.prebid.server.model.Endpoint; + +import java.io.IOException; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static java.util.Collections.singletonList; + +public class SynapseHXTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromSynapseHX() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/synapsehx-exchange")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/synapsehx/test-synapsehx-bid-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/synapsehx/test-synapsehx-bid-response.json")))); + + // when + final Response response = responseFor("openrtb2/synapsehx/test-auction-synapsehx-request.json", + Endpoint.openrtb2_auction); + + // then + assertJsonEquals("openrtb2/synapsehx/test-auction-synapsehx-response.json", + response, singletonList("synapsehx")); + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-auction-synapsehx-request.json b/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-auction-synapsehx-request.json new file mode 100644 index 00000000000..840c0894a31 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-auction-synapsehx-request.json @@ -0,0 +1,23 @@ +{ + "id": "tid", + "imp": [ + { + "id": "impId001", + "banner": { + "w": 300, + "h": 250 + }, + "ext": { + "synapsehx": { + "tenantId": "tenant-id-0" + } + } + } + ], + "tmax": 5000, + "regs": { + "ext": { + "gdpr": 0 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-auction-synapsehx-response.json b/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-auction-synapsehx-response.json new file mode 100644 index 00000000000..ddb27b81e7a --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-auction-synapsehx-response.json @@ -0,0 +1,40 @@ +{ + "id": "tid", + "seatbid": [ + { + "bid": [ + { + "id": "bid001", + "impid": "impId001", + "price": 3.33, + "crid": "crid001", + "w": 300, + "h": 250, + "exp" : 300, + "mtype": 1, + "ext": { + "prebid": { + "type": "banner", + "meta" : { + "adaptercode" : "synapsehx" + } + }, + "origbidcpm" : 3.33 + } + } + ], + "seat": "synapsehx", + "group": 0 + } + ], + "cur": "USD", + "ext": { + "responsetimemillis": { + "synapsehx": "{{ synapsehx.response_time_ms }}" + }, + "prebid": { + "auctiontimestamp": 1000 + }, + "tmaxrequest": 5000 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-synapsehx-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-synapsehx-bid-request.json new file mode 100644 index 00000000000..355afd6f14e --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-synapsehx-bid-request.json @@ -0,0 +1,57 @@ +{ + "id": "tid", + "imp": [ + { + "id": "impId001", + "banner": { + "w": 300, + "h": 250 + }, + "secure": 1, + "ext": { + "tid": "${json-unit.any-string}", + "bidder": { + "tenantId": "tenant-id-0" + } + } + } + ], + "site": { + "domain": "www.example.com", + "page": "http://www.example.com", + "publisher": { + "domain": "example.com" + }, + "ext": { + "amp": 0 + } + }, + "device": { + "ua": "userAgent", + "ip": "193.168.244.1" + }, + "at": 1, + "tmax": "${json-unit.any-number}", + "cur": [ + "USD" + ], + "source": { + "tid": "${json-unit.any-string}" + }, + "regs": { + "ext": { + "gdpr": 0 + } + }, + "ext": { + "prebid": { + "server": { + "externalurl": "http://localhost:8080", + "gvlid": 1, + "datacenter": "local", + "http_method" : "POST", + "endpoint": "/openrtb2/auction" + } + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-synapsehx-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-synapsehx-bid-response.json new file mode 100644 index 00000000000..72cab5f0364 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/synapsehx/test-synapsehx-bid-response.json @@ -0,0 +1,18 @@ +{ + "id": "tid", + "seatbid": [ + { + "bid": [ + { + "id": "bid001", + "impid": "impId001", + "price": 3.33, + "crid": "crid001", + "h": 250, + "w": 300, + "mtype": 1 + } + ] + } + ] +} 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 3aef0d8f20e..841d458c749 100644 --- a/src/test/resources/org/prebid/server/it/test-application.properties +++ b/src/test/resources/org/prebid/server/it/test-application.properties @@ -587,6 +587,8 @@ adapters.startio.enabled=true adapters.startio.endpoint=http://localhost:8090/startio-exchange adapters.stroeercore..enabled=true adapters.stroeercore.endpoint=http://localhost:8090/stroeercore-exchange +adapters.synapsehx.enabled=true +adapters.synapsehx.endpoint=http://localhost:8090/synapsehx-exchange adapters.suntContent.enabled=true adapters.suntContent.endpoint=http://localhost:8090/suntContent-exchange adapters.taboola.enabled=true