diff --git a/src/main/java/org/prebid/server/bidder/adswag/AdswagBidder.java b/src/main/java/org/prebid/server/bidder/adswag/AdswagBidder.java new file mode 100644 index 00000000000..07c9d61631e --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/adswag/AdswagBidder.java @@ -0,0 +1,371 @@ +package org.prebid.server.bidder.adswag; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.App; +import com.iab.openrtb.request.Banner; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Format; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Publisher; +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 org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.prebid.server.bidder.Bidder; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.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.adswag.ExtImpAdswag; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.util.BidderUtil; +import org.prebid.server.util.HttpUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * Adswag {@link Bidder} implementation. + *
+ * The Adswag bid endpoint speaks plain OpenRTB 2.6 JSON in/out and answers a + * no-bid as an empty-body HTTP 200 (its Prebid.js contract) as well as the + * conventional 204. + *
+ * Display (banner) bids carry markup in adm (a loader script tag that
+ * injects the composed creative into the containing frame at render time);
+ * it passes through unchanged. bid.ext.adswag.serve_url remains populated
+ * as a legacy fallback: if adm is ever absent, the adapter synthesizes an
+ * iframe tag around that URL so the bid still carries renderable markup
+ * for every PBS consumer (network-equivalent to the Prebid.js adapter's
+ * adUrl render).
+ * Video and audio bids carry VAST markup in adm and pass through unchanged.
+ */
+public class AdswagBidder implements Bidder>> makeHttpRequests(BidRequest request) {
+ final Map
> makeBids(BidderCall
>> result = target.makeHttpRequests(bidRequest);
+
+ // then
+ assertThat(result.getErrors()).hasSize(1)
+ .allSatisfy(error -> {
+ assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input);
+ assertThat(error.getMessage()).startsWith("Error parsing imp.ext for impression impId");
+ });
+ assertThat(result.getValue()).isEmpty();
+ }
+
+ @Test
+ public void makeHttpRequestsShouldReturnErrorIfPublisherIdIsMissing() {
+ // given
+ final BidRequest bidRequest = givenBidRequest(
+ imp -> imp.id("imp1").ext(givenImpExt(" ", "placement")));
+
+ // when
+ final Result
>> result = target.makeHttpRequests(bidRequest);
+
+ // then
+ assertThat(result.getErrors()).hasSize(1)
+ .allSatisfy(error -> {
+ assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input);
+ assertThat(error.getMessage()).isEqualTo("missing publisherId for imp imp1");
+ });
+ assertThat(result.getValue()).isEmpty();
+ }
+
+ @Test
+ public void makeHttpRequestsShouldReturnErrorIfNeitherSiteNorAppIsPresent() {
+ // given
+ final BidRequest bidRequest = BidRequest.builder()
+ .imp(singletonList(Imp.builder().id("imp1").ext(givenImpExt("pub-1", null)).build()))
+ .build();
+
+ // when
+ final Result
>> result = target.makeHttpRequests(bidRequest);
+
+ // then
+ assertThat(result.getErrors()).hasSize(1)
+ .allSatisfy(error -> {
+ assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input);
+ assertThat(error.getMessage()).isEqualTo("request must contain either site or app");
+ });
+ assertThat(result.getValue()).isEmpty();
+ }
+
+ @Test
+ public void makeHttpRequestsShouldPromotePublisherIdAndRewriteImpExt() {
+ // given
+ final ObjectNode impExt = givenImpExt("pub-1", "plc-1");
+ impExt.put("gpid", "/1111/homepage#div-1");
+ final BidRequest bidRequest = givenBidRequest(
+ Site.builder().publisher(Publisher.builder().name("Example").build()).build(),
+ imp -> imp.id("imp1").ext(impExt));
+
+ // when
+ final Result
>> result = target.makeHttpRequests(bidRequest);
+
+ // then
+ assertThat(result.getErrors()).isEmpty();
+ assertThat(result.getValue())
+ .extracting(HttpRequest::getPayload)
+ .extracting(BidRequest::getSite)
+ .extracting(Site::getPublisher)
+ .extracting(Publisher::getId, Publisher::getName)
+ .containsExactly(tuple("pub-1", "Example"));
+ assertThat(result.getValue())
+ .extracting(HttpRequest::getPayload)
+ .flatExtracting(BidRequest::getImp)
+ .extracting(Imp::getExt)
+ .allSatisfy(ext -> {
+ assertThat(ext.has("bidder")).isFalse();
+ assertThat(ext.get("gpid").textValue()).isEqualTo("/1111/homepage#div-1");
+ assertThat(ext.get("adswag").get("placement_id").textValue()).isEqualTo("plc-1");
+ });
+ }
+
+ @Test
+ public void makeHttpRequestsShouldRemoveImpExtWhenOnlyBidderParamsPresent() {
+ // given
+ final BidRequest bidRequest = givenBidRequest(imp -> imp.id("imp1").ext(givenImpExt("pub-1", null)));
+
+ // when
+ final Result
>> result = target.makeHttpRequests(bidRequest);
+
+ // then
+ assertThat(result.getErrors()).isEmpty();
+ assertThat(result.getValue())
+ .extracting(HttpRequest::getPayload)
+ .flatExtracting(BidRequest::getImp)
+ .extracting(Imp::getExt)
+ .containsOnlyNulls();
+ }
+
+ @Test
+ public void makeHttpRequestsShouldPromotePublisherIdToApp() {
+ // given
+ final BidRequest bidRequest = BidRequest.builder()
+ .imp(singletonList(Imp.builder().id("imp1").ext(givenImpExt("pub-1", null)).build()))
+ .app(App.builder().bundle("com.example.app").build())
+ .build();
+
+ // when
+ final Result
>> result = target.makeHttpRequests(bidRequest);
+
+ // then
+ assertThat(result.getErrors()).isEmpty();
+ assertThat(result.getValue())
+ .extracting(HttpRequest::getPayload)
+ .extracting(BidRequest::getApp)
+ .extracting(App::getPublisher)
+ .extracting(Publisher::getId)
+ .containsExactly("pub-1");
+ }
+
+ @Test
+ public void makeHttpRequestsShouldDropImpsWithInvalidExtAndKeepValidOnes() {
+ // given
+ final BidRequest bidRequest = givenBidRequest(
+ null,
+ imp -> imp.id("imp1").ext(givenImpExt("pub-1", null)),
+ imp -> imp.id("imp2").ext(givenImpExt(null, "plc-2")));
+
+ // when
+ final Result
>> result = target.makeHttpRequests(bidRequest);
+
+ // then
+ assertThat(result.getErrors()).hasSize(1)
+ .allSatisfy(error -> assertThat(error.getMessage()).isEqualTo("missing publisherId for imp imp2"));
+ assertThat(result.getValue())
+ .extracting(HttpRequest::getPayload)
+ .flatExtracting(BidRequest::getImp)
+ .extracting(Imp::getId)
+ .containsExactly("imp1");
+ }
+
+ @Test
+ public void makeHttpRequestsShouldSplitImpsByPublisherId() {
+ // given: one page, two Adswag publisher accounts. site.publisher.id is
+ // request-level, so folding these into one request would report — and
+ // pay — every impression under whichever account came first.
+ final BidRequest bidRequest = givenBidRequest(
+ null,
+ imp -> imp.id("imp-a").ext(givenImpExt("pub-a", null)),
+ imp -> imp.id("imp-b").ext(givenImpExt("pub-b", null)),
+ imp -> imp.id("imp-a-2").ext(givenImpExt("pub-a", null)));
+
+ // when
+ final Result
>> result = target.makeHttpRequests(bidRequest);
+
+ // then
+ assertThat(result.getErrors()).isEmpty();
+ assertThat(result.getValue()).hasSize(2);
+ assertThat(result.getValue())
+ .extracting(HttpRequest::getPayload)
+ .extracting(payload -> payload.getSite().getPublisher().getId())
+ .containsExactly("pub-a", "pub-b");
+ assertThat(result.getValue())
+ .extracting(HttpRequest::getPayload)
+ .extracting(payload -> payload.getImp().stream().map(Imp::getId).toList())
+ .containsExactly(List.of("imp-a", "imp-a-2"), List.of("imp-b"));
+ }
+
+ @Test
+ public void makeBidsShouldReturnEmptyResultForEmptyBody() {
+ // given: the endpoint's Prebid-path no-bid is an empty-body HTTP 200
+ final BidderCall
> result = target.makeBids(httpCall, null);
+
+ // then
+ assertThat(result.getErrors()).isEmpty();
+ assertThat(result.getValue()).isEmpty();
+ }
+
+ @Test
+ public void makeBidsShouldReturnErrorWhenResponseBodyCouldNotBeParsed() {
+ // given
+ final BidderCall
> result = target.makeBids(httpCall, null);
+
+ // then
+ assertThat(result.getErrors()).hasSize(1)
+ .allSatisfy(error -> {
+ assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response);
+ assertThat(error.getMessage()).startsWith("Failed to decode: Unrecognized token 'invalid_json'");
+ });
+ assertThat(result.getValue()).isEmpty();
+ }
+
+ @Test
+ public void makeBidsShouldSynthesizeIframeMarkupForBannerServeUrlBid() throws JsonProcessingException {
+ // given
+ final Bid responseBid = givenServeUrlBid("imp1");
+ final BidRequest bidRequest = givenBidRequest(imp -> imp.id("imp1").banner(givenBanner()));
+ final BidderCall
> result = target.makeBids(httpCall, bidRequest);
+
+ // then
+ assertThat(result.getErrors()).isEmpty();
+ assertThat(result.getValue())
+ .extracting(BidderBid::getType, BidderBid::getBidCurrency)
+ .containsExactly(tuple(BidType.banner, "EUR"));
+ assertThat(result.getValue())
+ .extracting(BidderBid::getBid)
+ .extracting(Bid::getAdm, Bid::getW, Bid::getH, Bid::getMtype)
+ .containsExactly(tuple(IFRAME_ADM, 300, 250, 1));
+ }
+
+ @Test
+ public void makeBidsShouldReturnVideoBidForVastMarkup() throws JsonProcessingException {
+ // given
+ final Bid responseBid = givenVastBid("imp1");
+ final BidRequest bidRequest = givenBidRequest(
+ imp -> imp.id("imp1").video(Video.builder().w(640).h(360).build()));
+ final BidderCall
> result = target.makeBids(httpCall, bidRequest);
+
+ // then
+ assertThat(result.getErrors()).isEmpty();
+ assertThat(result.getValue())
+ .extracting(BidderBid::getType)
+ .containsExactly(BidType.video);
+ assertThat(result.getValue())
+ .extracting(BidderBid::getBid)
+ .extracting(Bid::getAdm, Bid::getW, Bid::getH, Bid::getMtype)
+ .containsExactly(tuple(VAST_ADM, 640, 360, 2));
+ }
+
+ @Test
+ public void makeBidsShouldReturnAudioBidForVastMarkupOnAudioImp() throws JsonProcessingException {
+ // given
+ final Bid responseBid = givenVastBid("imp1");
+ final BidRequest bidRequest = givenBidRequest(
+ imp -> imp.id("imp1")
+ .banner(givenBanner())
+ .audio(Audio.builder().mimes(singletonList("audio/mpeg")).build()));
+ final BidderCall
> result = target.makeBids(httpCall, bidRequest);
+
+ // then
+ assertThat(result.getErrors()).isEmpty();
+ assertThat(result.getValue())
+ .extracting(BidderBid::getType)
+ .containsExactly(BidType.audio);
+ assertThat(result.getValue())
+ .extracting(BidderBid::getBid)
+ .extracting(Bid::getMtype)
+ .containsExactly(3);
+ }
+
+ @Test
+ public void makeBidsShouldResolveServeUrlBidOnMultiFormatImpAsBanner() throws JsonProcessingException {
+ // given: display precedence mirrors the endpoint's per-imp channel resolution
+ final Bid responseBid = givenServeUrlBid("imp1");
+ final BidRequest bidRequest = givenBidRequest(
+ imp -> imp.id("imp1").banner(givenBanner()).video(Video.builder().w(640).h(360).build()));
+ final BidderCall
> result = target.makeBids(httpCall, bidRequest);
+
+ // then
+ assertThat(result.getErrors()).isEmpty();
+ assertThat(result.getValue())
+ .extracting(BidderBid::getType)
+ .containsExactly(BidType.banner);
+ }
+
+ @Test
+ public void makeBidsShouldSetNurlForVideoServeUrlBidWithoutAdm() throws JsonProcessingException {
+ // given
+ final Bid responseBid = givenServeUrlBid("imp1");
+ final BidRequest bidRequest = givenBidRequest(
+ imp -> imp.id("imp1").video(Video.builder().w(640).h(360).build()));
+ final BidderCall
> result = target.makeBids(httpCall, bidRequest);
+
+ // then
+ assertThat(result.getErrors()).isEmpty();
+ assertThat(result.getValue())
+ .extracting(BidderBid::getBid)
+ .extracting(Bid::getNurl, Bid::getMtype)
+ .containsExactly(tuple(SERVE_URL, 2));
+ }
+
+ @Test
+ public void makeBidsShouldHonorMtypeWhenPresent() throws JsonProcessingException {
+ // given
+ final Bid responseBid = givenVastBid("imp1").toBuilder().mtype(2).build();
+ final BidRequest bidRequest = givenBidRequest(
+ imp -> imp.id("imp1")
+ .audio(Audio.builder().mimes(singletonList("audio/mpeg")).build())
+ .video(Video.builder().w(640).h(360).build()));
+ final BidderCall
> result = target.makeBids(httpCall, bidRequest);
+
+ // then
+ assertThat(result.getErrors()).isEmpty();
+ assertThat(result.getValue())
+ .extracting(BidderBid::getType)
+ .containsExactly(BidType.video);
+ }
+
+ @Test
+ public void makeBidsShouldReturnErrorForUnsupportedMtype() throws JsonProcessingException {
+ // given
+ final Bid responseBid = givenVastBid("imp1").toBuilder().mtype(4).build();
+ final BidRequest bidRequest = givenBidRequest(imp -> imp.id("imp1").banner(givenBanner()));
+ final BidderCall
> result = target.makeBids(httpCall, bidRequest);
+
+ // then
+ assertThat(result.getErrors()).hasSize(1)
+ .allSatisfy(error -> {
+ assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response);
+ assertThat(error.getMessage()).isEqualTo("unsupported bid.mtype 4 for impression imp1");
+ });
+ assertThat(result.getValue()).isEmpty();
+ }
+
+ @Test
+ public void makeBidsShouldReturnErrorForUnknownImp() throws JsonProcessingException {
+ // given
+ final Bid responseBid = givenServeUrlBid("unknown-imp");
+ final BidRequest bidRequest = givenBidRequest(imp -> imp.id("imp1").banner(givenBanner()));
+ final BidderCall
> result = target.makeBids(httpCall, bidRequest);
+
+ // then
+ assertThat(result.getErrors()).hasSize(1)
+ .allSatisfy(error -> {
+ assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response);
+ assertThat(error.getMessage())
+ .isEqualTo("bid adswag-unknown-imp references unknown imp unknown-imp");
+ });
+ assertThat(result.getValue()).isEmpty();
+ }
+
+ @Test
+ public void makeBidsShouldReturnErrorForBidWithoutAdmOrServeUrl() throws JsonProcessingException {
+ // given
+ final Bid responseBid = Bid.builder()
+ .id("adswag-imp1")
+ .impid("imp1")
+ .price(BigDecimal.valueOf(2.5))
+ .build();
+ final BidRequest bidRequest = givenBidRequest(imp -> imp.id("imp1").banner(givenBanner()));
+ final BidderCall
> result = target.makeBids(httpCall, bidRequest);
+
+ // then
+ assertThat(result.getErrors()).hasSize(1)
+ .allSatisfy(error -> {
+ assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response);
+ assertThat(error.getMessage())
+ .isEqualTo("bid adswag-imp1 has neither adm nor ext.adswag.serve_url");
+ });
+ assertThat(result.getValue()).isEmpty();
+ }
+
+ @Test
+ public void makeBidsShouldDefaultCurrencyToEur() throws JsonProcessingException {
+ // given
+ final BidResponse bidResponse = BidResponse.builder()
+ .seatbid(singletonList(SeatBid.builder().bid(singletonList(givenServeUrlBid("imp1"))).build()))
+ .build();
+ final BidRequest bidRequest = givenBidRequest(imp -> imp.id("imp1").banner(givenBanner()));
+ final BidderCall
> result = target.makeBids(httpCall, bidRequest);
+
+ // then
+ assertThat(result.getErrors()).isEmpty();
+ assertThat(result.getValue())
+ .extracting(BidderBid::getBidCurrency)
+ .containsExactly("EUR");
+ }
+
+ @Test
+ public void makeBidsShouldMapLiveCapturedResponse() throws IOException {
+ // given: a real bid.adswag.ai response captured 2026-08-11 (evergreen
+ // test publisher, banner 300x250, EUR 2.50 display bid; adm carries
+ // the loader script tag, ext.adswag.serve_url is the legacy fallback)
+ final String body = new String(Objects.requireNonNull(
+ getClass().getResourceAsStream("live-captured-response.json")).readAllBytes(),
+ StandardCharsets.UTF_8);
+ final BidRequest bidRequest = BidRequest.builder()
+ .id("adswag-pbs-smoke-1")
+ .imp(singletonList(Imp.builder().id("imp-1").banner(givenBanner()).build()))
+ .build();
+ final BidderCall
> result = target.makeBids(httpCall, bidRequest);
+
+ // then
+ assertThat(result.getErrors()).isEmpty();
+ assertThat(result.getValue())
+ .extracting(BidderBid::getType, BidderBid::getBidCurrency)
+ .containsExactly(tuple(BidType.banner, "EUR"));
+ assertThat(result.getValue())
+ .extracting(BidderBid::getBid)
+ .allSatisfy(bid -> {
+ assertThat(bid.getPrice()).isEqualByComparingTo(BigDecimal.valueOf(2.5));
+ assertThat(bid.getMtype()).isEqualTo(1);
+ assertThat(bid.getW()).isEqualTo(300);
+ assertThat(bid.getH()).isEqualTo(250);
+ // adm is the endpoint's loader script tag, passed through
+ // untouched — no iframe synthesis when markup is present.
+ assertThat(bid.getAdm()).startsWith("