From c3a87d6ddaa57f9c983d25c6ee6c9f4a3f11f028 Mon Sep 17 00:00:00 2001 From: Anton Babak <76536883+AntoxaAntoxic@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:26:13 +0200 Subject: [PATCH 1/5] Visx Adapter: Relay bid currency from bid respons (#4077) --- .../prebid/server/bidder/visx/VisxBidder.java | 64 +++++++++++++------ .../bidder/visx/model/VisxResponse.java | 2 + src/main/resources/bidder-config/visx.yaml | 2 +- .../server/bidder/visx/VisxBidderTest.java | 61 ++++++++++++++++-- 4 files changed, 100 insertions(+), 29 deletions(-) diff --git a/src/main/java/org/prebid/server/bidder/visx/VisxBidder.java b/src/main/java/org/prebid/server/bidder/visx/VisxBidder.java index e7f91b12717..49e64645206 100644 --- a/src/main/java/org/prebid/server/bidder/visx/VisxBidder.java +++ b/src/main/java/org/prebid/server/bidder/visx/VisxBidder.java @@ -1,11 +1,14 @@ package org.prebid.server.bidder.visx; -import com.fasterxml.jackson.databind.JsonNode; +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.Device; import com.iab.openrtb.request.Imp; import com.iab.openrtb.response.Bid; +import io.vertx.core.MultiMap; 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; @@ -18,7 +21,10 @@ 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.response.BidType; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebid; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebidMeta; import org.prebid.server.util.BidderUtil; import org.prebid.server.util.HttpUtil; @@ -26,6 +32,7 @@ import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.Set; public class VisxBidder implements Bidder { @@ -33,6 +40,9 @@ public class VisxBidder implements Bidder { private static final String DEFAULT_REQUEST_CURRENCY = "USD"; private static final Set SUPPORTED_BID_TYPES_TEXTUAL = Set.of("banner", "video"); + private static final TypeReference> BID_EXT_TYPE_REFERENCE = new TypeReference<>() { + }; + private final String endpointUrl; private final JacksonMapper mapper; @@ -43,20 +53,28 @@ public VisxBidder(String endpointUrl, JacksonMapper mapper) { @Override public Result>> makeHttpRequests(BidRequest request) { - return Result.withValue(makeRequest(request)); - } - - private HttpRequest makeRequest(BidRequest bidRequest) { - final BidRequest outgoingRequest = modifyRequest(bidRequest); - return BidderUtil.defaultRequest(outgoingRequest, endpointUrl, mapper); + final BidRequest outgoingRequest = modifyRequest(request); + return Result.withValue( + BidderUtil.defaultRequest(outgoingRequest, makeHeaders(request.getDevice()), endpointUrl, mapper)); } - private BidRequest modifyRequest(BidRequest bidRequest) { + private static BidRequest modifyRequest(BidRequest bidRequest) { return CollectionUtils.isEmpty(bidRequest.getCur()) ? bidRequest.toBuilder().cur(Collections.singletonList(DEFAULT_REQUEST_CURRENCY)).build() : bidRequest; } + private static MultiMap makeHeaders(Device device) { + final MultiMap headers = HttpUtil.headers(); + + if (device != null) { + HttpUtil.addHeaderIfValueIsNotEmpty(headers, HttpUtil.X_FORWARDED_FOR_HEADER, device.getIp()); + HttpUtil.addHeaderIfValueIsNotEmpty(headers, HttpUtil.X_FORWARDED_FOR_HEADER, device.getIpv6()); + } + + return headers; + } + @Override public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { try { @@ -80,14 +98,14 @@ private List bidsFromResponse(BidRequest bidRequest, VisxResponse vis .map(VisxSeatBid::getBid) .filter(Objects::nonNull) .flatMap(Collection::stream) - .map(visxBid -> toBidderBid(bidRequest, visxBid)) + .map(visxBid -> toBidderBid(bidRequest, visxBid, visxResponse.getCur())) .toList(); } - private BidderBid toBidderBid(BidRequest bidRequest, VisxBid visxBid) { + private BidderBid toBidderBid(BidRequest bidRequest, VisxBid visxBid, String currency) { final Bid bid = toBid(visxBid, bidRequest.getId()); final BidType bidType = getBidType(bid.getExt(), bid.getImpid(), bidRequest.getImp()); - return BidderBid.of(bid, bidType, null); + return BidderBid.of(bid, bidType, StringUtils.defaultIfBlank(currency, null)); } private static Bid toBid(VisxBid visxBid, String id) { @@ -105,20 +123,24 @@ private static Bid toBid(VisxBid visxBid, String id) { .build(); } - private static BidType getBidType(ObjectNode bidExt, String impId, List imps) { + private BidType getBidType(ObjectNode bidExt, String impId, List imps) { final BidType extBidType = getBidTypeFromExt(bidExt); return extBidType != null ? extBidType : getBidTypeFromImp(impId, imps); } - private static BidType getBidTypeFromExt(ObjectNode bidExt) { - final JsonNode mediaTypeNode = bidExt != null ? bidExt.at("/prebid/meta/mediaType") : null; - final String bidTypeTextual = mediaTypeNode != null && mediaTypeNode.isTextual() - ? mediaTypeNode.asText() - : null; - - return bidTypeTextual != null && SUPPORTED_BID_TYPES_TEXTUAL.contains(bidTypeTextual) - ? BidType.valueOf(bidTypeTextual) - : null; + private BidType getBidTypeFromExt(ObjectNode bidExt) { + try { + return Optional.ofNullable(bidExt) + .map(ext -> mapper.mapper().convertValue(bidExt, BID_EXT_TYPE_REFERENCE)) + .map(ExtPrebid::getPrebid) + .map(ExtBidPrebid::getMeta) + .map(ExtBidPrebidMeta::getMediaType) + .filter(SUPPORTED_BID_TYPES_TEXTUAL::contains) + .map(BidType::valueOf) + .orElse(null); + } catch (IllegalArgumentException e) { + return null; + } } private static BidType getBidTypeFromImp(String impId, List imps) { diff --git a/src/main/java/org/prebid/server/bidder/visx/model/VisxResponse.java b/src/main/java/org/prebid/server/bidder/visx/model/VisxResponse.java index 0022d773875..df156830167 100644 --- a/src/main/java/org/prebid/server/bidder/visx/model/VisxResponse.java +++ b/src/main/java/org/prebid/server/bidder/visx/model/VisxResponse.java @@ -8,4 +8,6 @@ public class VisxResponse { List seatbid; + + String cur; } diff --git a/src/main/resources/bidder-config/visx.yaml b/src/main/resources/bidder-config/visx.yaml index 9d99f8a31f0..df8384fc6c1 100644 --- a/src/main/resources/bidder-config/visx.yaml +++ b/src/main/resources/bidder-config/visx.yaml @@ -1,6 +1,6 @@ adapters: visx: - endpoint: https://t.visx.net/s2s_bid?wrapperType=s2s_prebid_java + endpoint: https://t.visx.net/s2s_bid?wrapperType=s2s_prebid_standard:0.1.2 meta-info: maintainer-email: supply.partners@yoc.com app-media-types: diff --git a/src/test/java/org/prebid/server/bidder/visx/VisxBidderTest.java b/src/test/java/org/prebid/server/bidder/visx/VisxBidderTest.java index cd11937bd94..d3640d344b7 100644 --- a/src/test/java/org/prebid/server/bidder/visx/VisxBidderTest.java +++ b/src/test/java/org/prebid/server/bidder/visx/VisxBidderTest.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.node.TextNode; import com.iab.openrtb.request.Banner; import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; import com.iab.openrtb.request.Imp; import com.iab.openrtb.request.Video; import com.iab.openrtb.response.Bid; @@ -22,16 +23,19 @@ import org.prebid.server.bidder.visx.model.VisxSeatBid; import org.prebid.server.proto.openrtb.ext.ExtPrebid; import org.prebid.server.proto.openrtb.ext.request.visx.ExtImpVisx; +import org.prebid.server.util.HttpUtil; import java.math.BigDecimal; import java.util.Arrays; import java.util.List; +import java.util.Map; 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.prebid.server.proto.openrtb.ext.response.BidType.banner; import static org.prebid.server.proto.openrtb.ext.response.BidType.video; @@ -68,6 +72,48 @@ public void makeHttpRequestsShouldNotModifyIncomingRequest() { .containsExactly(bidRequest); } + @Test + public void makeHttpRequestsShouldAddIp() { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(Imp.builder() + .ext(mapper.valueToTree(ExtPrebid.of(null, + ExtImpVisx.of(123, Arrays.asList(10, 20))))) + .build())) + .device(Device.builder().ip("someIp").ipv6("ipv6").build()) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()) + .flatExtracting(res -> res.getHeaders().entries()) + .extracting(Map.Entry::getKey, Map.Entry::getValue) + .contains(tuple(HttpUtil.X_FORWARDED_FOR_HEADER.toString(), "someIp")); + } + + @Test + public void makeHttpRequestsShouldAddIpv6IfIpIsNotPresent() { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(Imp.builder() + .ext(mapper.valueToTree(ExtPrebid.of(null, + ExtImpVisx.of(123, Arrays.asList(10, 20))))) + .build())) + .device(Device.builder().ipv6("ipv6").build()) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()) + .flatExtracting(res -> res.getHeaders().entries()) + .extracting(Map.Entry::getKey, Map.Entry::getValue) + .contains(tuple(HttpUtil.X_FORWARDED_FOR_HEADER.toString(), "ipv6")); + } + @Test public void makeBidsShouldReturnErrorIfResponseBodyCouldNotBeParsed() { // given @@ -122,7 +168,7 @@ public void makeBidsShouldReturnBidWithTypeBannerIfBannerIsPresent() throws Json // then assertThat(result.getErrors()).isEmpty(); assertThat(result.getValue()).containsExactly( - BidderBid.of(Bid.builder().id("id").impid("123").build(), banner, null)); + BidderBid.of(Bid.builder().id("id").impid("123").build(), banner, "USD")); } @Test @@ -138,7 +184,7 @@ public void makeBidsShouldReturnBidWithTypeBannerIfVideoIsPresentAndBannerIsAbse // then assertThat(result.getErrors()).isEmpty(); assertThat(result.getValue()).containsExactly( - BidderBid.of(Bid.builder().id("id").impid("123").build(), video, null)); + BidderBid.of(Bid.builder().id("id").impid("123").build(), video, "USD")); } @Test @@ -194,7 +240,7 @@ public void makeBidsShouldFavourBidExtMediaTypeToImpMediaTypeWhenPresent() throw // then assertThat(result.getErrors()).isEmpty(); assertThat(result.getValue()) - .containsExactly(BidderBid.of(givenBid(identity()), banner, null)); + .containsExactly(BidderBid.of(givenBid(identity()), banner, "USD")); } @Test @@ -213,7 +259,7 @@ public void makeBidsShouldReturnImpMediaTypeWhenBidExtMediaTypeIsAbsent() throws // then assertThat(result.getErrors()).isEmpty(); assertThat(result.getValue()).containsExactly( - BidderBid.of(givenBid(bidBuilder -> bidBuilder.ext(null)), video, null)); + BidderBid.of(givenBid(bidBuilder -> bidBuilder.ext(null)), video, "USD")); } @Test @@ -235,7 +281,7 @@ public void makeBidsShouldReturnImpMediaTypeWhenBidExtMediaTypeIsInvalid() throw // then assertThat(result.getErrors()).isEmpty(); assertThat(result.getValue()).containsExactly( - BidderBid.of(givenBid(bidBuilder -> bidBuilder.ext(givenBidExt("123"))), video, null)); + BidderBid.of(givenBid(bidBuilder -> bidBuilder.ext(givenBidExt("123"))), video, "USD")); } @Test @@ -289,7 +335,8 @@ public void makeBidsShouldReturnCorrectBidderBid() throws JsonProcessingExceptio .h(100) .adomain(singletonList("adomain")) .build(), - video, null); + video, + "USD"); assertThat(result.getErrors()).isEmpty(); assertThat(result.getValue()).containsExactly(expected); @@ -314,7 +361,7 @@ private static Imp givenImp(UnaryOperator impCustomizer) { private static VisxResponse givenVisxResponse(UnaryOperator bidCustomizer, String seat) { return VisxResponse.of(singletonList(VisxSeatBid.of( - singletonList(bidCustomizer.apply(VisxBid.builder()).build()), seat))); + singletonList(bidCustomizer.apply(VisxBid.builder()).build()), seat)), "USD"); } private static Bid givenBid(UnaryOperator bidCustomizer) { From 7e97ae70e5c4711b51cbe2b2721aef20a1bf0801 Mon Sep 17 00:00:00 2001 From: Anton Babak <76536883+AntoxaAntoxic@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:26:24 +0200 Subject: [PATCH 2/5] New Madsense Adapter (#3972) --- .../bidder/madsense/MadsenseBidder.java | 186 +++++++++ .../ext/request/madsense/ExtImpMadsense.java | 9 + .../config/bidder/MadsenseConfiguration.java | 41 ++ .../resources/bidder-config/madsense.yaml | 13 + .../static/bidder-params/madsense.json | 16 + .../bidder/madsense/MadsenseBidderTest.java | 368 ++++++++++++++++++ .../org/prebid/server/it/MadsenseTest.java | 33 ++ .../test-auction-madsense-request.json | 23 ++ .../test-auction-madsense-response.json | 40 ++ .../madsense/test-madsense-bid-request.json | 56 +++ .../madsense/test-madsense-bid-response.json | 19 + .../server/it/test-application.properties | 2 + 12 files changed, 806 insertions(+) create mode 100644 src/main/java/org/prebid/server/bidder/madsense/MadsenseBidder.java create mode 100644 src/main/java/org/prebid/server/proto/openrtb/ext/request/madsense/ExtImpMadsense.java create mode 100644 src/main/java/org/prebid/server/spring/config/bidder/MadsenseConfiguration.java create mode 100644 src/main/resources/bidder-config/madsense.yaml create mode 100644 src/main/resources/static/bidder-params/madsense.json create mode 100644 src/test/java/org/prebid/server/bidder/madsense/MadsenseBidderTest.java create mode 100644 src/test/java/org/prebid/server/it/MadsenseTest.java create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/madsense/test-auction-madsense-request.json create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/madsense/test-auction-madsense-response.json create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/madsense/test-madsense-bid-request.json create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/madsense/test-madsense-bid-response.json diff --git a/src/main/java/org/prebid/server/bidder/madsense/MadsenseBidder.java b/src/main/java/org/prebid/server/bidder/madsense/MadsenseBidder.java new file mode 100644 index 00000000000..5fdfe844c19 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/madsense/MadsenseBidder.java @@ -0,0 +1,186 @@ +package org.prebid.server.bidder.madsense; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Site; +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.madsense.ExtImpMadsense; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebidVideo; +import org.prebid.server.util.BidderUtil; +import org.prebid.server.util.HttpUtil; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +public class MadsenseBidder implements Bidder { + + private static final String X_OPENRTB_VERSION_HEADER_VALUE = "2.6"; + private static final TypeReference> TYPE_REFERENCE = new TypeReference<>() { + }; + + private final String endpointUrl; + private final JacksonMapper mapper; + + public MadsenseBidder(String endpointUrl, JacksonMapper mapper) { + this.endpointUrl = HttpUtil.validateUrl(Objects.requireNonNull(endpointUrl)); + this.mapper = Objects.requireNonNull(mapper); + } + + @Override + public Result>> makeHttpRequests(BidRequest request) { + final List> httpRequests = new ArrayList<>(); + final List errors = new ArrayList<>(); + final List videoImps = new ArrayList<>(); + + for (Imp imp : request.getImp()) { + if (imp.getBanner() != null) { + try { + httpRequests.add(makeHttpRequest(request, Collections.singletonList(imp))); + } catch (PreBidException e) { + errors.add(BidderError.badInput(e.getMessage())); + } + } else if (imp.getVideo() != null) { + videoImps.add(imp); + } + } + + if (CollectionUtils.isNotEmpty(videoImps)) { + try { + httpRequests.add(makeHttpRequest(request, videoImps)); + } catch (PreBidException e) { + errors.add(BidderError.badInput(e.getMessage())); + } + } + + return Result.of(httpRequests, errors); + } + + private ExtImpMadsense parseImpExt(Imp imp) { + try { + return mapper.mapper().convertValue(imp.getExt(), TYPE_REFERENCE).getBidder(); + } catch (IllegalArgumentException e) { + throw new PreBidException("Error parsing imp.ext parameters"); + } + } + + private HttpRequest makeHttpRequest(BidRequest request, List imps) { + final Imp firstImp = request.getImp().getFirst(); + final ExtImpMadsense extImp = parseImpExt(firstImp); + final String companyId = Objects.equals(request.getTest(), 1) ? "test" : extImp.getCompanyId(); + return BidderUtil.defaultRequest( + request.toBuilder().imp(imps).build(), + makeHeaders(request), + makeEndpoint(companyId), + mapper); + } + + private static MultiMap makeHeaders(BidRequest request) { + final MultiMap headers = HttpUtil.headers() + .set(HttpUtil.X_OPENRTB_VERSION_HEADER, X_OPENRTB_VERSION_HEADER_VALUE); + + final Device device = request.getDevice(); + if (device != null) { + HttpUtil.addHeaderIfValueIsNotEmpty(headers, HttpUtil.USER_AGENT_HEADER, device.getUa()); + HttpUtil.addHeaderIfValueIsNotEmpty(headers, HttpUtil.X_FORWARDED_FOR_HEADER, device.getIp()); + HttpUtil.addHeaderIfValueIsNotEmpty(headers, HttpUtil.X_FORWARDED_FOR_HEADER, device.getIpv6()); + } + + final Site site = request.getSite(); + if (site != null) { + HttpUtil.addHeaderIfValueIsNotEmpty(headers, HttpUtil.ORIGIN_HEADER, site.getDomain()); + HttpUtil.addHeaderIfValueIsNotEmpty(headers, HttpUtil.REFERER_HEADER, site.getRef()); + } + + return headers; + } + + private String makeEndpoint(String companyId) { + return endpointUrl + "?company_id=" + HttpUtil.encodeUrl(companyId); + } + + @Override + public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + final List errors = new ArrayList<>(); + try { + final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); + return Result.of(extractBids(bidResponse, errors), errors); + } catch (DecodeException e) { + return Result.withError(BidderError.badServerResponse(e.getMessage())); + } + } + + private static List extractBids(BidResponse bidResponse, List errors) { + if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) { + return Collections.emptyList(); + } + return bidsFromResponse(bidResponse, errors); + } + + private static List bidsFromResponse(BidResponse bidResponse, List errors) { + return bidResponse.getSeatbid().stream() + .filter(Objects::nonNull) + .map(SeatBid::getBid) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .filter(Objects::nonNull) + .map(bid -> makeBidderBid(bid, bidResponse.getCur(), errors)) + .filter(Objects::nonNull) + .toList(); + } + + private static BidderBid makeBidderBid(Bid bid, String currency, List errors) { + try { + final BidType bidType = getBidType(bid); + return BidderBid.builder() + .bid(bid) + .bidCurrency(currency) + .videoInfo(bidType == BidType.video + ? ExtBidPrebidVideo.of(resolveDuration(bid), resolveCategory(bid)) + : null) + .type(bidType) + .build(); + } catch (PreBidException e) { + errors.add(BidderError.badServerResponse(e.getMessage())); + return null; + } + } + + private static BidType getBidType(Bid bid) { + return switch (bid.getMtype()) { + case 1 -> BidType.banner; + case 2 -> BidType.video; + case null, default -> throw new PreBidException( + "Unsupported bid mediaType: %s for impression: %s".formatted(bid.getMtype(), bid.getImpid())); + }; + } + + private static String resolveCategory(Bid bid) { + final List categories = bid.getCat(); + return CollectionUtils.isEmpty(categories) ? null : categories.getFirst(); + } + + private static Integer resolveDuration(Bid bid) { + final Integer duration = bid.getDur(); + return duration != null && duration > 0 ? duration : null; + } +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/madsense/ExtImpMadsense.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/madsense/ExtImpMadsense.java new file mode 100644 index 00000000000..6725007f682 --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/madsense/ExtImpMadsense.java @@ -0,0 +1,9 @@ +package org.prebid.server.proto.openrtb.ext.request.madsense; + +import lombok.Value; + +@Value(staticConstructor = "of") +public class ExtImpMadsense { + + String companyId; +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/MadsenseConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/MadsenseConfiguration.java new file mode 100644 index 00000000000..a1cc34057e3 --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/MadsenseConfiguration.java @@ -0,0 +1,41 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.madsense.MadsenseBidder; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.spring.config.bidder.model.BidderConfigurationProperties; +import org.prebid.server.spring.config.bidder.util.BidderDepsAssembler; +import org.prebid.server.spring.config.bidder.util.UsersyncerCreator; +import org.prebid.server.spring.env.YamlPropertySourceFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +import jakarta.validation.constraints.NotBlank; + +@Configuration +@PropertySource(value = "classpath:/bidder-config/madsense.yaml", factory = YamlPropertySourceFactory.class) +public class MadsenseConfiguration { + + private static final String BIDDER_NAME = "madsense"; + + @Bean("madsenseConfigurationProperties") + @ConfigurationProperties("adapters.madsense") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps madsenseBidderDeps(BidderConfigurationProperties madsenseConfigurationProperties, + @NotBlank @Value("${external-url}") String externalUrl, + JacksonMapper mapper) { + + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(madsenseConfigurationProperties) + .usersyncerCreator(UsersyncerCreator.create(externalUrl)) + .bidderCreator(config -> new MadsenseBidder(config.getEndpoint(), mapper)) + .assemble(); + } +} diff --git a/src/main/resources/bidder-config/madsense.yaml b/src/main/resources/bidder-config/madsense.yaml new file mode 100644 index 00000000000..29dc214b093 --- /dev/null +++ b/src/main/resources/bidder-config/madsense.yaml @@ -0,0 +1,13 @@ +adapters: + madsense: + endpoint: https://ads.madsense.io/pbs + meta-info: + maintainer-email: prebid@madsense.io + app-media-types: + - banner + - video + site-media-types: + - banner + - video + supported-vendors: + vendor-id: 0 diff --git a/src/main/resources/static/bidder-params/madsense.json b/src/main/resources/static/bidder-params/madsense.json new file mode 100644 index 00000000000..f45ac81f3ed --- /dev/null +++ b/src/main/resources/static/bidder-params/madsense.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "madSense Adapter Params", + "description": "A schema which validates params accepted by the madSense adapter", + "type": "object", + "properties": { + "company_id": { + "type": "string", + "description": "An id used to identify madSense company", + "minLength": 1 + } + }, + "required": [ + "company_id" + ] +} diff --git a/src/test/java/org/prebid/server/bidder/madsense/MadsenseBidderTest.java b/src/test/java/org/prebid/server/bidder/madsense/MadsenseBidderTest.java new file mode 100644 index 00000000000..ac4933dc439 --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/madsense/MadsenseBidderTest.java @@ -0,0 +1,368 @@ +package org.prebid.server.bidder.madsense; + +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.Device; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Site; +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.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +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.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.madsense.ExtImpMadsense; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebidVideo; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.function.UnaryOperator; + +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.prebid.server.bidder.model.BidderError.Type; +import static org.prebid.server.bidder.model.BidderError.badInput; +import static org.prebid.server.bidder.model.BidderError.badServerResponse; +import static org.prebid.server.util.HttpUtil.ACCEPT_HEADER; +import static org.prebid.server.util.HttpUtil.APPLICATION_JSON_CONTENT_TYPE; +import static org.prebid.server.util.HttpUtil.CONTENT_TYPE_HEADER; +import static org.prebid.server.util.HttpUtil.ORIGIN_HEADER; +import static org.prebid.server.util.HttpUtil.REFERER_HEADER; +import static org.prebid.server.util.HttpUtil.USER_AGENT_HEADER; +import static org.prebid.server.util.HttpUtil.X_FORWARDED_FOR_HEADER; +import static org.prebid.server.util.HttpUtil.X_OPENRTB_VERSION_HEADER; +import static org.springframework.util.MimeTypeUtils.APPLICATION_JSON_VALUE; + +@ExtendWith(MockitoExtension.class) +public class MadsenseBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "https://ads.madsense.io/pbs"; + + private MadsenseBidder target; + + @BeforeEach + public void setUp() { + target = new MadsenseBidder(ENDPOINT_URL, jacksonMapper); + } + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + assertThatIllegalArgumentException().isThrownBy(() -> new MadsenseBidder("invalid_url", jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldReturnErrorWhenFirstImpExtCouldNotBeParsed() { + // given + final BidRequest bidRequest = givenBidRequest(imp -> imp + .banner(Banner.builder().build()) + .ext(givenInvalidImpExt())); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1).containsExactly(badInput("Error parsing imp.ext parameters")); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldCreateSeparateRequestForEachBannerImp() { + // given + final BidRequest bidRequest = givenBidRequest( + builder -> builder.id("bannerImp1").banner(Banner.builder().build()) + .ext(givenImpExt("testCompanyId")), + builder -> builder.id("bannerImp2").banner(Banner.builder().build()) + .ext(givenImpExt("otherCompanyId"))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(2); + assertThat(result.getValue()) + .extracting(HttpRequest::getImpIds) + .containsExactlyInAnyOrder(Set.of("bannerImp1"), Set.of("bannerImp2")); + + assertThat(result.getValue()) + .extracting(HttpRequest::getUri) + .containsOnly(ENDPOINT_URL + "?company_id=testCompanyId"); + } + + @Test + public void makeHttpRequestsShouldCreateOneRequestForAllVideoImps() { + // given + final BidRequest bidRequest = givenBidRequest( + builder -> builder.id("videoImp1").video(Video.builder().build()) + .ext(givenImpExt("testCompanyId")), + builder -> builder.id("videoImp2").video(Video.builder().build()) + .ext(givenImpExt("otherCompanyId"))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1); + assertThat(result.getValue().getFirst().getImpIds()).containsExactlyInAnyOrder("videoImp1", "videoImp2"); + } + + @Test + public void makeHttpRequestsShouldHandleMixedBannerAndVideoImps() { + // given + final BidRequest bidRequest = givenBidRequest( + builder -> builder.id("bannerImp1").banner(Banner.builder().build()) + .ext(givenImpExt("testCompanyId")), + builder -> builder.id("videoImp1").video(Video.builder().build()) + .ext(givenImpExt("otherCompanyId1")), + builder -> builder.id("bannerImp2").banner(Banner.builder().build()) + .ext(givenImpExt("otherCompanyId2")), + builder -> builder.id("videoImp2").video(Video.builder().build()) + .ext(givenImpExt("otherCompanyId3"))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(3) + .extracting(HttpRequest::getImpIds) + .containsExactly(Set.of("bannerImp1"), Set.of("bannerImp2"), Set.of("videoImp1", "videoImp2")); + } + + @Test + public void makeHttpRequestsShouldUseTestCompanyIdWhenRequestTestIsOne() { + // given + final BidRequest bidRequest = givenBidRequest( + builder -> builder.banner(Banner.builder().build()).ext(givenImpExt("testCompanyId"))) + .toBuilder() + .test(1) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getUri) + .containsExactly(ENDPOINT_URL + "?company_id=test"); + } + + @Test + public void makeHttpRequestsShouldSetCorrectHeaders() { + // given + final BidRequest bidRequest = givenBidRequest(imp -> imp.banner(Banner.builder().build())) + .toBuilder() + .device(Device.builder().ua("ua").ip("ip").ipv6("ipv6").build()) + .site(Site.builder().domain("domain").ref("referrer").build()) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1).first() + .extracting(HttpRequest::getHeaders) + .satisfies(headers -> { + assertThat(headers.get(CONTENT_TYPE_HEADER)).isEqualTo(APPLICATION_JSON_CONTENT_TYPE); + assertThat(headers.get(ACCEPT_HEADER)).isEqualTo(APPLICATION_JSON_VALUE); + assertThat(headers.get(X_OPENRTB_VERSION_HEADER)).isEqualTo("2.6"); + assertThat(headers.get(USER_AGENT_HEADER)).isEqualTo("ua"); + assertThat(headers.get(X_FORWARDED_FOR_HEADER)).isEqualTo("ip"); + assertThat(headers.get(ORIGIN_HEADER)).isEqualTo("domain"); + assertThat(headers.get(REFERER_HEADER)).isEqualTo("referrer"); + }); + } + + @Test + public void makeHttpRequestsShouldSetIpv6WhenIpIsMissing() { + // given + final BidRequest bidRequest = givenBidRequest(imp -> imp.banner(Banner.builder().build())) + .toBuilder() + .device(Device.builder().ipv6("ipv6").ip(null).build()) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue().getFirst().getHeaders().get(X_FORWARDED_FOR_HEADER)) + .isEqualTo("ipv6"); + } + + @Test + public void makeBidsShouldReturnErrorWhenResponseBodyCouldNotBeParsed() { + // given + final BidderCall httpCall = givenHttpCall("invalid_json"); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(Type.bad_server_response); + assertThat(error.getMessage()).startsWith("Failed to decode: Unrecognized token 'invalid_json'"); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnEmptyListWhenBidResponseIsNull() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(mapper.writeValueAsString(null)); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnEmptyListWhenSeatBidIsEmpty() throws JsonProcessingException { + // given + final BidResponse bidResponseEmptySeatBid = BidResponse.builder().seatbid(emptyList()).build(); + final BidderCall httpCall = givenHttpCall(mapper.writeValueAsString(bidResponseEmptySeatBid)); + + // when + final Result> resultEmptySeatBid = target.makeBids(httpCall, null); + + // then + assertThat(resultEmptySeatBid.getErrors()).isEmpty(); + assertThat(resultEmptySeatBid.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnBannerBidSuccessfully() throws JsonProcessingException { + // given + final Bid bannerBid = Bid.builder().id("bidId1").impid("impId1").price(BigDecimal.ONE) + .adm("adm1").mtype(1).cat(singletonList("cat1")).build(); + final BidResponse bidResponse = BidResponse.builder() + .cur("USD") + .seatbid(singletonList(SeatBid.builder().bid(singletonList(bannerBid)).build())) + .build(); + final BidderCall httpCall = givenHttpCall(mapper.writeValueAsString(bidResponse)); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .containsExactly(BidderBid.of(bannerBid, BidType.banner, "USD")); + } + + @Test + public void makeBidsShouldReturnVideoBidSuccessfullyWithVideoInfo() throws JsonProcessingException { + // given + final Bid videoBid = Bid.builder().id("bidId1").mtype(2).cat(singletonList("cat-video")).dur(30).build(); + final BidResponse bidResponse = BidResponse.builder() + .cur("EUR") + .seatbid(singletonList(SeatBid.builder().bid(singletonList(videoBid)).build())) + .build(); + final BidderCall httpCall = givenHttpCall(mapper.writeValueAsString(bidResponse)); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + final BidderBid expectedBidderBid = BidderBid.builder() + .bid(videoBid) + .type(BidType.video) + .bidCurrency("EUR") + .videoInfo(ExtBidPrebidVideo.of(30, "cat-video")) + .build(); + + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1).containsExactly(expectedBidderBid); + } + + @Test + public void makeBidsShouldReturnErrorWhenBidMtypeIsMissing() throws JsonProcessingException { + // given + final Bid bid = Bid.builder().id("bidId1").impid("impId1").build(); + final BidResponse bidResponse = BidResponse.builder() + .cur("USD") + .seatbid(singletonList(SeatBid.builder().bid(singletonList(bid)).build())) + .build(); + final BidderCall httpCall = givenHttpCall(mapper.writeValueAsString(bidResponse)); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1) + .containsExactly(badServerResponse("Unsupported bid mediaType: null for impression: impId1")); + } + + @Test + public void makeBidsShouldReturnErrorWhenBidMtypeIsUnsupported() throws JsonProcessingException { + // given + final Bid bid = Bid.builder().id("bidId1").impid("impId1").mtype(3).build(); + final BidResponse bidResponse = BidResponse.builder() + .cur("USD") + .seatbid(singletonList(SeatBid.builder() + .bid(singletonList(bid)).build())) + .build(); + final BidderCall httpCall = givenHttpCall(mapper.writeValueAsString(bidResponse)); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1) + .containsExactly(badServerResponse("Unsupported bid mediaType: 3 for impression: impId1")); + } + + private static BidRequest givenBidRequest(UnaryOperator... impCustomizers) { + final List imps = Arrays.stream(impCustomizers) + .map(MadsenseBidderTest::givenImp) + .toList(); + return BidRequest.builder().imp(imps).cur(singletonList("USD")).build(); + } + + private static Imp givenImp(UnaryOperator impCustomizer) { + return impCustomizer.apply(Imp.builder() + .id("test-imp-id") + .ext(givenImpExt("testCompanyId"))) + .build(); + } + + private static ObjectNode givenImpExt(String companyId) { + return mapper.valueToTree(ExtPrebid.of(null, ExtImpMadsense.of(companyId))); + } + + private static ObjectNode givenInvalidImpExt() { + return mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode())); + } + + private static BidderCall givenHttpCall(String body) { + return BidderCall.succeededHttp( + HttpRequest.builder().build(), + HttpResponse.of(200, null, body), + null); + } +} diff --git a/src/test/java/org/prebid/server/it/MadsenseTest.java b/src/test/java/org/prebid/server/it/MadsenseTest.java new file mode 100644 index 00000000000..fd7366fafb6 --- /dev/null +++ b/src/test/java/org/prebid/server/it/MadsenseTest.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 MadsenseTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromMadsense() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/madsense-exchange")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/madsense/test-madsense-bid-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/madsense/test-madsense-bid-response.json")))); + + // when + final Response response = responseFor("openrtb2/madsense/test-auction-madsense-request.json", + Endpoint.openrtb2_auction); + + // then + assertJsonEquals("openrtb2/madsense/test-auction-madsense-response.json", response, + singletonList("madsense")); + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/madsense/test-auction-madsense-request.json b/src/test/resources/org/prebid/server/it/openrtb2/madsense/test-auction-madsense-request.json new file mode 100644 index 00000000000..6dd62c37a6d --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/madsense/test-auction-madsense-request.json @@ -0,0 +1,23 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "banner": { + "w": 320, + "h": 250 + }, + "ext": { + "madsense": { + "company_id": "companyId" + } + } + } + ], + "tmax": 5000, + "regs": { + "ext": { + "gdpr": 0 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/madsense/test-auction-madsense-response.json b/src/test/resources/org/prebid/server/it/openrtb2/madsense/test-auction-madsense-response.json new file mode 100644 index 00000000000..cab85202f9e --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/madsense/test-auction-madsense-response.json @@ -0,0 +1,40 @@ +{ + "id": "request_id", + "seatbid": [ + { + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "exp": 300, + "price": 0.01, + "adid": "adid", + "cid": "cid", + "crid": "crid", + "mtype": 1, + "ext": { + "prebid": { + "type": "banner", + "meta": { + "adaptercode": "madsense" + } + }, + "origbidcpm": 0.01 + } + } + ], + "seat": "madsense", + "group": 0 + } + ], + "cur": "USD", + "ext": { + "responsetimemillis": { + "madsense": "{{ madsense.response_time_ms }}" + }, + "prebid": { + "auctiontimestamp": 0 + }, + "tmaxrequest": 5000 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/madsense/test-madsense-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/madsense/test-madsense-bid-request.json new file mode 100644 index 00000000000..69b9eb55ae2 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/madsense/test-madsense-bid-request.json @@ -0,0 +1,56 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "secure": 1, + "banner": { + "w": 320, + "h": 250 + }, + "ext": { + "tid": "${json-unit.any-string}", + "bidder": { + "company_id": "companyId" + } + } + } + ], + "source": { + "tid": "${json-unit.any-string}" + }, + "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" + ], + "regs": { + "ext": { + "gdpr": 0 + } + }, + "ext": { + "prebid": { + "server": { + "externalurl": "http://localhost:8080", + "gvlid": 1, + "datacenter": "local", + "endpoint": "/openrtb2/auction" + } + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/madsense/test-madsense-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/madsense/test-madsense-bid-response.json new file mode 100644 index 00000000000..0720cdf8a5e --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/madsense/test-madsense-bid-response.json @@ -0,0 +1,19 @@ +{ + "id": "request_id", + "seatbid": [ + { + "bid": [ + { + "crid": "crid", + "adid": "adid", + "price": 0.01, + "id": "bid_id", + "impid": "imp_id", + "cid": "cid", + "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 01ff79b8691..30a8e1be569 100644 --- a/src/test/resources/org/prebid/server/it/test-application.properties +++ b/src/test/resources/org/prebid/server/it/test-application.properties @@ -353,6 +353,8 @@ adapters.lunamedia.enabled=true adapters.lunamedia.endpoint=http://localhost:8090/lunamedia-exchange?pubid= adapters.mabidder.enabled=true adapters.mabidder.endpoint=http://localhost:8090/mabidder-exchange +adapters.madsense.enabled=true +adapters.madsense.endpoint=http://localhost:8090/madsense-exchange adapters.madvertise.enabled=true adapters.madvertise.endpoint=http://localhost:8090/madvertise-exchange adapters.marsmedia.enabled=true From 4a6fb6cf32063876bbd15d550e7226dd19e48f95 Mon Sep 17 00:00:00 2001 From: Anton Babak <76536883+AntoxaAntoxic@users.noreply.github.com> Date: Tue, 29 Jul 2025 14:26:58 +0200 Subject: [PATCH 3/5] RFC 3986 compatibility as optional for HttpApplicationSettings (#4057) --- docs/config-app.md | 1 + extra/pom.xml | 6 + pom.xml | 4 + .../settings/HttpApplicationSettings.java | 84 +++++---- .../spring/config/SettingsConfiguration.java | 13 +- .../java/org/prebid/server/util/HttpUtil.java | 12 ++ .../container/NetworkServiceContainer.groovy | 3 + .../scaffolding/HttpSettings.groovy | 43 +++++ .../functional/tests/HttpSettingsSpec.groovy | 177 +++++++++++++++++- .../settings/HttpApplicationSettingsTest.java | 108 +++++++++-- 10 files changed, 389 insertions(+), 62 deletions(-) diff --git a/docs/config-app.md b/docs/config-app.md index 52c454075a6..238a6511d37 100644 --- a/docs/config-app.md +++ b/docs/config-app.md @@ -346,6 +346,7 @@ For HTTP data source available next options: - `settings.http.amp-endpoint` - the url to fetch AMP stored requests. - `settings.http.video-endpoint` - the url to fetch video stored requests. - `settings.http.category-endpoint` - the url to fetch categories for long form video. +- `settings.http.rfc3986-compatible` - if equals to `true` the url will be build according to RFC 3986, `false` by default For account processing rules available next options: - `settings.enforce-valid-account` - if equals to `true` then request without account id will be rejected with 401. diff --git a/extra/pom.xml b/extra/pom.xml index b0e7b78c044..51a3974b9e6 100644 --- a/extra/pom.xml +++ b/extra/pom.xml @@ -39,6 +39,7 @@ 4.4 1.27.1 3.6.1 + 1.10.0 2.1 4.5.14 5.5.1 @@ -135,6 +136,11 @@ commons-math3 ${commons-math3.version} + + commons-validator + commons-validator + ${commons-validator.version} + org.apache.httpcomponents diff --git a/pom.xml b/pom.xml index b7573b1a282..f5df96e6d39 100644 --- a/pom.xml +++ b/pom.xml @@ -114,6 +114,10 @@ org.apache.httpcomponents httpclient + + commons-validator + commons-validator + com.github.seancfoley ipaddress diff --git a/src/main/java/org/prebid/server/settings/HttpApplicationSettings.java b/src/main/java/org/prebid/server/settings/HttpApplicationSettings.java index 98517003baf..3dce1bcc12d 100644 --- a/src/main/java/org/prebid/server/settings/HttpApplicationSettings.java +++ b/src/main/java/org/prebid/server/settings/HttpApplicationSettings.java @@ -8,6 +8,7 @@ import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.StringUtils; +import org.apache.http.client.utils.URIBuilder; import org.prebid.server.exception.PreBidException; import org.prebid.server.execution.timeout.Timeout; import org.prebid.server.json.DecodeException; @@ -25,6 +26,7 @@ import org.prebid.server.vertx.httpclient.HttpClient; import org.prebid.server.vertx.httpclient.model.HttpClientResponse; +import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -44,10 +46,14 @@ * In order to enable caching and reduce latency for read operations {@link HttpApplicationSettings} * can be decorated by {@link CachingApplicationSettings}. *

- * Expected the endpoint to satisfy the following API: + * Expected the endpoint to satisfy the following API (URL is encoded): *

* GET {endpoint}?request-ids=["req1","req2"]&imp-ids=["imp1","imp2","imp3"] *

+ * or settings.http.rfc3986-compatible is set to true + *

+ * * GET {endpoint}?request-id=req1&request-id=req2&imp-id=imp1&imp-id=imp2&imp-id=imp3 + * *

* This endpoint should return a payload like: *

  * {
@@ -76,20 +82,27 @@ public class HttpApplicationSettings implements ApplicationSettings {
     private final String categoryEndpoint;
     private final HttpClient httpClient;
     private final JacksonMapper mapper;
+    private final boolean isRfc3986Compatible;
+
+    public HttpApplicationSettings(HttpClient httpClient,
+                                   JacksonMapper mapper,
+                                   String endpoint,
+                                   String ampEndpoint,
+                                   String videoEndpoint,
+                                   String categoryEndpoint,
+                                   boolean isRfc3986Compatible) {
 
-    public HttpApplicationSettings(HttpClient httpClient, JacksonMapper mapper, String endpoint, String ampEndpoint,
-                                   String videoEndpoint, String categoryEndpoint) {
         this.httpClient = Objects.requireNonNull(httpClient);
         this.mapper = Objects.requireNonNull(mapper);
-        this.endpoint = HttpUtil.validateUrl(Objects.requireNonNull(endpoint));
-        this.ampEndpoint = HttpUtil.validateUrl(Objects.requireNonNull(ampEndpoint));
-        this.videoEndpoint = HttpUtil.validateUrl(Objects.requireNonNull(videoEndpoint));
-        this.categoryEndpoint = HttpUtil.validateUrl(Objects.requireNonNull(categoryEndpoint));
+        this.endpoint = HttpUtil.validateUrlSyntax(Objects.requireNonNull(endpoint));
+        this.ampEndpoint = HttpUtil.validateUrlSyntax(Objects.requireNonNull(ampEndpoint));
+        this.videoEndpoint = HttpUtil.validateUrlSyntax(Objects.requireNonNull(videoEndpoint));
+        this.categoryEndpoint = HttpUtil.validateUrlSyntax(Objects.requireNonNull(categoryEndpoint));
+        this.isRfc3986Compatible = isRfc3986Compatible;
     }
 
     @Override
     public Future getAccountById(String accountId, Timeout timeout) {
-
         return fetchAccountsByIds(Collections.singleton(accountId), timeout)
                 .map(accounts -> accounts.stream()
                         .findFirst()
@@ -111,15 +124,20 @@ private Future> fetchAccountsByIds(Set accountIds, Timeout
                 .recover(Future::failedFuture);
     }
 
-    private static String accountsRequestUrlFrom(String endpoint, Set accountIds) {
-        final StringBuilder url = new StringBuilder(endpoint);
-        url.append(endpoint.contains("?") ? "&" : "?");
-
-        if (!accountIds.isEmpty()) {
-            url.append("account-ids=[\"").append(joinIds(accountIds)).append("\"]");
+    private String accountsRequestUrlFrom(String endpoint, Set accountIds) {
+        try {
+            final URIBuilder uriBuilder = new URIBuilder(endpoint);
+            if (!accountIds.isEmpty()) {
+                if (isRfc3986Compatible) {
+                    accountIds.forEach(accountId -> uriBuilder.addParameter("account-id", accountId));
+                } else {
+                    uriBuilder.addParameter("account-ids", "[\"%s\"]".formatted(joinIds(accountIds)));
+                }
+            }
+            return uriBuilder.build().toString();
+        } catch (URISyntaxException e) {
+            throw new PreBidException("URL %s has bad syntax".formatted(endpoint));
         }
-
-        return url.toString();
     }
 
     private Future> processAccountsResponse(HttpClientResponse response, Set accountIds) {
@@ -165,9 +183,6 @@ public Future getAmpStoredData(String accountId, Set r
         return fetchStoredData(ampEndpoint, requestIds, Collections.emptySet(), timeout);
     }
 
-    /**
-     * Not supported and returns failed result.
-     */
     @Override
     public Future getVideoStoredData(String accountId, Set requestIds, Set impIds,
                                                        Timeout timeout) {
@@ -240,22 +255,27 @@ private Future fetchStoredData(String endpoint, Set re
                 .recover(exception -> failStoredDataResponse(exception, requestIds, impIds));
     }
 
-    private static String storeRequestUrlFrom(String endpoint, Set requestIds, Set impIds) {
-        final StringBuilder url = new StringBuilder(endpoint);
-        url.append(endpoint.contains("?") ? "&" : "?");
-
-        if (!requestIds.isEmpty()) {
-            url.append("request-ids=[\"").append(joinIds(requestIds)).append("\"]");
-        }
-
-        if (!impIds.isEmpty()) {
+    private String storeRequestUrlFrom(String endpoint, Set requestIds, Set impIds) {
+        try {
+            final URIBuilder uriBuilder = new URIBuilder(endpoint);
             if (!requestIds.isEmpty()) {
-                url.append("&");
+                if (isRfc3986Compatible) {
+                    requestIds.forEach(requestId -> uriBuilder.addParameter("request-id", requestId));
+                } else {
+                    uriBuilder.addParameter("request-ids", "[\"%s\"]".formatted(joinIds(requestIds)));
+                }
+            }
+            if (!impIds.isEmpty()) {
+                if (isRfc3986Compatible) {
+                    impIds.forEach(impId -> uriBuilder.addParameter("imp-id", impId));
+                } else {
+                    uriBuilder.addParameter("imp-ids", "[\"%s\"]".formatted(joinIds(impIds)));
+                }
             }
-            url.append("imp-ids=[\"").append(joinIds(impIds)).append("\"]");
+            return uriBuilder.build().toString();
+        } catch (URISyntaxException e) {
+            throw new PreBidException("URL %s has bad syntax".formatted(endpoint));
         }
-
-        return url.toString();
     }
 
     private static String joinIds(Set ids) {
diff --git a/src/main/java/org/prebid/server/spring/config/SettingsConfiguration.java b/src/main/java/org/prebid/server/spring/config/SettingsConfiguration.java
index 4e883ba2495..3f674ae814d 100644
--- a/src/main/java/org/prebid/server/spring/config/SettingsConfiguration.java
+++ b/src/main/java/org/prebid/server/spring/config/SettingsConfiguration.java
@@ -122,10 +122,17 @@ HttpApplicationSettings httpApplicationSettings(
                 @Value("${settings.http.endpoint}") String endpoint,
                 @Value("${settings.http.amp-endpoint}") String ampEndpoint,
                 @Value("${settings.http.video-endpoint}") String videoEndpoint,
-                @Value("${settings.http.category-endpoint}") String categoryEndpoint) {
+                @Value("${settings.http.category-endpoint}") String categoryEndpoint,
+                @Value("${settings.http.rfc3986-compatible:false}") boolean isRfc3986Compatible) {
 
-            return new HttpApplicationSettings(httpClient, mapper, endpoint, ampEndpoint, videoEndpoint,
-                    categoryEndpoint);
+            return new HttpApplicationSettings(
+                    httpClient,
+                    mapper,
+                    endpoint,
+                    ampEndpoint,
+                    videoEndpoint,
+                    categoryEndpoint,
+                    isRfc3986Compatible);
         }
     }
 
diff --git a/src/main/java/org/prebid/server/util/HttpUtil.java b/src/main/java/org/prebid/server/util/HttpUtil.java
index ad9dd8a9238..47a5f24eda8 100644
--- a/src/main/java/org/prebid/server/util/HttpUtil.java
+++ b/src/main/java/org/prebid/server/util/HttpUtil.java
@@ -8,6 +8,7 @@
 import io.vertx.core.http.HttpServerResponse;
 import io.vertx.ext.web.RoutingContext;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.validator.routines.UrlValidator;
 import org.prebid.server.log.ConditionalLogger;
 import org.prebid.server.log.Logger;
 import org.prebid.server.log.LoggerFactory;
@@ -78,12 +79,15 @@ public final class HttpUtil {
     public static final String MACROS_OPEN = "{{";
     public static final String MACROS_CLOSE = "}}";
 
+    private static final UrlValidator URL_VALIDAROR = UrlValidator.getInstance();
+
     private HttpUtil() {
     }
 
     /**
      * Checks the input string for using as URL.
      */
+    @Deprecated
     public static String validateUrl(String url) {
         if (containsMacrosses(url)) {
             return url;
@@ -96,6 +100,14 @@ public static String validateUrl(String url) {
         }
     }
 
+    public static String validateUrlSyntax(String url) {
+        if (containsMacrosses(url) || URL_VALIDAROR.isValid(url)) {
+            return url;
+        }
+
+        throw new IllegalArgumentException("URL supplied is not valid: " + url);
+    }
+
     // TODO: We need our own way to work with url macrosses
     private static boolean containsMacrosses(String url) {
         return StringUtils.contains(url, MACROS_OPEN) && StringUtils.contains(url, MACROS_CLOSE);
diff --git a/src/test/groovy/org/prebid/server/functional/testcontainers/container/NetworkServiceContainer.groovy b/src/test/groovy/org/prebid/server/functional/testcontainers/container/NetworkServiceContainer.groovy
index 53faa7165fa..8022f2e8dcc 100644
--- a/src/test/groovy/org/prebid/server/functional/testcontainers/container/NetworkServiceContainer.groovy
+++ b/src/test/groovy/org/prebid/server/functional/testcontainers/container/NetworkServiceContainer.groovy
@@ -8,6 +8,9 @@ class NetworkServiceContainer extends MockServerContainer {
 
     NetworkServiceContainer(String version) {
         super(DockerImageName.parse("mockserver/mockserver:mockserver-$version"))
+        def aliasWithTopLevelDomain = "${getNetworkAliases().first()}.com".toString()
+        withCreateContainerCmdModifier { it.withHostName(aliasWithTopLevelDomain) }
+        setNetworkAliases([aliasWithTopLevelDomain])
     }
 
     String getHostAndPort() {
diff --git a/src/test/groovy/org/prebid/server/functional/testcontainers/scaffolding/HttpSettings.groovy b/src/test/groovy/org/prebid/server/functional/testcontainers/scaffolding/HttpSettings.groovy
index de271b4123a..5af648b2bc0 100644
--- a/src/test/groovy/org/prebid/server/functional/testcontainers/scaffolding/HttpSettings.groovy
+++ b/src/test/groovy/org/prebid/server/functional/testcontainers/scaffolding/HttpSettings.groovy
@@ -1,13 +1,21 @@
 package org.prebid.server.functional.testcontainers.scaffolding
 
+import org.mockserver.matchers.Times
+import org.mockserver.model.Header
 import org.mockserver.model.HttpRequest
+import org.mockserver.model.HttpStatusCode
+import org.prebid.server.functional.model.ResponseModel
 import org.testcontainers.containers.MockServerContainer
 
 import static org.mockserver.model.HttpRequest.request
+import static org.mockserver.model.HttpResponse.response
+import static org.mockserver.model.HttpStatusCode.OK_200
+import static org.mockserver.model.MediaType.APPLICATION_JSON
 
 class HttpSettings extends NetworkScaffolding {
 
     private static final String ENDPOINT = "/stored-requests"
+    private static final String RFC_ENDPOINT = "/stored-requests-rfc"
     private static final String AMP_ENDPOINT = "/amp-stored-requests"
 
     HttpSettings(MockServerContainer mockServerContainer) {
@@ -27,12 +35,47 @@ class HttpSettings extends NetworkScaffolding {
 
     @Override
     void setResponse() {
+    }
+
+    protected HttpRequest getRfcRequest(String accountId) {
+        request().withPath(RFC_ENDPOINT)
+                .withQueryStringParameter("account-id", accountId)
+    }
+
+
+    void setRfcResponse(String value,
+                     ResponseModel responseModel,
+                     HttpStatusCode statusCode = OK_200,
+                     Map headers = [:]) {
+        def responseHeaders = headers.collect { new Header(it.key, it.value) }
+        def mockResponse = encode(responseModel)
+        mockServerClient.when(getRfcRequest(value), Times.unlimited())
+                .respond(response().withStatusCode(statusCode.code())
+                        .withBody(mockResponse, APPLICATION_JSON)
+                        .withHeaders(responseHeaders))
+    }
 
+    int getRfcRequestCount(String value) {
+        mockServerClient.retrieveRecordedRequests(getRfcRequest(value))
+                .size()
     }
 
     @Override
     void reset() {
         super.reset(ENDPOINT)
+        super.reset(RFC_ENDPOINT)
         super.reset(AMP_ENDPOINT)
     }
+
+    static String getEndpoint() {
+        return ENDPOINT
+    }
+
+    static String getAmpEndpoint() {
+        return AMP_ENDPOINT
+    }
+
+    static String getRfcEndpoint() {
+        return RFC_ENDPOINT
+    }
 }
diff --git a/src/test/groovy/org/prebid/server/functional/tests/HttpSettingsSpec.groovy b/src/test/groovy/org/prebid/server/functional/tests/HttpSettingsSpec.groovy
index 2c6d1556a81..01f60ac2808 100644
--- a/src/test/groovy/org/prebid/server/functional/tests/HttpSettingsSpec.groovy
+++ b/src/test/groovy/org/prebid/server/functional/tests/HttpSettingsSpec.groovy
@@ -15,7 +15,6 @@ import org.prebid.server.functional.testcontainers.PbsConfig
 import org.prebid.server.functional.testcontainers.scaffolding.HttpSettings
 import org.prebid.server.functional.util.PBSUtils
 import org.prebid.server.util.ResourceUtil
-import spock.lang.Shared
 
 import static org.prebid.server.functional.model.bidder.BidderName.GENERIC
 import static org.prebid.server.functional.testcontainers.Dependencies.networkServiceContainer
@@ -23,11 +22,25 @@ import static org.prebid.server.functional.testcontainers.Dependencies.networkSe
 class HttpSettingsSpec extends BaseSpec {
 // Check that PBS actually applied account config only possible by relying on side effects.
 
-    @Shared
-    HttpSettings httpSettings = new HttpSettings(networkServiceContainer)
+    static PrebidServerService prebidServerService
+    static PrebidServerService prebidServerServiceWithRfc
 
-    @Shared
-    PrebidServerService prebidServerService = pbsServiceFactory.getService(PbsConfig.httpSettingsConfig)
+    private static final HttpSettings httpSettings = new HttpSettings(networkServiceContainer)
+    private static final Map PBS_CONFIG_WITH_RFC = new HashMap<>(PbsConfig.httpSettingsConfig) +
+            ['settings.http.endpoint': "${networkServiceContainer.rootUri}${HttpSettings.rfcEndpoint}".toString(),
+            'settings.http.rfc3986-compatible': 'true']
+
+    def setupSpec() {
+        prebidServerService = pbsServiceFactory.getService(PbsConfig.httpSettingsConfig)
+        prebidServerServiceWithRfc = pbsServiceFactory.getService(PBS_CONFIG_WITH_RFC)
+        bidder.setResponse()
+        vendorList.setResponse()
+    }
+
+    def cleanupSpec() {
+        prebidServerService = pbsServiceFactory.removeContainer(PbsConfig.httpSettingsConfig)
+        prebidServerService = pbsServiceFactory.removeContainer(PBS_CONFIG_WITH_RFC)
+    }
 
     def "PBS should take account information from http data source on auction request"() {
         given: "Get basic BidRequest with generic bidder and set gdpr = 1"
@@ -35,8 +48,8 @@ class HttpSettingsSpec extends BaseSpec {
         bidRequest.regs.gdpr = 1
 
         and: "Prepare default account response with gdpr = 0"
-        def httpSettingsResponse = HttpAccountsResponse.getDefaultHttpAccountsResponse(bidRequest?.site?.publisher?.id)
-        httpSettings.setResponse(bidRequest?.site?.publisher?.id, httpSettingsResponse)
+        def httpSettingsResponse = HttpAccountsResponse.getDefaultHttpAccountsResponse(bidRequest.accountId)
+        httpSettings.setResponse(bidRequest.accountId, httpSettingsResponse)
 
         when: "PBS processes auction request"
         def response = prebidServerService.sendAuctionRequest(bidRequest)
@@ -51,7 +64,32 @@ class HttpSettingsSpec extends BaseSpec {
         assert bidder.getRequestCount(bidRequest.id) == 1
 
         and: "There should be only one account request"
-        assert httpSettings.getRequestCount(bidRequest?.site?.publisher?.id) == 1
+        assert httpSettings.getRequestCount(bidRequest.accountId) == 1
+    }
+
+    def "PBS should take account information from http data source on auction request when rfc3986 enabled"() {
+        given: "Get basic BidRequest with generic bidder and set gdpr = 1"
+        def bidRequest = BidRequest.defaultBidRequest
+        bidRequest.regs.gdpr = 1
+
+        and: "Prepare default account response with gdpr = 0"
+        def httpSettingsResponse = HttpAccountsResponse.getDefaultHttpAccountsResponse(bidRequest.accountId)
+        httpSettings.setRfcResponse(bidRequest.accountId, httpSettingsResponse)
+
+        when: "PBS processes auction request"
+        def response = prebidServerServiceWithRfc.sendAuctionRequest(bidRequest)
+
+        then: "Response should contain basic fields"
+        assert response.id
+        assert response.seatbid?.size() == 1
+        assert response.seatbid.first().seat == GENERIC
+        assert response.seatbid?.first()?.bid?.size() == 1
+
+        and: "There should be only one call to bidder"
+        assert bidder.getRequestCount(bidRequest.id) == 1
+
+        and: "There should be only one account request"
+        assert httpSettings.getRfcRequestCount(bidRequest.accountId) == 1
     }
 
     def "PBS should take account information from http data source on AMP request"() {
@@ -84,6 +122,36 @@ class HttpSettingsSpec extends BaseSpec {
         assert !response.ext?.debug?.httpcalls?.isEmpty()
     }
 
+    def "PBS should take account information from http data source on AMP request when rfc3986 enabled"() {
+        given: "Default AmpRequest"
+        def ampRequest = AmpRequest.defaultAmpRequest
+
+        and: "Get basic stored request and set gdpr = 1"
+        def ampStoredRequest = BidRequest.defaultBidRequest
+        ampStoredRequest.site.publisher.id = ampRequest.account
+        ampStoredRequest.regs.gdpr = 1
+
+        and: "Save storedRequest into DB"
+        def storedRequest = StoredRequest.getStoredRequest(ampRequest, ampStoredRequest)
+        storedRequestDao.save(storedRequest)
+
+        and: "Prepare default account response with gdpr = 0"
+        def httpSettingsResponse = HttpAccountsResponse.getDefaultHttpAccountsResponse(ampRequest.account.toString())
+        httpSettings.setRfcResponse(ampRequest.account.toString(), httpSettingsResponse)
+
+        when: "PBS processes amp request"
+        def response = prebidServerServiceWithRfc.sendAmpRequest(ampRequest)
+
+        then: "Response should contain httpcalls"
+        assert !response.ext?.debug?.httpcalls?.isEmpty()
+
+        and: "There should be only one account request"
+        assert httpSettings.getRfcRequestCount(ampRequest.account.toString()) == 1
+
+        then: "Response should contain targeting"
+        assert !response.ext?.debug?.httpcalls?.isEmpty()
+    }
+
     def "PBS should take account information from http data source on event request"() {
         given: "Default EventRequest"
         def eventRequest = EventRequest.defaultEventRequest
@@ -103,6 +171,25 @@ class HttpSettingsSpec extends BaseSpec {
         assert httpSettings.getRequestCount(eventRequest.accountId.toString()) == 1
     }
 
+    def "PBS should take account information from http data source on event request when rfc3986 enabled"() {
+        given: "Default EventRequest"
+        def eventRequest = EventRequest.defaultEventRequest
+
+        and: "Prepare default account response"
+        def httpSettingsResponse = HttpAccountsResponse.getDefaultHttpAccountsResponse(eventRequest.accountId.toString())
+        httpSettings.setRfcResponse(eventRequest.accountId.toString(), httpSettingsResponse)
+
+        when: "PBS processes event request"
+        def responseBody = prebidServerServiceWithRfc.sendEventRequest(eventRequest)
+
+        then: "Event response should contain and corresponding content-type"
+        assert responseBody ==
+                ResourceUtil.readByteArrayFromClassPath("org/prebid/server/functional/tracking-pixel.png")
+
+        and: "There should be only one account request"
+        assert httpSettings.getRfcRequestCount(eventRequest.accountId.toString()) == 1
+    }
+
     def "PBS should take account information from http data source on setuid request"() {
         given: "Pbs config with adapters.generic.usersync.redirect.*"
         def pbsConfig = PbsConfig.httpSettingsConfig +
@@ -137,6 +224,42 @@ class HttpSettingsSpec extends BaseSpec {
         pbsServiceFactory.removeContainer(pbsConfig)
     }
 
+    def "PBS should take account information from http data source on setuid request when rfc3986 enabled"() {
+        given: "Pbs config with adapters.generic.usersync.redirect.*"
+        def pbsConfig = new HashMap<>(PbsConfig.httpSettingsConfig) +
+                ['settings.http.endpoint': "${networkServiceContainer.rootUri}${HttpSettings.rfcEndpoint}".toString(),
+                 'settings.http.rfc3986-compatible': 'true',
+                 'adapters.generic.usersync.redirect.url'            : "$networkServiceContainer.rootUri/generic-usersync&redir={{redirect_url}}".toString(),
+                 'adapters.generic.usersync.redirect.support-cors'   : 'false',
+                 'adapters.generic.usersync.redirect.format-override': 'blank']
+        def prebidServerService = pbsServiceFactory.getService(pbsConfig)
+
+        and: "Get default SetuidRequest and set account, gdpr=1 "
+        def request = SetuidRequest.defaultSetuidRequest
+        request.gdpr = 1
+        request.account = PBSUtils.randomNumber.toString()
+        def uidsCookie = UidsCookie.defaultUidsCookie
+
+        and: "Prepare default account response"
+        def httpSettingsResponse = HttpAccountsResponse.getDefaultHttpAccountsResponse(request.account)
+        httpSettings.setRfcResponse(request.account, httpSettingsResponse)
+
+        when: "PBS processes setuid request"
+        def response = prebidServerService.sendSetUidRequest(request, uidsCookie)
+
+        then: "Response should contain tempUIDs cookie"
+        assert !response.uidsCookie.uids
+        assert response.uidsCookie.tempUIDs
+        assert response.responseBody ==
+                ResourceUtil.readByteArrayFromClassPath("org/prebid/server/functional/tracking-pixel.png")
+
+        and: "There should be only one account request"
+        assert httpSettings.getRfcRequestCount(request.account) == 1
+
+        cleanup: "Stop and remove pbs container"
+        pbsServiceFactory.removeContainer(pbsConfig)
+    }
+
     def "PBS should take account information from http data source on vtrack request"() {
         given: "Default VtrackRequest"
         String payload = PBSUtils.randomString
@@ -162,6 +285,31 @@ class HttpSettingsSpec extends BaseSpec {
         assert prebidCacheRequest.contains("/event?t=imp&b=${request.puts[0].bidid}&a=$accountId&bidder=${request.puts[0].bidder}")
     }
 
+    def "PBS should take account information from http data source on vtrack request when rfc3986 enabled"() {
+        given: "Default VtrackRequest"
+        String payload = PBSUtils.randomString
+        def request = VtrackRequest.getDefaultVtrackRequest(encodeXml(Vast.getDefaultVastModel(payload)))
+        def accountId = PBSUtils.randomNumber.toString()
+
+        and: "Prepare default account response"
+        def httpSettingsResponse = HttpAccountsResponse.getDefaultHttpAccountsResponse(accountId)
+        httpSettings.setRfcResponse(accountId, httpSettingsResponse)
+
+        when: "PBS processes vtrack request"
+        def response = prebidServerServiceWithRfc.sendVtrackRequest(request, accountId)
+
+        then: "Response should contain uid"
+        assert response.responses[0]?.uuid
+
+        and: "There should be only one account request and pbc request"
+        assert httpSettings.getRfcRequestCount(accountId.toString()) == 1
+        assert prebidCache.getXmlRequestCount(payload) == 1
+
+        and: "VastXml that was send to PrebidCache must contain event url"
+        def prebidCacheRequest = prebidCache.getXmlRecordedRequestsBody(payload)[0]
+        assert prebidCacheRequest.contains("/event?t=imp&b=${request.puts[0].bidid}&a=$accountId&bidder=${request.puts[0].bidder}")
+    }
+
     def "PBS should return error if account settings isn't found"() {
         given: "Default EventRequest"
         def eventRequest = EventRequest.defaultEventRequest
@@ -174,4 +322,17 @@ class HttpSettingsSpec extends BaseSpec {
         assert exception.statusCode == 401
         assert exception.responseBody.contains("Account '$eventRequest.accountId' doesn't support events")
     }
+
+    def "PBS should return error if account settings isn't found when rfc3986 enabled"() {
+        given: "Default EventRequest"
+        def eventRequest = EventRequest.defaultEventRequest
+
+        when: "PBS processes event request"
+        prebidServerServiceWithRfc.sendEventRequest(eventRequest)
+
+        then: "Request should fail with error"
+        def exception = thrown(PrebidServerException)
+        assert exception.statusCode == 401
+        assert exception.responseBody.contains("Account '$eventRequest.accountId' doesn't support events")
+    }
 }
diff --git a/src/test/java/org/prebid/server/settings/HttpApplicationSettingsTest.java b/src/test/java/org/prebid/server/settings/HttpApplicationSettingsTest.java
index e3076ddbdfd..60627cf2571 100644
--- a/src/test/java/org/prebid/server/settings/HttpApplicationSettingsTest.java
+++ b/src/test/java/org/prebid/server/settings/HttpApplicationSettingsTest.java
@@ -2,6 +2,8 @@
 
 import com.fasterxml.jackson.core.JsonProcessingException;
 import io.vertx.core.Future;
+import org.apache.http.NameValuePair;
+import org.apache.http.client.utils.URIBuilder;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -22,12 +24,14 @@
 import org.prebid.server.vertx.httpclient.HttpClient;
 import org.prebid.server.vertx.httpclient.model.HttpClientResponse;
 
+import java.net.URISyntaxException;
 import java.time.Clock;
 import java.time.Instant;
 import java.time.ZoneId;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.TimeoutException;
 
 import static java.util.Arrays.asList;
@@ -49,10 +53,10 @@
 @ExtendWith(MockitoExtension.class)
 public class HttpApplicationSettingsTest extends VertxTest {
 
-    private static final String ENDPOINT = "http://stored-requests";
-    private static final String AMP_ENDPOINT = "http://amp-stored-requests";
-    private static final String VIDEO_ENDPOINT = "http://video-stored-requests";
-    private static final String CATEGORY_ENDPOINT = "http://category-requests";
+    private static final String ENDPOINT = "http://stored-requests.com/something?id=1";
+    private static final String AMP_ENDPOINT = "http://amp-stored-requests.com/something?id=2";
+    private static final String VIDEO_ENDPOINT = "http://video-stored-requests.com/something?id=3";
+    private static final String CATEGORY_ENDPOINT = "http://category-requests.com/something";
 
     @Mock(strictness = LENIENT)
     private HttpClient httpClient;
@@ -65,7 +69,7 @@ public class HttpApplicationSettingsTest extends VertxTest {
     @BeforeEach
     public void setUp() {
         httpApplicationSettings = new HttpApplicationSettings(httpClient, jacksonMapper, ENDPOINT, AMP_ENDPOINT,
-                VIDEO_ENDPOINT, CATEGORY_ENDPOINT);
+                VIDEO_ENDPOINT, CATEGORY_ENDPOINT, false);
 
         final Clock clock = Clock.fixed(Instant.now(), ZoneId.systemDefault());
         final TimeoutFactory timeoutFactory = new TimeoutFactory(clock);
@@ -77,7 +81,7 @@ public void setUp() {
     public void creationShouldFailsOnInvalidEndpoint() {
         assertThatIllegalArgumentException()
                 .isThrownBy(() -> new HttpApplicationSettings(httpClient, jacksonMapper, "invalid_url", AMP_ENDPOINT,
-                        VIDEO_ENDPOINT, CATEGORY_ENDPOINT))
+                        VIDEO_ENDPOINT, CATEGORY_ENDPOINT, false))
                 .withMessage("URL supplied is not valid: invalid_url");
     }
 
@@ -85,7 +89,7 @@ public void creationShouldFailsOnInvalidEndpoint() {
     public void creationShouldFailsOnInvalidAmpEndpoint() {
         assertThatIllegalArgumentException()
                 .isThrownBy(() -> new HttpApplicationSettings(httpClient, jacksonMapper, ENDPOINT, "invalid_url",
-                        VIDEO_ENDPOINT, CATEGORY_ENDPOINT))
+                        VIDEO_ENDPOINT, CATEGORY_ENDPOINT, false))
                 .withMessage("URL supplied is not valid: invalid_url");
     }
 
@@ -93,7 +97,7 @@ public void creationShouldFailsOnInvalidAmpEndpoint() {
     public void creationShouldFailsOnInvalidVideoEndpoint() {
         assertThatIllegalArgumentException()
                 .isThrownBy(() -> new HttpApplicationSettings(httpClient, jacksonMapper, ENDPOINT, AMP_ENDPOINT,
-                        "invalid_url", CATEGORY_ENDPOINT))
+                        "invalid_url", CATEGORY_ENDPOINT, false))
                 .withMessage("URL supplied is not valid: invalid_url");
     }
 
@@ -118,7 +122,40 @@ public void getAccountByIdShouldReturnFetchedAccount() throws JsonProcessingExce
         assertThat(future.result().getId()).isEqualTo("someId");
         assertThat(future.result().getAuction().getPriceGranularity()).isEqualTo("testPriceGranularity");
 
-        verify(httpClient).get(eq("http://stored-requests?account-ids=[\"someId\"]"), any(),
+        verify(httpClient).get(
+                eq("http://stored-requests.com/something?id=1&account-ids=%5B%22someId%22%5D"),
+                any(),
+                anyLong());
+    }
+
+    @Test
+    public void getAccountByIdShouldReturnFetchedAccountWithRfc3986CompatibleParams() throws JsonProcessingException {
+        // given
+        givenHttpClientReturnsResponse(200, null);
+        httpApplicationSettings = new HttpApplicationSettings(httpClient, jacksonMapper,
+                ENDPOINT, AMP_ENDPOINT, VIDEO_ENDPOINT, CATEGORY_ENDPOINT, true);
+
+        final Account account = Account.builder()
+                .id("someId")
+                .auction(AccountAuctionConfig.builder()
+                        .priceGranularity("testPriceGranularity")
+                        .build())
+                .privacy(AccountPrivacyConfig.builder().build())
+                .build();
+        final HttpAccountsResponse response = HttpAccountsResponse.of(Collections.singletonMap("someId", account));
+        givenHttpClientReturnsResponse(200, mapper.writeValueAsString(response));
+
+        // when
+        final Future future = httpApplicationSettings.getAccountById("someId", timeout);
+
+        // then
+        assertThat(future.succeeded()).isTrue();
+        assertThat(future.result().getId()).isEqualTo("someId");
+        assertThat(future.result().getAuction().getPriceGranularity()).isEqualTo("testPriceGranularity");
+
+        verify(httpClient).get(
+                eq("http://stored-requests.com/something?id=1&account-id=someId"),
+                any(),
                 anyLong());
     }
 
@@ -234,8 +271,11 @@ public void getStoredDataShouldSendHttpRequestWithExpectedNewParams() {
                 new HashSet<>(asList("id3", "id4")), timeout);
 
         // then
-        verify(httpClient).get(eq("http://stored-requests?request-ids=[\"id2\",\"id1\"]&imp-ids=[\"id4\",\"id3\"]"),
-                any(), anyLong());
+        verify(httpClient).get(
+                eq("http://stored-requests.com/something"
+                        + "?id=1&request-ids=%5B%22id2%22%2C%22id1%22%5D&imp-ids=%5B%22id4%22%2C%22id3%22%5D"),
+                any(),
+                anyLong());
     }
 
     @Test
@@ -243,16 +283,45 @@ public void getStoredDataShouldSendHttpRequestWithExpectedAppendedParams() {
         // given
         givenHttpClientReturnsResponse(200, null);
         httpApplicationSettings = new HttpApplicationSettings(httpClient, jacksonMapper,
-                "http://some-domain?param1=value1", AMP_ENDPOINT, VIDEO_ENDPOINT, CATEGORY_ENDPOINT);
+                "http://some-domain.com?param1=value1", AMP_ENDPOINT, VIDEO_ENDPOINT, CATEGORY_ENDPOINT, false);
 
         // when
         httpApplicationSettings.getStoredData(null, singleton("id1"), singleton("id2"), timeout);
 
         // then
-        verify(httpClient).get(eq("http://some-domain?param1=value1&request-ids=[\"id1\"]&imp-ids=[\"id2\"]"), any(),
+        verify(httpClient).get(
+                eq("http://some-domain.com?param1=value1&request-ids=%5B%22id1%22%5D&imp-ids=%5B%22id2%22%5D"),
+                any(),
                 anyLong());
     }
 
+    @Test
+    public void getStoredDataShouldSendHttpRequestWithRfc3986CompatibleParams() throws URISyntaxException {
+        // given
+        givenHttpClientReturnsResponse(200, null);
+        httpApplicationSettings = new HttpApplicationSettings(httpClient, jacksonMapper,
+                ENDPOINT, AMP_ENDPOINT, VIDEO_ENDPOINT, CATEGORY_ENDPOINT, true);
+
+        // when
+        httpApplicationSettings.getStoredData(null, Set.of("id1", "id2"), Set.of("id1", "id2"), timeout);
+
+        // then
+        final ArgumentCaptor captor = ArgumentCaptor.forClass(String.class);
+        verify(httpClient).get(captor.capture(), any(), anyLong());
+
+        final URIBuilder uriBuilder = new URIBuilder(captor.getValue());
+        assertThat(uriBuilder.getHost()).isEqualTo("stored-requests.com");
+        assertThat(uriBuilder.getPath()).isEqualTo("/something");
+        assertThat(uriBuilder.getQueryParams())
+                .extracting(NameValuePair::getName, NameValuePair::getValue)
+                .containsExactlyInAnyOrder(
+                        tuple("id", "1"),
+                        tuple("request-id", "id1"),
+                        tuple("request-id", "id2"),
+                        tuple("imp-id", "id1"),
+                        tuple("imp-id", "id2"));
+    }
+
     @Test
     public void getStoredDataShouldReturnResultWithErrorIfHttpClientFails() {
         // given
@@ -416,7 +485,7 @@ public void getCategoriesShouldBuildUrlFromEndpointAdServerAndPublisher() {
         // then
         final ArgumentCaptor urlArgumentCaptor = ArgumentCaptor.forClass(String.class);
         verify(httpClient).get(urlArgumentCaptor.capture(), anyLong());
-        assertThat(urlArgumentCaptor.getValue()).isEqualTo("http://category-requests/primaryAdServer/publisher.json");
+        assertThat(urlArgumentCaptor.getValue()).isEqualTo("http://category-requests.com/something/primaryAdServer/publisher.json");
     }
 
     @Test
@@ -431,7 +500,8 @@ public void getCategoriesShouldBuildUrlFromEndpointAdServer() {
         // then
         final ArgumentCaptor urlArgumentCaptor = ArgumentCaptor.forClass(String.class);
         verify(httpClient).get(urlArgumentCaptor.capture(), anyLong());
-        assertThat(urlArgumentCaptor.getValue()).isEqualTo("http://category-requests/primaryAdServer.json");
+        assertThat(urlArgumentCaptor.getValue())
+                .isEqualTo("http://category-requests.com/something/primaryAdServer.json");
     }
 
     @Test
@@ -447,7 +517,7 @@ public void getCategoriesShouldReturnFailedFutureWithTimeoutException() {
         // then
         assertThat(result.failed()).isTrue();
         assertThat(result.cause()).isInstanceOf(TimeoutException.class)
-                .hasMessage("Failed to fetch categories from url 'http://category-requests/primaryAdServer.json'."
+                .hasMessage("Failed to fetch categories from url 'http://category-requests.com/something/primaryAdServer.json'."
                         + " Reason: Timeout exceeded");
     }
 
@@ -464,7 +534,7 @@ public void getCategoriesShouldReturnFailedFutureWhenResponseStatusIsNot200() {
         // then
         assertThat(result.failed()).isTrue();
         assertThat(result.cause()).isInstanceOf(PreBidException.class)
-                .hasMessage("Failed to fetch categories from url 'http://category-requests/primaryAdServer.json'."
+                .hasMessage("Failed to fetch categories from url 'http://category-requests.com/something/primaryAdServer.json'."
                         + " Reason: Response status code is '400'");
     }
 
@@ -481,7 +551,7 @@ public void getCategoriesShouldReturnFailedFutureWhenBodyIsNull() {
         // then
         assertThat(result.failed()).isTrue();
         assertThat(result.cause()).isInstanceOf(PreBidException.class)
-                .hasMessage("Failed to fetch categories from url 'http://category-requests/primaryAdServer.json'."
+                .hasMessage("Failed to fetch categories from url 'http://category-requests.com/something/primaryAdServer.json'."
                         + " Reason: Response body is null or empty");
     }
 
@@ -499,7 +569,7 @@ public void getCategoriesShouldReturnFailedFutureWhenBodyCantBeParsed() {
         assertThat(result.failed()).isTrue();
         assertThat(result.cause()).isInstanceOf(PreBidException.class)
                 .hasMessageStartingWith("Failed to fetch categories from url "
-                        + "'http://category-requests/primaryAdServer.json'. Reason: Failed to decode response body");
+                        + "'http://category-requests.com/something/primaryAdServer.json'. Reason: Failed to decode response body");
     }
 
     @Test

From 794c62f7757e8418992c969cdac832cc29bfed49 Mon Sep 17 00:00:00 2001
From: Anton Babak <76536883+AntoxaAntoxic@users.noreply.github.com>
Date: Tue, 29 Jul 2025 14:28:09 +0200
Subject: [PATCH 4/5] ZetaGlobalSSP Adapter: Add sid parameter and audio
 support (#4055)

---
 .../zeta_global_ssp/ZetaGlobalSspBidder.java  | 141 +++++++++++
 .../zeta_global_ssp/ExtImpZetaGlobalSSP.java  |   9 +
 .../bidder/ZetaGlobalSspConfiguration.java    |  43 ++++
 src/main/resources/bidder-config/generic.yaml |  21 --
 .../bidder-config/zeta_global_ssp.yaml        |  23 ++
 .../static/bidder-params/zeta_global_ssp.json |  13 +-
 .../testcontainers/PbsConfig.groovy           |   2 -
 .../ZetaGlobalSspBidderTest.java              | 221 ++++++++++++++++++
 .../test-auction-zeta_global_ssp-request.json |   8 +-
 .../test-zeta_global_ssp-bid-request.json     |  10 +-
 .../test-zeta_global_ssp-bid-response.json    |  10 +-
 .../server/it/test-application.properties     |   4 +-
 12 files changed, 461 insertions(+), 44 deletions(-)
 create mode 100644 src/main/java/org/prebid/server/bidder/zeta_global_ssp/ZetaGlobalSspBidder.java
 create mode 100644 src/main/java/org/prebid/server/proto/openrtb/ext/request/zeta_global_ssp/ExtImpZetaGlobalSSP.java
 create mode 100644 src/main/java/org/prebid/server/spring/config/bidder/ZetaGlobalSspConfiguration.java
 create mode 100644 src/main/resources/bidder-config/zeta_global_ssp.yaml
 create mode 100644 src/test/java/org/prebid/server/bidder/zeta_global_ssp/ZetaGlobalSspBidderTest.java

diff --git a/src/main/java/org/prebid/server/bidder/zeta_global_ssp/ZetaGlobalSspBidder.java b/src/main/java/org/prebid/server/bidder/zeta_global_ssp/ZetaGlobalSspBidder.java
new file mode 100644
index 00000000000..85454c43f2d
--- /dev/null
+++ b/src/main/java/org/prebid/server/bidder/zeta_global_ssp/ZetaGlobalSspBidder.java
@@ -0,0 +1,141 @@
+package org.prebid.server.bidder.zeta_global_ssp;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+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 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.zeta_global_ssp.ExtImpZetaGlobalSSP;
+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 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 ZetaGlobalSspBidder implements Bidder {
+
+    private static final TypeReference> ZETA_GLOBAL_EXT_TYPE_REFERENCE =
+            new TypeReference<>() {
+            };
+
+    private static final TypeReference> EXT_BID_TYPE_REFERENCE =
+            new TypeReference<>() {
+            };
+    private static final String SID_MACRO = "{{AccountID}}";
+
+    private final String endpointUrl;
+    private final JacksonMapper mapper;
+
+    public ZetaGlobalSspBidder(String endpointUrl, JacksonMapper mapper) {
+        this.endpointUrl = HttpUtil.validateUrl(endpointUrl);
+        this.mapper = Objects.requireNonNull(mapper);
+    }
+
+    @Override
+    public Result>> makeHttpRequests(BidRequest request) {
+        final Imp firstImp = request.getImp().getFirst();
+        final ExtImpZetaGlobalSSP extImp;
+
+        try {
+            extImp = parseImpExt(firstImp);
+        } catch (PreBidException e) {
+            return Result.withError(BidderError.badInput(e.getMessage()));
+        }
+
+        final HttpRequest httpRequest = BidderUtil.defaultRequest(
+                removeImpsExt(request),
+                resolveEndpoint(extImp),
+                mapper);
+
+        return Result.withValues(Collections.singletonList(httpRequest));
+    }
+
+    private ExtImpZetaGlobalSSP parseImpExt(Imp imp) {
+        try {
+            return mapper.mapper().convertValue(imp.getExt(), ZETA_GLOBAL_EXT_TYPE_REFERENCE).getBidder();
+        } catch (IllegalArgumentException e) {
+            throw new PreBidException("Missing bidder ext in impression with id: " + imp.getId());
+        }
+    }
+
+    private String resolveEndpoint(ExtImpZetaGlobalSSP extImpZetaGlobalSSP) {
+        return endpointUrl
+                .replace(SID_MACRO, Objects.toString(extImpZetaGlobalSSP.getSid(), "0"));
+    }
+
+    private BidRequest removeImpsExt(BidRequest request) {
+        final List imps = new ArrayList<>(request.getImp());
+        final Imp firstImp = imps.getFirst().toBuilder().ext(null).build();
+        imps.set(0, firstImp);
+
+        return request.toBuilder()
+                .imp(imps)
+                .build();
+    }
+
+    @Override
+    public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) {
+        try {
+            final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class);
+            final List errors = new ArrayList<>();
+            return Result.of(extractBids(bidResponse, errors), errors);
+        } catch (DecodeException | PreBidException e) {
+            return Result.withError(BidderError.badServerResponse(e.getMessage()));
+        }
+    }
+
+    private List extractBids(BidResponse bidResponse, List errors) {
+        if (bidResponse == null || bidResponse.getSeatbid() == null) {
+            return Collections.emptyList();
+        }
+        return bidsFromResponse(bidResponse, errors);
+    }
+
+    private List bidsFromResponse(BidResponse bidResponse, List errors) {
+        return bidResponse.getSeatbid().stream()
+                .filter(Objects::nonNull)
+                .map(SeatBid::getBid)
+                .filter(Objects::nonNull)
+                .flatMap(Collection::stream)
+                .filter(Objects::nonNull)
+                .map(bid -> makeBid(bid, bidResponse.getCur(), errors))
+                .filter(Objects::nonNull)
+                .toList();
+    }
+
+    private BidderBid makeBid(Bid bid, String currency, List errors) {
+        final BidType mediaType = getMediaType(bid, errors);
+        return mediaType == null ? null : BidderBid.of(bid, mediaType, currency);
+    }
+
+    private BidType getMediaType(Bid bid, List errors) {
+        try {
+            return Optional.ofNullable(bid.getExt())
+                    .map(ext -> mapper.mapper().convertValue(ext, EXT_BID_TYPE_REFERENCE))
+                    .map(ExtPrebid::getPrebid)
+                    .map(ExtBidPrebid::getType)
+                    .orElseThrow(IllegalArgumentException::new);
+        } catch (IllegalArgumentException e) {
+            errors.add(BidderError.badServerResponse(
+                    "Failed to parse impression \"%s\" mediatype".formatted(bid.getImpid())));
+            return null;
+        }
+    }
+}
diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/zeta_global_ssp/ExtImpZetaGlobalSSP.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/zeta_global_ssp/ExtImpZetaGlobalSSP.java
new file mode 100644
index 00000000000..b904d2e677a
--- /dev/null
+++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/zeta_global_ssp/ExtImpZetaGlobalSSP.java
@@ -0,0 +1,9 @@
+package org.prebid.server.proto.openrtb.ext.request.zeta_global_ssp;
+
+import lombok.Value;
+
+@Value(staticConstructor = "of")
+public class ExtImpZetaGlobalSSP {
+
+    Integer sid;
+}
diff --git a/src/main/java/org/prebid/server/spring/config/bidder/ZetaGlobalSspConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/ZetaGlobalSspConfiguration.java
new file mode 100644
index 00000000000..aa98e645aa0
--- /dev/null
+++ b/src/main/java/org/prebid/server/spring/config/bidder/ZetaGlobalSspConfiguration.java
@@ -0,0 +1,43 @@
+package org.prebid.server.spring.config.bidder;
+
+import org.prebid.server.bidder.BidderDeps;
+import org.prebid.server.bidder.zeta_global_ssp.ZetaGlobalSspBidder;
+import org.prebid.server.json.JacksonMapper;
+import org.prebid.server.spring.config.bidder.model.BidderConfigurationProperties;
+import org.prebid.server.spring.config.bidder.util.BidderDepsAssembler;
+import org.prebid.server.spring.config.bidder.util.UsersyncerCreator;
+import org.prebid.server.spring.env.YamlPropertySourceFactory;
+import org.springframework.beans.factory.annotation.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 jakarta.validation.constraints.NotBlank;
+
+@Configuration
+@PropertySource(value = "classpath:/bidder-config/zeta_global_ssp.yaml", factory = YamlPropertySourceFactory.class)
+public class ZetaGlobalSspConfiguration {
+
+    private static final String BIDDER_NAME = "zeta_global_ssp";
+
+    @Bean("zetaglobalsspConfigurationProperties")
+    @ConfigurationProperties("adapters.zetaglobalssp")
+    BidderConfigurationProperties configurationProperties() {
+        return new BidderConfigurationProperties();
+    }
+
+    @Bean
+    BidderDeps zetaGlobalSspBidderDeps(@Qualifier("zetaglobalsspConfigurationProperties")
+                                       BidderConfigurationProperties zetaGlobalSspConfigurationProperties,
+                                       @NotBlank @Value("${external-url}") String externalUrl,
+                                       JacksonMapper mapper) {
+
+        return BidderDepsAssembler.forBidder(BIDDER_NAME)
+                .withConfig(zetaGlobalSspConfigurationProperties)
+                .usersyncerCreator(UsersyncerCreator.create(externalUrl))
+                .bidderCreator(config -> new ZetaGlobalSspBidder(config.getEndpoint(), mapper))
+                .assemble();
+    }
+}
diff --git a/src/main/resources/bidder-config/generic.yaml b/src/main/resources/bidder-config/generic.yaml
index 4aa01b82bbb..a6522be1122 100644
--- a/src/main/resources/bidder-config/generic.yaml
+++ b/src/main/resources/bidder-config/generic.yaml
@@ -41,27 +41,6 @@ adapters:
             - video
           supported-vendors:
           vendor-id: 0
-      zeta_global_ssp:
-        enabled: false
-        endpoint: https://ssp.disqus.com/bid/prebid-server?sid=GET_SID_FROM_ZETA
-        endpoint-compression: gzip
-        meta-info:
-          maintainer-email: DL-Zeta-SSP@zetaglobal.com
-          app-media-types:
-            - banner
-            - video
-          site-media-types:
-            - banner
-            - video
-          supported-vendors:
-          vendor-id: 833
-        usersync:
-          enabled: true
-          cookie-family-name: zeta_global_ssp
-          redirect:
-            url: https://ssp.disqus.com/redirectuser?sid=GET_SID_FROM_ZETA&gdpr={{gdpr}}&gdpr_consent={{gdpr_consent}}&us_privacy={{us_privacy}}&r={{redirect_url}}
-            uid-macro: 'BUYERUID'
-            support-cors: false
       blue:
         enabled: false
         endpoint: https://prebid-us-east-1.getblue.io/?src=prebid
diff --git a/src/main/resources/bidder-config/zeta_global_ssp.yaml b/src/main/resources/bidder-config/zeta_global_ssp.yaml
new file mode 100644
index 00000000000..a1bd90b75f1
--- /dev/null
+++ b/src/main/resources/bidder-config/zeta_global_ssp.yaml
@@ -0,0 +1,23 @@
+adapters:
+  zeta_global_ssp:
+    endpoint: https://ssp.disqus.com/bid/prebid-server?sid={{AccountID}}
+    endpoint-compression: gzip
+    ortb-version: "2.6"
+    modifying-vast-xml-allowed: true
+    meta-info:
+      maintainer-email: DL-Zeta-SSP@zetaglobal.com
+      app-media-types:
+        - banner
+        - video
+        - audio
+      site-media-types:
+        - banner
+        - video
+        - audio
+      vendor-id: 833
+    usersync:
+      cookie-family-name: zeta_global_ssp
+      redirect:
+        url: https://ssp.disqus.com/redirectuser?gdpr={{gdpr}}&gdpr_consent={{gdpr_consent}}&us_privacy={{us_privacy}}&gpp={{gpp}}&gpp_sid={{gpp_sid}}&r={{redirect_url}}
+        support-cors: false
+        uid-macro: 'BUYERUID'
diff --git a/src/main/resources/static/bidder-params/zeta_global_ssp.json b/src/main/resources/static/bidder-params/zeta_global_ssp.json
index 91ff05ed089..8a6d1d0a060 100644
--- a/src/main/resources/static/bidder-params/zeta_global_ssp.json
+++ b/src/main/resources/static/bidder-params/zeta_global_ssp.json
@@ -1,10 +1,13 @@
 {
   "$schema": "http://json-schema.org/draft-04/schema#",
   "title": "Zeta Global SSP Adapter Params",
-  "description": "A schema which validates params accepted by the Zeta SSP adapter",
-  "type": "object",
-
-  "properties": {},
+  "description": "A schema which validates params accepted by the Zeta Global SSP adapter",
 
-  "required": []
+  "type": "object",
+  "properties": {
+    "sid": {
+      "type": "integer",
+      "description": "An ID which identifies the publisher"
+    }
+  }
 }
diff --git a/src/test/groovy/org/prebid/server/functional/testcontainers/PbsConfig.groovy b/src/test/groovy/org/prebid/server/functional/testcontainers/PbsConfig.groovy
index 052bcf2f69f..41c781f372e 100644
--- a/src/test/groovy/org/prebid/server/functional/testcontainers/PbsConfig.groovy
+++ b/src/test/groovy/org/prebid/server/functional/testcontainers/PbsConfig.groovy
@@ -129,8 +129,6 @@ LIMIT 1
          "adapters.generic.aliases.nativo.meta-info.site-media-types"         : "",
          "adapters.generic.aliases.infytv.meta-info.app-media-types"          : "",
          "adapters.generic.aliases.infytv.meta-info.site-media-types"         : "",
-         "adapters.generic.aliases.zeta-global-ssp.meta-info.app-media-types" : "",
-         "adapters.generic.aliases.zeta-global-ssp.meta-info.site-media-types": "",
          "adapters.generic.aliases.ccx.meta-info.app-media-types"             : "",
          "adapters.generic.aliases.ccx.meta-info.site-media-types"            : "",
          "adapters.generic.aliases.adrino.meta-info.app-media-types"          : "",
diff --git a/src/test/java/org/prebid/server/bidder/zeta_global_ssp/ZetaGlobalSspBidderTest.java b/src/test/java/org/prebid/server/bidder/zeta_global_ssp/ZetaGlobalSspBidderTest.java
new file mode 100644
index 00000000000..886af999d74
--- /dev/null
+++ b/src/test/java/org/prebid/server/bidder/zeta_global_ssp/ZetaGlobalSspBidderTest.java
@@ -0,0 +1,221 @@
+package org.prebid.server.bidder.zeta_global_ssp;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+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 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.bidder.theadx.TheadxBidder;
+import org.prebid.server.proto.openrtb.ext.ExtPrebid;
+import org.prebid.server.proto.openrtb.ext.request.zeta_global_ssp.ExtImpZetaGlobalSSP;
+import org.prebid.server.proto.openrtb.ext.response.BidType;
+
+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.prebid.server.bidder.model.BidderError.Type.bad_server_response;
+import static org.prebid.server.util.HttpUtil.ACCEPT_HEADER;
+import static org.prebid.server.util.HttpUtil.APPLICATION_JSON_CONTENT_TYPE;
+import static org.prebid.server.util.HttpUtil.CONTENT_TYPE_HEADER;
+import static org.springframework.util.MimeTypeUtils.APPLICATION_JSON_VALUE;
+
+public class ZetaGlobalSspBidderTest extends VertxTest {
+
+    private static final String ENDPOINT_URL = "https://test-url.com/{{AccountID}}";
+
+    private final ZetaGlobalSspBidder target = new ZetaGlobalSspBidder(ENDPOINT_URL, jacksonMapper);
+
+    @Test
+    public void shouldFailOnBidderCreation() {
+        assertThatIllegalArgumentException().isThrownBy(() -> new TheadxBidder("invalid_url", jacksonMapper));
+    }
+
+    @Test
+    public void makeHttpRequestsShouldReturnErrorIfImpExtMissing() {
+        // given
+        final BidRequest bidRequest = givenBidRequest(
+                givenImp(imp -> imp.id("imp1")
+                        .ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createArrayNode())))));
+
+        // when
+        final Result>> result = target.makeHttpRequests(bidRequest);
+
+        // then
+        assertThat(result.getErrors()).hasSize(1)
+                .allSatisfy(error -> {
+                    assertThat(error.getMessage()).contains("Missing bidder ext in impression with id: imp1");
+                    assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input);
+                });
+        assertThat(result.getValue()).isEmpty();
+    }
+
+    @Test
+    public void makeHttpRequestsShouldCreateSingleRequestAndRemoveImpExt() {
+        // given
+        final Imp imp1 = givenImp(imp -> imp.id("imp1").ext(givenImpExt(11)));
+        final Imp imp2 = givenImp(imp -> imp.id("imp2").ext(givenImpExt(44)));
+        final BidRequest bidRequest = givenBidRequest(imp1, imp2);
+
+        // when
+        final Result>> result = target.makeHttpRequests(bidRequest);
+        final HttpRequest httpRequest = result.getValue().getFirst();
+
+        // then
+        assertThat(result.getValue())
+                .extracting(HttpRequest::getPayload)
+                .flatExtracting(BidRequest::getImp)
+                .extracting(Imp::getExt)
+                .containsExactly(null, givenImpExt(44));
+        assertThat(result.getErrors()).isEmpty();
+    }
+
+    @Test
+    public void makeHttpRequestsShouldReturnExpectedHeaders() {
+        // given
+        final BidRequest bidRequest = givenBidRequest(givenImp(identity()));
+
+        // when
+        final Result>> result = target.makeHttpRequests(bidRequest);
+
+        // then
+        assertThat(result.getValue()).hasSize(1).first()
+                .extracting(HttpRequest::getHeaders)
+                .satisfies(headers -> {
+                    assertThat(headers.get(CONTENT_TYPE_HEADER)).isEqualTo(APPLICATION_JSON_CONTENT_TYPE);
+                    assertThat(headers.get(ACCEPT_HEADER)).isEqualTo(APPLICATION_JSON_VALUE);
+                });
+        assertThat(result.getErrors()).isEmpty();
+    }
+
+    @Test
+    public void makeHttpRequestsShouldResolveMacroInEndpointUrl() {
+        // given
+        final Imp imp1 = givenImp(imp -> imp.id("imp1").ext(givenImpExt(11)));
+        final BidRequest bidRequest = givenBidRequest(imp1);
+
+        // when
+        final Result>> result = target.makeHttpRequests(bidRequest);
+
+        // then
+        assertThat(result.getValue()).hasSize(1)
+                .extracting(HttpRequest::getUri)
+                .containsExactly("https://test-url.com/11");
+    }
+
+    @Test
+    public void makeBidsShouldReturnErrorIfResponseBodyInvalid() {
+        // given
+        final BidderCall httpCall = givenHttpCall("invalid-response-body");
+
+        // when
+        final Result> result = target.makeBids(httpCall, null);
+
+        // then
+        assertThat(result.getValue()).isEmpty();
+        assertThat(result.getErrors()).hasSize(1)
+                .allSatisfy(error -> {
+                    assertThat(error.getMessage()).contains("Failed to decode:");
+                    assertThat(error.getType()).isEqualTo(bad_server_response);
+                });
+    }
+
+    @Test
+    public void makeBidsShouldReturnEmptyListIfSeatBidIsNullOrEmpty() throws JsonProcessingException {
+        // given
+        final BidderCall httpCall =
+                givenHttpCall(mapper.writeValueAsString(BidResponse.builder().cur("USD").build()));
+
+        // when
+        final Result> result = target.makeBids(httpCall, null);
+
+        // then
+        assertThat(result.getErrors()).isEmpty();
+        assertThat(result.getValue()).isEmpty();
+    }
+
+    @Test
+    public void makeBidsShouldReturnErrorIfCannotResolveBidType() throws JsonProcessingException {
+        // given
+        final Bid bid = givenBid("imp1", mapper.createObjectNode());
+        final BidderCall httpCall =
+                givenHttpCall(mapper.writeValueAsString(givenBidResponse(List.of(bid))));
+
+        // when
+        final Result> result = target.makeBids(httpCall, null);
+
+        // then
+        assertThat(result.getValue()).isEmpty();
+        assertThat(result.getErrors()).hasSize(1)
+                .allSatisfy(error -> {
+                    assertThat(error.getMessage()).contains("Failed to parse impression \"imp1\" mediatype");
+                    assertThat(error.getType()).isEqualTo(bad_server_response);
+                });
+    }
+
+    @Test
+    public void makeBidsShouldReturnBannerBidIfTypeParsedProperly() throws JsonProcessingException {
+        // given
+        final ObjectNode extWithPrebidType = mapper.createObjectNode();
+        extWithPrebidType.putObject("prebid").put("type", "banner");
+        final Bid validBid = givenBid("imp1", extWithPrebidType);
+
+        final BidResponse bidResponse = givenBidResponse(List.of(validBid));
+        final BidderCall httpCall = givenHttpCall(mapper.writeValueAsString(bidResponse));
+
+        // when
+        final Result> result = target.makeBids(httpCall, null);
+
+        // then
+        assertThat(result.getErrors()).isEmpty();
+        assertThat(result.getValue()).hasSize(1);
+        final BidderBid bidderBid = result.getValue().getFirst();
+        assertThat(bidderBid.getBid().getImpid()).isEqualTo("imp1");
+        assertThat(bidderBid.getType()).isEqualTo(BidType.banner);
+        assertThat(bidderBid.getBidCurrency()).isEqualTo("USD");
+    }
+
+    private static BidRequest givenBidRequest(Imp... imps) {
+        return BidRequest.builder().imp(List.of(imps)).build();
+    }
+
+    private static Imp givenImp(UnaryOperator impCustomizer) {
+        return impCustomizer.apply(Imp.builder().id("imp_id").ext(givenImpExt(11))).build();
+    }
+
+    private static ObjectNode givenImpExt(Integer sid) {
+        return mapper.valueToTree(ExtPrebid.of(null, ExtImpZetaGlobalSSP.of(sid)));
+    }
+
+    private static BidderCall givenHttpCall(String body) {
+        return BidderCall.succeededHttp(
+                HttpRequest.builder().payload(null).build(),
+                HttpResponse.of(200, null, body),
+                null);
+    }
+
+    private static Bid givenBid(String impId, ObjectNode ext) {
+        return Bid.builder().impid(impId).ext(ext).build();
+    }
+
+    private static BidResponse givenBidResponse(List bids) {
+        return BidResponse.builder()
+                .cur("USD")
+                .seatbid(singletonList(SeatBid.builder().bid(bids).build()))
+                .build();
+    }
+
+}
diff --git a/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-auction-zeta_global_ssp-request.json b/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-auction-zeta_global_ssp-request.json
index 0a3824d6e31..9ee42b27d18 100644
--- a/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-auction-zeta_global_ssp-request.json
+++ b/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-auction-zeta_global_ssp-request.json
@@ -12,7 +12,9 @@
         ]
       },
       "ext": {
-        "zeta_global_ssp": {}
+        "zeta_global_ssp": {
+          "sid": 11
+        }
       }
     }
   ],
@@ -37,8 +39,6 @@
     "tid": "tid"
   },
   "regs": {
-    "ext": {
-      "gdpr": 0
-    }
+    "gdpr": 0
   }
 }
diff --git a/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-zeta_global_ssp-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-zeta_global_ssp-bid-request.json
index 2608812c09e..2f86fcc4a79 100644
--- a/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-zeta_global_ssp-bid-request.json
+++ b/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-zeta_global_ssp-bid-request.json
@@ -11,11 +11,7 @@
           }
         ]
       },
-      "secure": 1,
-      "ext": {
-        "tid": "${json-unit.any-string}",
-        "bidder": {}
-      }
+      "secure": 1
     }
   ],
   "site": {
@@ -47,9 +43,7 @@
     "tid": "tid"
   },
   "regs": {
-    "ext": {
-      "gdpr": 0
-    }
+    "gdpr": 0
   },
   "ext": {
     "prebid": {
diff --git a/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-zeta_global_ssp-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-zeta_global_ssp-bid-response.json
index c31fabcb822..39d74ae42cd 100644
--- a/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-zeta_global_ssp-bid-response.json
+++ b/src/test/resources/org/prebid/server/it/openrtb2/zeta_global_ssp/test-zeta_global_ssp-bid-response.json
@@ -12,9 +12,15 @@
           "cid": "cid001",
           "adm": "adm001",
           "h": 250,
-          "w": 300
+          "w": 300,
+          "ext": {
+            "prebid": {
+              "type": "banner"
+            }
+          }
         }
-      ]
+      ],
+      "seat": "zeta_global_ssp"
     }
   ]
 }
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 30a8e1be569..49c87e9bb84 100644
--- a/src/test/resources/org/prebid/server/it/test-application.properties
+++ b/src/test/resources/org/prebid/server/it/test-application.properties
@@ -14,8 +14,6 @@ adapters.generic.aliases.cwire.enabled=true
 adapters.generic.aliases.cwire.endpoint=http://localhost:8090/cwire-exchange
 adapters.generic.aliases.infytv.enabled=true
 adapters.generic.aliases.infytv.endpoint=http://localhost:8090/infytv-exchange
-adapters.generic.aliases.zeta_global_ssp.enabled=true
-adapters.generic.aliases.zeta_global_ssp.endpoint=http://localhost:8090/zeta_global_ssp-exchange
 adapters.aceex.enabled=true
 adapters.aceex.endpoint=http://localhost:8090/aceex-exchange
 adapters.acuityads.enabled=true
@@ -626,6 +624,8 @@ adapters.zentotem.enabled=true
 adapters.zentotem.endpoint=http://localhost:8090/zentotem-exchange
 adapters.zeroclickfraud.enabled=true
 adapters.zeroclickfraud.endpoint=http://{{Host}}/zeroclickfraud-exchange?sid={{SourceId}}
+adapters.zeta_global_ssp.enabled=true
+adapters.zeta_global_ssp.endpoint=http://localhost:8090/zeta_global_ssp-exchange
 adapters.aax.enabled=true
 adapters.aax.endpoint=http://localhost:8090/aax-exchange
 adapters.zmaticoo.enabled=true

From d1b64aca74218354f240b40b4cb804d0b8037fa4 Mon Sep 17 00:00:00 2001
From: andre-gielow-ttd <124626380+andre-gielow-ttd@users.noreply.github.com>
Date: Tue, 29 Jul 2025 08:30:42 -0400
Subject: [PATCH 5/5] TheTradeDesk: Resolve AUCTION_PRICE macro (#4081)

---
 .../thetradedesk/TheTradeDeskBidder.java      |  39 ++-
 .../thetradedesk/TheTradeDeskBidderTest.java  | 234 +++++++++++++++++-
 2 files changed, 263 insertions(+), 10 deletions(-)

diff --git a/src/main/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidder.java b/src/main/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidder.java
index e047a077599..d83274e4852 100644
--- a/src/main/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidder.java
+++ b/src/main/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidder.java
@@ -29,6 +29,7 @@
 import org.prebid.server.util.BidderUtil;
 import org.prebid.server.util.HttpUtil;
 
+import java.math.BigDecimal;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -44,6 +45,7 @@ public class TheTradeDeskBidder implements Bidder {
 
     private static final String SUPPLY_ID_MACRO = "{{SupplyId}}";
     private static final Pattern SUPPLY_ID_PATTERN = Pattern.compile("([a-z]+)$");
+    private static final String PRICE_MACRO = "${AUCTION_PRICE}";
 
     private final String endpointUrl;
     private final String supplyId;
@@ -180,32 +182,55 @@ private String resolveEndpoint(String sourceSupplyId) {
     public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) {
         try {
             final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class);
-            return Result.withValues(extractBids(bidResponse));
-        } catch (DecodeException | PreBidException e) {
+            final List errors = new ArrayList<>();
+            final List bids = extractBids(bidResponse, errors);
+            return Result.of(bids, errors);
+        } catch (DecodeException e) {
             return Result.withError(BidderError.badServerResponse(e.getMessage()));
         }
     }
 
-    private static List extractBids(BidResponse bidResponse) {
+    private static List extractBids(BidResponse bidResponse, List errors) {
         if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) {
             return Collections.emptyList();
         }
 
         return bidResponse.getSeatbid().stream()
                 .filter(Objects::nonNull)
-                .map(SeatBid::getBid).filter(Objects::nonNull)
+                .map(SeatBid::getBid)
+                .filter(Objects::nonNull)
                 .flatMap(Collection::stream)
                 .filter(Objects::nonNull)
-                .map(bid -> BidderBid.of(bid, getBidType(bid), bidResponse.getCur()))
+                .map(bid -> makeBid(bid, bidResponse.getCur(), errors))
+                .filter(Objects::nonNull)
                 .toList();
     }
 
-    private static BidType getBidType(Bid bid) {
+    private static BidderBid makeBid(Bid bid, String currency, List errors) {
+        final BidType bidType = getBidType(bid, errors);
+        return bidType != null ? BidderBid.of(resolvePriceMacros(bid), bidType, currency) : null;
+    }
+
+    private static BidType getBidType(Bid bid, List errors) {
         return switch (bid.getMtype()) {
             case 1 -> BidType.banner;
             case 2 -> BidType.video;
             case 4 -> BidType.xNative;
-            case null, default -> throw new PreBidException("unsupported mtype: %s".formatted(bid.getMtype()));
+            case null, default -> {
+                errors.add(BidderError.badServerResponse(
+                        "could not define media type for impression: " + bid.getImpid()));
+                yield null;
+            }
         };
     }
+
+    private static Bid resolvePriceMacros(Bid bid) {
+        final BigDecimal price = bid.getPrice();
+        final String priceAsString = price != null ? price.toPlainString() : "0";
+
+        return bid.toBuilder()
+                .nurl(StringUtils.replace(bid.getNurl(), PRICE_MACRO, priceAsString))
+                .adm(StringUtils.replace(bid.getAdm(), PRICE_MACRO, priceAsString))
+                .build();
+    }
 }
diff --git a/src/test/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidderTest.java b/src/test/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidderTest.java
index a292ac6d385..8c167590e4f 100644
--- a/src/test/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidderTest.java
+++ b/src/test/java/org/prebid/server/bidder/thetradedesk/TheTradeDeskBidderTest.java
@@ -24,6 +24,7 @@
 import org.prebid.server.proto.openrtb.ext.ExtPrebid;
 import org.prebid.server.proto.openrtb.ext.request.thetradedesk.ExtImpTheTradeDesk;
 
+import java.math.BigDecimal;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Set;
@@ -33,6 +34,7 @@
 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.prebid.server.proto.openrtb.ext.response.BidType.banner;
 import static org.prebid.server.proto.openrtb.ext.response.BidType.video;
 import static org.prebid.server.proto.openrtb.ext.response.BidType.xNative;
@@ -458,7 +460,7 @@ public void makeBidsShouldReturnVideoBid() throws JsonProcessingException {
     }
 
     @Test
-    public void makeBidsShouldThrowErrorWhenMediaTypeIsMissing() throws JsonProcessingException {
+    public void makeBidsShouldReturnErrorWhenMediaTypeIsMissing() throws JsonProcessingException {
         // given
         final BidderCall httpCall = givenHttpCall(
                 givenBidResponse(bidBuilder -> bidBuilder.impid("123")));
@@ -469,7 +471,234 @@ public void makeBidsShouldThrowErrorWhenMediaTypeIsMissing() throws JsonProcessi
         // then
         assertThat(result.getValue()).isEmpty();
         assertThat(result.getErrors()).hasSize(1)
-                .containsOnly(BidderError.badServerResponse("unsupported mtype: null"));
+                .containsOnly(BidderError.badServerResponse("could not define media type for impression: 123"));
+    }
+
+    @Test
+    public void makeBidsShouldReturnValidBidsAndErrorsForMixedMediaTypes() throws JsonProcessingException {
+        // given
+        final BidderCall httpCall = givenHttpCall(
+                mapper.writeValueAsString(BidResponse.builder()
+                        .cur("USD")
+                        .seatbid(singletonList(SeatBid.builder()
+                                .bid(Arrays.asList(
+                                        Bid.builder().mtype(1).impid("valid1").build(),  // valid banner
+                                        Bid.builder().mtype(3).impid("invalid1").build(), // invalid mtype
+                                        Bid.builder().mtype(2).impid("valid2").build(),  // valid video
+                                        Bid.builder().mtype(null).impid("invalid2").build() // null mtype
+                                ))
+                                .build()))
+                        .build()));
+
+        // when
+        final Result> result = target.makeBids(httpCall, null);
+
+        // then
+        assertThat(result.getValue()).hasSize(2)
+                .extracting(bidderBid -> bidderBid.getBid().getImpid())
+                .containsExactly("valid1", "valid2");
+        assertThat(result.getErrors()).hasSize(2)
+                .containsExactly(
+                        BidderError.badServerResponse("could not define media type for impression: invalid1"),
+                        BidderError.badServerResponse("could not define media type for impression: invalid2"));
+    }
+
+    @Test
+    public void makeBidsShouldReplacePriceMacroInNurlAndAdmWithBidPrice() throws JsonProcessingException {
+        // given
+        final BidderCall httpCall = givenHttpCall(
+                givenBidResponse(bidBuilder -> bidBuilder
+                        .mtype(1)
+                        .impid("123")
+                        .price(BigDecimal.valueOf(1.23))
+                        .nurl("http://example.com/nurl?price=${AUCTION_PRICE}")
+                        .adm("
Price: ${AUCTION_PRICE}
"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm, Bid::getPrice) + .containsOnly(tuple("http://example.com/nurl?price=1.23", "
Price: 1.23
", BigDecimal.valueOf(1.23))); + } + + @Test + public void makeBidsShouldReplacePriceMacroWithZeroWhenBidPriceIsNull() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder + .mtype(1) + .impid("123") + .price(null) + .nurl("http://example.com/nurl?price=${AUCTION_PRICE}") + .adm("
Price: ${AUCTION_PRICE}
"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm) + .containsOnly(tuple("http://example.com/nurl?price=0", "
Price: 0
")); + } + + @Test + public void makeBidsShouldReplacePriceMacroWithZeroWhenBidPriceIsZero() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder + .mtype(1) + .impid("123") + .price(BigDecimal.ZERO) + .nurl("http://example.com/nurl?price=${AUCTION_PRICE}") + .adm("
Price: ${AUCTION_PRICE}
"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm) + .containsOnly(tuple("http://example.com/nurl?price=0", "
Price: 0
")); + } + + @Test + public void makeBidsShouldReplacePriceMacroInNurlOnlyWhenAdmDoesNotContainMacro() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder + .mtype(1) + .impid("123") + .price(BigDecimal.valueOf(5.67)) + .nurl("http://example.com/nurl?price=${AUCTION_PRICE}") + .adm("
No macro here
"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm) + .containsOnly(tuple("http://example.com/nurl?price=5.67", "
No macro here
")); + } + + @Test + public void makeBidsShouldReplacePriceMacroInAdmOnlyWhenNurlDoesNotContainMacro() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder + .mtype(1) + .impid("123") + .price(BigDecimal.valueOf(8.90)) + .nurl("http://example.com/nurl") + .adm("
Price: ${AUCTION_PRICE}
"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm) + .containsOnly(tuple("http://example.com/nurl", "
Price: 8.9
")); + } + + @Test + public void makeBidsShouldNotReplacePriceMacroWhenNurlAndAdmDoNotContainMacro() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder + .mtype(1) + .impid("123") + .price(BigDecimal.valueOf(12.34)) + .nurl("http://example.com/nurl") + .adm("
No macro
"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm) + .containsOnly(tuple("http://example.com/nurl", "
No macro
")); + } + + @Test + public void makeBidsShouldHandleNullNurlAndAdm() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder + .mtype(1) + .impid("123") + .price(BigDecimal.valueOf(15.00)) + .nurl(null) + .adm(null))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm) + .containsOnly(tuple(null, null)); + } + + @Test + public void makeBidsShouldReplaceMultiplePriceMacrosInSameField() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder + .mtype(1) + .impid("123") + .price(BigDecimal.valueOf(9.99)) + .nurl("http://example.com/nurl?price=${AUCTION_PRICE}&backup_price=${AUCTION_PRICE}") + .adm("
Price: ${AUCTION_PRICE}, Fallback: ${AUCTION_PRICE}
"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm) + .containsOnly(tuple("http://example.com/nurl?price=9.99&backup_price=9.99", "
Price: 9.99, Fallback: 9.99
")); + } + + @Test + public void makeBidsShouldHandleLargeDecimalPrices() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder + .mtype(1) + .impid("123") + .price(new BigDecimal("123456789.123456789")) + .nurl("http://example.com/nurl?price=${AUCTION_PRICE}") + .adm("
Price: ${AUCTION_PRICE}
"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(BidderBid::getBid) + .extracting(Bid::getNurl, Bid::getAdm) + .containsOnly(tuple("http://example.com/nurl?price=123456789.123456789", "
Price: 123456789.123456789
")); } private String givenBidResponse(UnaryOperator bidCustomizer) throws JsonProcessingException { @@ -508,5 +737,4 @@ private static ObjectNode impExt(String publisherId) { private static ObjectNode impExt(String publisherId, String supplySourceId) { return mapper.valueToTree(ExtPrebid.of(null, ExtImpTheTradeDesk.of(publisherId, supplySourceId))); } - }