From 7a25dc337e2a261a6df5aba0f76add915eda40f3 Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Sun, 30 Nov 2025 11:08:19 +0100 Subject: [PATCH 1/3] New Adapter: Clydo --- .../server/bidder/clydo/ClydoBidder.java | 178 ++++++++ .../ext/request/clydo/ExtImpClydo.java | 14 + .../config/bidder/ClydoConfiguration.java | 41 ++ src/main/resources/bidder-config/clydo.yaml | 21 + .../resources/static/bidder-params/clydo.json | 20 + .../server/bidder/clydo/ClydoBidderTest.java | 389 ++++++++++++++++++ .../java/org/prebid/server/it/ClydoTest.java | 32 ++ .../clydo/test-auction-clydo-request.json | 24 ++ .../clydo/test-auction-clydo-response.json | 42 ++ .../clydo/test-clydo-bid-request.json | 57 +++ .../clydo/test-clydo-bid-response.json | 20 + .../server/it/test-application.properties | 2 + 12 files changed, 840 insertions(+) create mode 100644 src/main/java/org/prebid/server/bidder/clydo/ClydoBidder.java create mode 100644 src/main/java/org/prebid/server/proto/openrtb/ext/request/clydo/ExtImpClydo.java create mode 100644 src/main/java/org/prebid/server/spring/config/bidder/ClydoConfiguration.java create mode 100644 src/main/resources/bidder-config/clydo.yaml create mode 100644 src/main/resources/static/bidder-params/clydo.json create mode 100644 src/test/java/org/prebid/server/bidder/clydo/ClydoBidderTest.java create mode 100644 src/test/java/org/prebid/server/it/ClydoTest.java create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/clydo/test-auction-clydo-request.json create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/clydo/test-auction-clydo-response.json create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/clydo/test-clydo-bid-request.json create mode 100644 src/test/resources/org/prebid/server/it/openrtb2/clydo/test-clydo-bid-response.json diff --git a/src/main/java/org/prebid/server/bidder/clydo/ClydoBidder.java b/src/main/java/org/prebid/server/bidder/clydo/ClydoBidder.java new file mode 100644 index 00000000000..74302d41419 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/clydo/ClydoBidder.java @@ -0,0 +1,178 @@ +package org.prebid.server.bidder.clydo; + +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.clydo.ExtImpClydo; +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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +public class ClydoBidder implements Bidder { + + private static final TypeReference> CLYDO_EXT_TYPE_REFERENCE = + new TypeReference<>() { + }; + + private static final String REGION_MACRO = "{{Region}}"; + private static final String PARTNER_ID_MACRO = "{{PartnerId}}"; + private static final String DEFAULT_REGION = "us"; + + private final String endpointUrl; + private final JacksonMapper mapper; + + public ClydoBidder(String endpointUrl, JacksonMapper mapper) { + this.endpointUrl = HttpUtil.validateUrl(Objects.requireNonNull(endpointUrl)); + this.mapper = Objects.requireNonNull(mapper); + } + + @Override + public Result>> makeHttpRequests(BidRequest request) { + final List errors = new ArrayList<>(); + final List> httpRequests = new ArrayList<>(); + + for (Imp imp : request.getImp()) { + try { + final ExtImpClydo extImpClydo = parseExtImp(imp); + final HttpRequest httpRequest = makeHttpRequest(request, imp, extImpClydo); + httpRequests.add(httpRequest); + } catch (PreBidException e) { + errors.add(BidderError.badInput(e.getMessage())); + } + } + + if (httpRequests.isEmpty()) { + return Result.withError(BidderError.badInput("found no valid impressions")); + } + + return Result.of(httpRequests, errors); + } + + private ExtImpClydo parseExtImp(Imp imp) { + try { + return mapper.mapper().convertValue(imp.getExt(), CLYDO_EXT_TYPE_REFERENCE).getBidder(); + } catch (IllegalArgumentException e) { + throw new PreBidException("Cannot deserialize ExtImpClydo: " + e.getMessage()); + } + } + + private static String resolveUrl(String endpoint, ExtImpClydo extImp) { + return endpoint + .replace(REGION_MACRO, getRegionInfo(extImp)) + .replace(PARTNER_ID_MACRO, extImp.getPartnerId()); + } + + private static String getRegionInfo(ExtImpClydo extImp) { + final String region = extImp.getRegion(); + if (region == null) { + return DEFAULT_REGION; + } + + return switch (region) { + case "us", "usw", "eu", "apac" -> region; + default -> DEFAULT_REGION; + }; + } + + private HttpRequest makeHttpRequest(BidRequest request, Imp imp, ExtImpClydo extImpClydo) { + final BidRequest outgoingRequest = request.toBuilder().imp(List.of(imp)).build(); + + return BidderUtil.defaultRequest(outgoingRequest, resolveUrl(endpointUrl, extImpClydo), mapper); + } + + @Override + public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + try { + final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); + final List bidderBids = extractBids(bidRequest, bidResponse); + return Result.of(bidderBids, Collections.emptyList()); + } catch (DecodeException e) { + return Result.withError(BidderError.badServerResponse(e.getMessage())); + } catch (PreBidException e) { + return Result.withError(BidderError.badInput(e.getMessage())); + } + } + + private List extractBids(BidRequest bidRequest, BidResponse bidResponse) { + if (bidResponse == null || bidResponse.getSeatbid() == null || bidResponse.getSeatbid().isEmpty()) { + return Collections.emptyList(); + } + + final Map impIdToBidTypeMap = buildImpIdToBidTypeMap(bidRequest); + + return bidResponse.getSeatbid().stream() + .filter(Objects::nonNull) + .map(SeatBid::getBid) + .filter(Objects::nonNull) + .flatMap(List::stream) + .filter(Objects::nonNull) + .map(bid -> BidderBid.of(bid, resolveBidType(bid, impIdToBidTypeMap), bidResponse.getCur())) + .collect(Collectors.toList()); + } + + private static Map buildImpIdToBidTypeMap(BidRequest bidRequest) { + if (bidRequest == null || bidRequest.getImp() == null || bidRequest.getImp().isEmpty()) { + return Collections.emptyMap(); + } + + final Map impIdToBidTypeMap = new HashMap<>(); + for (Imp imp : bidRequest.getImp()) { + final String impId = imp.getId(); + if (impIdToBidTypeMap.containsKey(impId)) { + throw new PreBidException("Duplicate impression ID found"); + } + + final BidType bidType = determineBidType(imp); + if (bidType == null) { + throw new PreBidException("Failed to get media type"); + } + + impIdToBidTypeMap.put(impId, bidType); + } + + return impIdToBidTypeMap; + } + + private static BidType determineBidType(Imp imp) { + if (imp.getAudio() != null) { + return BidType.audio; + } else if (imp.getVideo() != null) { + return BidType.video; + } else if (imp.getXNative() != null) { + return BidType.xNative; + } else if (imp.getBanner() != null) { + return BidType.banner; + } + + return null; + } + + private static BidType resolveBidType(Bid bid, Map impIdToBidTypeMap) { + if (bid.getImpid() == null) { + throw new PreBidException("Missing imp id for bid.id: '%s'".formatted(bid.getId())); + } + + return impIdToBidTypeMap.getOrDefault(bid.getImpid(), BidType.banner); + } +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/clydo/ExtImpClydo.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/clydo/ExtImpClydo.java new file mode 100644 index 00000000000..71ce2023724 --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/clydo/ExtImpClydo.java @@ -0,0 +1,14 @@ +package org.prebid.server.proto.openrtb.ext.request.clydo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Value; + +@Value(staticConstructor = "of") +public class ExtImpClydo { + + @JsonProperty("partnerId") + String partnerId; + + @JsonProperty("region") + String region; +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/ClydoConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/ClydoConfiguration.java new file mode 100644 index 00000000000..4bb92d317df --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/ClydoConfiguration.java @@ -0,0 +1,41 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.clydo.ClydoBidder; +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/clydo.yaml", factory = YamlPropertySourceFactory.class) +public class ClydoConfiguration { + + private static final String BIDDER_NAME = "clydo"; + + @Bean("clydoConfigurationProperties") + @ConfigurationProperties("adapters.clydo") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps clydoBidderDeps(BidderConfigurationProperties clydoConfigurationProperties, + @NotBlank @Value("${external-url}") String externalUrl, + JacksonMapper mapper) { + + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(clydoConfigurationProperties) + .usersyncerCreator(UsersyncerCreator.create(externalUrl)) + .bidderCreator(config -> new ClydoBidder(config.getEndpoint(), mapper)) + .assemble(); + } +} diff --git a/src/main/resources/bidder-config/clydo.yaml b/src/main/resources/bidder-config/clydo.yaml new file mode 100644 index 00000000000..aa90e3a4f07 --- /dev/null +++ b/src/main/resources/bidder-config/clydo.yaml @@ -0,0 +1,21 @@ +adapters: + clydo: + endpoint: http://region={{Region}}.clydo.io/partnerId={{PartnerId}} + modifying-vast-xml-allowed: true + endpoint-compression: gzip + geoscope: + - global + meta-info: + maintainer-email: cto@clydo.io + app-media-types: + - banner + - video + - audio + - native + site-media-types: + - banner + - video + - audio + - native + supported-vendors: + vendor-id: 0 diff --git a/src/main/resources/static/bidder-params/clydo.json b/src/main/resources/static/bidder-params/clydo.json new file mode 100644 index 00000000000..71b60d09405 --- /dev/null +++ b/src/main/resources/static/bidder-params/clydo.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Clydo Adapter Params", + "description": "A schema which validates params accepted by the Clydo adapter", + "type": "object", + "properties": { + "partnerId": { + "type": "string", + "description": "Partner ID", + "minLength": 1 + }, + "region": { + "type": "string", + "description": "Regional endpoint identifier (us, usw, eu, apac)", + "enum": ["us", "usw", "eu", "apac"] + } + }, + + "required": ["partnerId"] +} diff --git a/src/test/java/org/prebid/server/bidder/clydo/ClydoBidderTest.java b/src/test/java/org/prebid/server/bidder/clydo/ClydoBidderTest.java new file mode 100644 index 00000000000..21eeb253aae --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/clydo/ClydoBidderTest.java @@ -0,0 +1,389 @@ +package org.prebid.server.bidder.clydo; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.Audio; +import com.iab.openrtb.request.Banner; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Native; +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.Test; +import org.prebid.server.VertxTest; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.HttpResponse; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.clydo.ExtImpClydo; + +import java.util.List; +import java.util.function.UnaryOperator; + +import static java.util.Arrays.asList; +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.proto.openrtb.ext.response.BidType.audio; +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; +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 ClydoBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "http://region={{Region}}.clydo.io/partnerId={{PartnerId}}"; + + private final ClydoBidder target = new ClydoBidder(ENDPOINT_URL, jacksonMapper); + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + assertThatIllegalArgumentException().isThrownBy(() -> new ClydoBidder("invalid_url", jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldMakeOneRequestPerImp() { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(asList( + givenImp(UnaryOperator.identity()), + givenImp(imp -> imp.id("321").ext(mapper.valueToTree(ExtPrebid + .of(null, ExtImpClydo.of("parentId", "us"))))))) + .build(); + + //when + final Result>> result = target.makeHttpRequests(bidRequest); + + //then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(2) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getImp) + .extracting(List::size) + .containsOnly(1); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getId) + .containsExactlyInAnyOrder("123", "321"); + } + + @Test + public void shouldMakeOneRequestWhenOneImpIsValidAndAnotherIsNot() { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(asList(givenImp(UnaryOperator.identity()), givenBadImp(UnaryOperator.identity()))) + .build(); + + //when + final Result>> result = target.makeHttpRequests(bidRequest); + + //then + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getId) + .containsExactly("123"); + + } + + @Test + public void makeHttpRequestsShouldIncludeImpIds() { + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> impBuilder.id("imp1")); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getId) + .containsExactly("imp1"); + } + + @Test + public void makeHttpRequestsShouldUseCorrectUri() { + // given + final BidRequest bidRequest = givenBidRequest(UnaryOperator.identity()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getUri) + .containsExactly("http://region=us.clydo.io/partnerId=parentId"); + } + + @Test + public void makeHttpRequestsShouldReturnErrorIfImpExtCannotBeParsed() { + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> + impBuilder.ext(mapper.createObjectNode().set("bidder", mapper.createArrayNode()))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().getFirst().getMessage()).startsWith("found no valid impressions"); + + } + + @Test + public void makeBidsShouldReturnEmptyListIfBidResponseIsNull() 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 makeHttpRequestsShouldReturnExpectedHeaders() { + // given + final BidRequest bidRequest = givenBidRequest(UnaryOperator.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)) + .satisfies(headers -> assertThat(headers.get(ACCEPT_HEADER)) + .isEqualTo(APPLICATION_JSON_VALUE)); + assertThat(result.getErrors()).isEmpty(); + } + + @Test + public void makeHttpRequestsShouldUseRegionUsInEndpoint() { + // given + final BidRequest bidRequest = givenBidRequest(imp -> imp + .id("imp-us") + .ext(mapper.valueToTree(ExtPrebid.of(null, + ExtImpClydo.of("partner123", "us"))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1).first() + .extracting(HttpRequest::getUri) + .isEqualTo("http://region=us.clydo.io/partnerId=partner123"); + } + + @Test + public void makeHttpRequestsShouldUseRegionEuInEndpointWithEuRegion() { + // given + final BidRequest bidRequest = givenBidRequest(imp -> imp + .id("imp-us") + .ext(mapper.valueToTree(ExtPrebid.of(null, + ExtImpClydo.of("partner123", "eu"))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1).first() + .extracting(HttpRequest::getUri) + .isEqualTo("http://region=eu.clydo.io/partnerId=partner123"); + } + + @Test + public void makeBidsShouldReturnErrorIfResponseBodyCouldNotBeParsed() { + // given + final BidderCall httpCall = givenHttpCall("invalid"); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getMessage()).startsWith("Failed to decode: Unrecognized token 'invalid':"); + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response); + }); + } + + @Test + public void makeHttpRequestsShouldUseDefaultRegionUsWhenRegionIsNull() { + // given + final BidRequest bidRequest = givenBidRequest(imp -> imp + .id("imp-null-region") + .ext(mapper.valueToTree(ExtPrebid.of(null, + ExtImpClydo.of("partner123", null))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1).first() + .extracting(HttpRequest::getUri) + .isEqualTo("http://region=us.clydo.io/partnerId=partner123"); + } + + @Test + public void makeBidsShouldReturnEmptyListIfBidResponseSeatBidIsNull() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + mapper.writeValueAsString(BidResponse.builder().build())); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnBannerBid() throws JsonProcessingException { + final BidRequest bidRequest = givenBidRequest(imp -> imp + .banner(Banner.builder().w(300).h(250).build())); + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder.impid("123"))); + + final Result> result = target.makeBids(httpCall, bidRequest); + + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).extracting(BidderBid::getType).containsExactly(banner); + } + + @Test + public void makeBidsShouldReturnVideoBid() throws JsonProcessingException { + final BidRequest bidRequest = givenBidRequest(imp -> imp + .video(Video.builder().mimes(singletonList("video/mp4")).build())); + final BidderCall httpCall = givenHttpCall(givenBidResponse(bidBuilder -> bidBuilder.impid("123"))); + + final Result> result = target.makeBids(httpCall, bidRequest); + + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).extracting(BidderBid::getType).containsExactly(video); + } + + @Test + public void makeBidsShouldReturnNativeBid() throws JsonProcessingException { + final BidRequest bidRequest = givenBidRequest(imp -> imp + .xNative(Native.builder().request("{\"assets\":[]}").build())); + final BidderCall httpCall = givenHttpCall(givenBidResponse(bidBuilder -> bidBuilder.impid("123"))); + + final Result> result = target.makeBids(httpCall, bidRequest); + + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).extracting(BidderBid::getType).containsExactly(xNative); + } + + @Test + public void makeBidsShouldReturnAudioBid() throws JsonProcessingException { + final BidRequest bidRequest = givenBidRequest(imp -> imp + .banner(null) + .audio(Audio.builder().mimes(singletonList("audio/mp3")).build())); + final BidderCall httpCall = givenHttpCall(givenBidResponse(bidBuilder -> bidBuilder.impid("123"))); + + final Result> result = target.makeBids(httpCall, bidRequest); + + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).extracting(BidderBid::getType).containsExactly(audio); + } + + @Test + public void makeBidsShouldReturnErrorForWrongType() throws JsonProcessingException { + // given + final ObjectNode bidExt = mapper.createObjectNode() + .set("prebid", mapper.createArrayNode()); + final BidderCall httpCall = givenHttpCall( + givenBidResponse(bidBuilder -> bidBuilder.ext(bidExt).id("123"))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()) + .containsExactly( + BidderError.badInput("Missing imp id for bid.id: '123'")); + } + + @Test + public void makeBidsShouldReturnErrorWhenImpHasNoMediaType() throws JsonProcessingException { + final BidRequest bidRequest = givenBidRequest(imp -> imp.banner(null).video(null).xNative(null).audio(null)); + final BidderCall httpCall = givenHttpCall(givenBidResponse(bidBuilder -> bidBuilder.impid("123"))); + + final Result> result = target.makeBids(httpCall, bidRequest); + + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1).first() + .satisfies(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()).isEqualTo("Failed to get media type"); + }); + } + + private static BidRequest givenBidRequest( + UnaryOperator bidRequestCustomizer, + UnaryOperator impCustomizer) { + + return bidRequestCustomizer.apply(BidRequest.builder() + .imp(singletonList(givenImp(impCustomizer)))) + .build(); + } + + private static BidRequest givenBidRequest(UnaryOperator impCustomizer) { + return givenBidRequest(UnaryOperator.identity(), impCustomizer); + } + + private static Imp givenImp(UnaryOperator impCustomizer) { + return impCustomizer.apply(Imp.builder() + .id("123") + .ext(mapper.valueToTree(ExtPrebid.of(null, + ExtImpClydo.of("parentId", "us"))))) + .build(); + } + + private static Imp givenBadImp(UnaryOperator impCustomizer) { + return impCustomizer.apply(Imp.builder() + .id("invalidImp") + .ext(mapper.createObjectNode().set("bidder", mapper.createArrayNode()))) + .build(); + } + + private static String givenBidResponse(UnaryOperator bidCustomizer) throws JsonProcessingException { + final BidResponse bidResponse = BidResponse.builder() + .cur("USD") + .seatbid(singletonList(SeatBid.builder().bid(singletonList(bidCustomizer.apply(Bid.builder()).build())) + .build())) + .build(); + return mapper.writeValueAsString(bidResponse); + } + + private static BidderCall givenHttpCall(String body) { + return BidderCall.succeededHttp( + HttpRequest.builder().payload(null).build(), + HttpResponse.of(200, null, body), + null); + } +} diff --git a/src/test/java/org/prebid/server/it/ClydoTest.java b/src/test/java/org/prebid/server/it/ClydoTest.java new file mode 100644 index 00000000000..6bb262350a3 --- /dev/null +++ b/src/test/java/org/prebid/server/it/ClydoTest.java @@ -0,0 +1,32 @@ +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 ClydoTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromClydo() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/clydo-exchange")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/clydo/test-clydo-bid-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/clydo/test-clydo-bid-response.json")))); + + // when + final Response response = responseFor("openrtb2/clydo/test-auction-clydo-request.json", + Endpoint.openrtb2_auction); + + // then + assertJsonEquals("openrtb2/clydo/test-auction-clydo-response.json", response, singletonList("clydo")); + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/clydo/test-auction-clydo-request.json b/src/test/resources/org/prebid/server/it/openrtb2/clydo/test-auction-clydo-request.json new file mode 100644 index 00000000000..bfe3de91342 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/clydo/test-auction-clydo-request.json @@ -0,0 +1,24 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "banner": { + "w": 300, + "h": 250 + }, + "ext": { + "clydo": { + "region": "us", + "partnerId": "testPartnerId" + } + } + } + ], + "tmax": 5000, + "regs": { + "ext": { + "gdpr": 0 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/clydo/test-auction-clydo-response.json b/src/test/resources/org/prebid/server/it/openrtb2/clydo/test-auction-clydo-response.json new file mode 100644 index 00000000000..4b1494175f4 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/clydo/test-auction-clydo-response.json @@ -0,0 +1,42 @@ +{ + "id": "request_id", + "seatbid": [ + { + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "exp": 300, + "price": 3.33, + "adm": "adm001", + "adid": "adid", + "cid": "cid", + "crid": "crid", + "w": 300, + "h": 250, + "ext": { + "prebid": { + "type": "banner", + "meta": { + "adaptercode": "clydo" + } + }, + "origbidcpm": 3.33 + } + } + ], + "seat": "clydo", + "group": 0 + } + ], + "cur": "USD", + "ext": { + "responsetimemillis": { + "clydo": "{{ clydo.response_time_ms }}" + }, + "prebid": { + "auctiontimestamp": 0 + }, + "tmaxrequest": 5000 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/clydo/test-clydo-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/clydo/test-clydo-bid-request.json new file mode 100644 index 00000000000..e43823a0317 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/clydo/test-clydo-bid-request.json @@ -0,0 +1,57 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "secure": 1, + "banner": { + "w": 300, + "h": 250 + }, + "ext": { + "tid": "${json-unit.any-string}", + "bidder": { + "region": "us", + "partnerId": "testPartnerId" + } + } + } + ], + "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/clydo/test-clydo-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/clydo/test-clydo-bid-response.json new file mode 100644 index 00000000000..46fdbbec2ad --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/clydo/test-clydo-bid-response.json @@ -0,0 +1,20 @@ +{ + "id": "request_id", + "seatbid": [ + { + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "price": 3.33, + "adid": "adid", + "crid": "crid", + "cid": "cid", + "adm": "adm001", + "h": 250, + "w": 300 + } + ] + } + ] +} 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 1a073dcda06..50eccbdb992 100644 --- a/src/test/resources/org/prebid/server/it/test-application.properties +++ b/src/test/resources/org/prebid/server/it/test-application.properties @@ -191,6 +191,8 @@ adapters.brave.enabled=true adapters.brave.endpoint=http://localhost:8090/brave-exchange adapters.bwx.enabled=true adapters.bwx.endpoint=http://localhost:8090/bwx-exchange +adapters.clydo.enabled=true +adapters.clydo.endpoint=http://localhost:8090/clydo-exchange adapters.cointraffic.enabled=true adapters.cointraffic.endpoint=http://localhost:8090/cointraffic-exchange adapters.connatix.enabled=true From d370c91014922d018402b536215cb2ebd08ec911 Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Sat, 6 Dec 2025 14:11:55 +0100 Subject: [PATCH 2/3] fix comments --- .../server/bidder/clydo/ClydoBidder.java | 59 +++++++++++-------- .../server/bidder/clydo/ClydoBidderTest.java | 21 +------ 2 files changed, 36 insertions(+), 44 deletions(-) diff --git a/src/main/java/org/prebid/server/bidder/clydo/ClydoBidder.java b/src/main/java/org/prebid/server/bidder/clydo/ClydoBidder.java index 74302d41419..bb5a20f30ec 100644 --- a/src/main/java/org/prebid/server/bidder/clydo/ClydoBidder.java +++ b/src/main/java/org/prebid/server/bidder/clydo/ClydoBidder.java @@ -2,10 +2,13 @@ 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.response.Bid; import com.iab.openrtb.response.BidResponse; import com.iab.openrtb.response.SeatBid; +import io.netty.handler.codec.http.HttpHeaderValues; +import io.vertx.core.MultiMap; import org.prebid.server.bidder.Bidder; import org.prebid.server.bidder.model.BidderBid; import org.prebid.server.bidder.model.BidderCall; @@ -20,6 +23,7 @@ import org.prebid.server.proto.openrtb.ext.response.BidType; import org.prebid.server.util.BidderUtil; import org.prebid.server.util.HttpUtil; +import org.prebid.server.util.ObjectUtil; import java.util.ArrayList; import java.util.Collections; @@ -38,6 +42,7 @@ public class ClydoBidder implements Bidder { private static final String REGION_MACRO = "{{Region}}"; private static final String PARTNER_ID_MACRO = "{{PartnerId}}"; private static final String DEFAULT_REGION = "us"; + private static final String X_OPENRTB_VERSION = "2.5"; private final String endpointUrl; private final JacksonMapper mapper; @@ -77,10 +82,36 @@ private ExtImpClydo parseExtImp(Imp imp) { } } + private HttpRequest makeHttpRequest(BidRequest request, Imp imp, ExtImpClydo extImpClydo) { + final BidRequest outgoingRequest = request.toBuilder().imp(List.of(imp)).build(); + + return BidderUtil.defaultRequest( + outgoingRequest, + constructHeaders(request), + resolveUrl(endpointUrl, extImpClydo), mapper); + } + + private static MultiMap constructHeaders(BidRequest bidRequest) { + final Device device = bidRequest.getDevice(); + final MultiMap headers = HttpUtil.headers(); + + headers.set(HttpUtil.X_OPENRTB_VERSION_HEADER, X_OPENRTB_VERSION); + headers.set(HttpUtil.ACCEPT_HEADER, HttpHeaderValues.APPLICATION_JSON); + headers.set(HttpUtil.CONTENT_TYPE_HEADER, HttpUtil.APPLICATION_JSON_CONTENT_TYPE); + HttpUtil.addHeaderIfValueIsNotEmpty(headers, HttpUtil.X_FORWARDED_FOR_HEADER, + ObjectUtil.getIfNotNull(device, Device::getIpv6)); + HttpUtil.addHeaderIfValueIsNotEmpty(headers, HttpUtil.X_FORWARDED_FOR_HEADER, + ObjectUtil.getIfNotNull(device, Device::getIp)); + HttpUtil.addHeaderIfValueIsNotEmpty(headers, HttpUtil.USER_AGENT_HEADER, + ObjectUtil.getIfNotNull(device, Device::getUa)); + + return headers; + } + private static String resolveUrl(String endpoint, ExtImpClydo extImp) { return endpoint .replace(REGION_MACRO, getRegionInfo(extImp)) - .replace(PARTNER_ID_MACRO, extImp.getPartnerId()); + .replace(PARTNER_ID_MACRO, HttpUtil.encodeUrl(extImp.getPartnerId())); } private static String getRegionInfo(ExtImpClydo extImp) { @@ -95,22 +126,14 @@ private static String getRegionInfo(ExtImpClydo extImp) { }; } - private HttpRequest makeHttpRequest(BidRequest request, Imp imp, ExtImpClydo extImpClydo) { - final BidRequest outgoingRequest = request.toBuilder().imp(List.of(imp)).build(); - - return BidderUtil.defaultRequest(outgoingRequest, resolveUrl(endpointUrl, extImpClydo), mapper); - } - @Override public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { try { final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); final List bidderBids = extractBids(bidRequest, bidResponse); - return Result.of(bidderBids, Collections.emptyList()); - } catch (DecodeException e) { + return Result.withValues(bidderBids); + } catch (DecodeException | PreBidException e) { return Result.withError(BidderError.badServerResponse(e.getMessage())); - } catch (PreBidException e) { - return Result.withError(BidderError.badInput(e.getMessage())); } } @@ -132,17 +155,9 @@ private List extractBids(BidRequest bidRequest, BidResponse bidRespon } private static Map buildImpIdToBidTypeMap(BidRequest bidRequest) { - if (bidRequest == null || bidRequest.getImp() == null || bidRequest.getImp().isEmpty()) { - return Collections.emptyMap(); - } - final Map impIdToBidTypeMap = new HashMap<>(); for (Imp imp : bidRequest.getImp()) { final String impId = imp.getId(); - if (impIdToBidTypeMap.containsKey(impId)) { - throw new PreBidException("Duplicate impression ID found"); - } - final BidType bidType = determineBidType(imp); if (bidType == null) { throw new PreBidException("Failed to get media type"); @@ -165,14 +180,10 @@ private static BidType determineBidType(Imp imp) { return BidType.banner; } - return null; + throw new PreBidException("Failed to get media type"); } private static BidType resolveBidType(Bid bid, Map impIdToBidTypeMap) { - if (bid.getImpid() == null) { - throw new PreBidException("Missing imp id for bid.id: '%s'".formatted(bid.getId())); - } - return impIdToBidTypeMap.getOrDefault(bid.getImpid(), BidType.banner); } } diff --git a/src/test/java/org/prebid/server/bidder/clydo/ClydoBidderTest.java b/src/test/java/org/prebid/server/bidder/clydo/ClydoBidderTest.java index 21eeb253aae..03e34c9c2ed 100644 --- a/src/test/java/org/prebid/server/bidder/clydo/ClydoBidderTest.java +++ b/src/test/java/org/prebid/server/bidder/clydo/ClydoBidderTest.java @@ -1,7 +1,6 @@ package org.prebid.server.bidder.clydo; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.node.ObjectNode; import com.iab.openrtb.request.Audio; import com.iab.openrtb.request.Banner; import com.iab.openrtb.request.BidRequest; @@ -310,24 +309,6 @@ public void makeBidsShouldReturnAudioBid() throws JsonProcessingException { assertThat(result.getValue()).extracting(BidderBid::getType).containsExactly(audio); } - @Test - public void makeBidsShouldReturnErrorForWrongType() throws JsonProcessingException { - // given - final ObjectNode bidExt = mapper.createObjectNode() - .set("prebid", mapper.createArrayNode()); - final BidderCall httpCall = givenHttpCall( - givenBidResponse(bidBuilder -> bidBuilder.ext(bidExt).id("123"))); - - // when - final Result> result = target.makeBids(httpCall, null); - - // then - assertThat(result.getValue()).isEmpty(); - assertThat(result.getErrors()) - .containsExactly( - BidderError.badInput("Missing imp id for bid.id: '123'")); - } - @Test public void makeBidsShouldReturnErrorWhenImpHasNoMediaType() throws JsonProcessingException { final BidRequest bidRequest = givenBidRequest(imp -> imp.banner(null).video(null).xNative(null).audio(null)); @@ -338,7 +319,7 @@ public void makeBidsShouldReturnErrorWhenImpHasNoMediaType() throws JsonProcessi assertThat(result.getValue()).isEmpty(); assertThat(result.getErrors()).hasSize(1).first() .satisfies(error -> { - assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response); assertThat(error.getMessage()).isEqualTo("Failed to get media type"); }); } From ca67d5c43d5877d2d1b0acfb2a120ec86f047ef6 Mon Sep 17 00:00:00 2001 From: pkaczmarek Date: Sat, 6 Dec 2025 14:12:55 +0100 Subject: [PATCH 3/3] fix comments --- .../java/org/prebid/server/bidder/clydo/ClydoBidder.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/java/org/prebid/server/bidder/clydo/ClydoBidder.java b/src/main/java/org/prebid/server/bidder/clydo/ClydoBidder.java index bb5a20f30ec..bd29a8333db 100644 --- a/src/main/java/org/prebid/server/bidder/clydo/ClydoBidder.java +++ b/src/main/java/org/prebid/server/bidder/clydo/ClydoBidder.java @@ -116,13 +116,10 @@ private static String resolveUrl(String endpoint, ExtImpClydo extImp) { private static String getRegionInfo(ExtImpClydo extImp) { final String region = extImp.getRegion(); - if (region == null) { - return DEFAULT_REGION; - } return switch (region) { case "us", "usw", "eu", "apac" -> region; - default -> DEFAULT_REGION; + case null, default -> DEFAULT_REGION; }; }