diff --git a/src/main/java/org/prebid/server/bidder/kobler/KoblerBidder.java b/src/main/java/org/prebid/server/bidder/kobler/KoblerBidder.java new file mode 100644 index 00000000000..cecf1c40c30 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/kobler/KoblerBidder.java @@ -0,0 +1,154 @@ +package org.prebid.server.bidder.kobler; + +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.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.ObjectUtils; +import org.prebid.server.bidder.Bidder; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.BidderError; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.Price; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.currency.CurrencyConversionService; +import org.prebid.server.exception.PreBidException; +import org.prebid.server.json.DecodeException; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.kobler.ExtImpKobler; +import org.prebid.server.proto.openrtb.ext.response.BidType; +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; +import java.util.List; +import java.util.Objects; + +public class KoblerBidder implements Bidder { + + private static final TypeReference> KOBLER_EXT_TYPE_REFERENCE = + new TypeReference<>() { + }; + private static final String DEFAULT_BID_CURRENCY = "USD"; + + private final String endpointUrl; + private final CurrencyConversionService currencyConversionService; + private final JacksonMapper mapper; + + public KoblerBidder(String endpointUrl, + CurrencyConversionService currencyConversionService, + JacksonMapper mapper) { + + this.endpointUrl = HttpUtil.validateUrl(endpointUrl); + this.currencyConversionService = Objects.requireNonNull(currencyConversionService); + this.mapper = Objects.requireNonNull(mapper); + } + + + @Override + public Result>> makeHttpRequests(BidRequest bidRequest) { + final List errors = new ArrayList<>(); + final List> requests = new ArrayList<>(); + + for (Imp imp : bidRequest.getImp()) { + try { + final ExtImpKobler impExt = parseImpExt(imp); + final Imp modifiedImp = modifyImp(imp, impExt, bidRequest); + requests.add(makeHttpRequest(bidRequest, modifiedImp)); + } catch (PreBidException e) { + errors.add(BidderError.badInput(e.getMessage())); + } + } + + return Result.of(requests, errors); + } + + private ExtImpKobler parseImpExt(Imp imp) { + try { + return mapper.mapper().convertValue(imp.getExt(), KOBLER_EXT_TYPE_REFERENCE).getBidder(); + } catch (IllegalArgumentException e) { + throw new PreBidException(e.getMessage()); + } + } + + private Imp modifyImp(Imp imp, ExtImpKobler extImpKobler, BidRequest bidRequest) { + final Price resolvedBidFloor = resolveBidFloor(imp, bidRequest); + + return imp.toBuilder() + .bidfloor(resolvedBidFloor.getValue()) + .bidfloorcur(resolvedBidFloor.getCurrency()) + .ext(mapper.mapper().valueToTree(extImpKobler)) + .build(); + } + + private Price resolveBidFloor(Imp imp, BidRequest bidRequest) { + final Price initialBidFloorPrice = Price.of(imp.getBidfloorcur(), imp.getBidfloor()); + return BidderUtil.shouldConvertBidFloor(initialBidFloorPrice, DEFAULT_BID_CURRENCY) + ? convertBidFloor(initialBidFloorPrice, bidRequest) + : initialBidFloorPrice; + } + + private Price convertBidFloor(Price bidFloorPrice, BidRequest bidRequest) { + final BigDecimal convertedPrice = currencyConversionService.convertCurrency( + bidFloorPrice.getValue(), + bidRequest, + bidFloorPrice.getCurrency(), + DEFAULT_BID_CURRENCY); + + return Price.of(DEFAULT_BID_CURRENCY, convertedPrice); + } + + private HttpRequest makeHttpRequest(BidRequest bidRequest, Imp imp) { + final BidRequest modifiedBidRequest = bidRequest.toBuilder() + .imp(Collections.singletonList(imp)) + .build(); + return BidderUtil.defaultRequest(modifiedBidRequest, endpointUrl, mapper); + } + + @Override + public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + try { + final List errors = new ArrayList<>(); + 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 List extractBids(BidResponse bidResponse, List errors) { + if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) { + 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) + .map(bid -> BidderBid.of(bid, getBidType(bid), bidResponse.getCur())) + .filter(Objects::nonNull) + .toList(); + } + + private BidType getBidType(Bid bid) { + final Integer markupType = ObjectUtils.defaultIfNull(bid.getMtype(), 0); + + return switch (markupType) { + case 1 -> BidType.banner; + default -> throw new PreBidException( + "could not define media type for impression: " + bid.getImpid()); + }; + } +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/kobler/ExtImpKobler.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/kobler/ExtImpKobler.java new file mode 100644 index 00000000000..ba8e94bb6a9 --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/kobler/ExtImpKobler.java @@ -0,0 +1,9 @@ +package org.prebid.server.proto.openrtb.ext.request.kobler; + +import lombok.Value; + +@Value(staticConstructor = "of") +public class ExtImpKobler { + + Boolean test; +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/KoblerConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/KoblerConfiguration.java new file mode 100644 index 00000000000..400e8f8d42a --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/KoblerConfiguration.java @@ -0,0 +1,41 @@ +package org.prebid.server.spring.config.bidder; + +import jakarta.validation.constraints.NotBlank; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.kobler.KoblerBidder; +import org.prebid.server.currency.CurrencyConversionService; +import org.prebid.server.json.JacksonMapper; +import org.prebid.server.spring.config.bidder.model.BidderConfigurationProperties; +import org.prebid.server.spring.config.bidder.util.BidderDepsAssembler; +import org.prebid.server.spring.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; + +@Configuration +@PropertySource(value = "classpath:/bidder-config/kobler.yaml", factory = YamlPropertySourceFactory.class) +public class KoblerConfiguration { + + private static final String BIDDER_NAME = "kobler"; + + @Bean("koblerConfigurationProperties") + @ConfigurationProperties("adapters.kobler") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + BidderDeps koblerBidderDeps(BidderConfigurationProperties koblerConfigurationProperies, + CurrencyConversionService currencyConversionService, + @NotBlank @Value("#{external-url}") String externalUrl, + JacksonMapper mapper) { + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(koblerConfigurationProperies) + .usersyncerCreator(UsersyncerCreator.create(externalUrl)) + .bidderCreator(config -> new KoblerBidder(config.getEndpoint(),currencyConversionService, mapper)) + .assemble(); + } +} diff --git a/src/main/resources/bidder-config/kobler.yaml b/src/main/resources/bidder-config/kobler.yaml new file mode 100644 index 00000000000..93844635f0d --- /dev/null +++ b/src/main/resources/bidder-config/kobler.yaml @@ -0,0 +1,14 @@ +adapters: + kobler: + endpoint: "https://bid.essrtb.com/bid/prebid_server_rtb_call" + endpointCompression: gzip + maintainer: + email: bidding-support@kobler.no + geoscope: + - NOR + - SWE + - DNK + capabilities: + site: + mediaTypes: + - banner diff --git a/src/main/resources/static/bidder-params/kobler.json b/src/main/resources/static/bidder-params/kobler.json new file mode 100644 index 00000000000..7e85601bfe8 --- /dev/null +++ b/src/main/resources/static/bidder-params/kobler.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Kobler Adapter Params", + "description": "A schema which validates params accepted by the Kobler adapter", + "type": "object", + + "properties": { + "test": { + "type": "boolean", + "description": "Whether the request is for testing only. When multiple ad units are submitted together, it is enough to set this parameter on the first one." + } + } +} diff --git a/src/test/java/org/prebid/server/bidder/kobler/KoblerBidderTest.java b/src/test/java/org/prebid/server/bidder/kobler/KoblerBidderTest.java new file mode 100644 index 00000000000..378aeac08a6 --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/kobler/KoblerBidderTest.java @@ -0,0 +1,169 @@ +package org.prebid.server.bidder.kobler; + +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.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.prebid.server.VertxTest; +import org.prebid.server.bidder.model.BidderBid; +import org.prebid.server.bidder.model.BidderCall; +import org.prebid.server.bidder.model.HttpRequest; +import org.prebid.server.bidder.model.HttpResponse; +import org.prebid.server.bidder.model.Result; +import org.prebid.server.currency.CurrencyConversionService; +import org.prebid.server.proto.openrtb.ext.ExtPrebid; +import org.prebid.server.proto.openrtb.ext.request.kobler.ExtImpKobler; +import org.prebid.server.proto.openrtb.ext.response.BidType; + +import java.math.BigDecimal; +import java.util.List; +import java.util.function.Function; +import java.util.function.UnaryOperator; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.BDDAssertions.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +public class KoblerBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "https://test.com"; + + @Mock + private CurrencyConversionService currencyConversionService; + + private KoblerBidder target; + + @BeforeEach + public void setUp() { + target = new KoblerBidder(ENDPOINT_URL, currencyConversionService, jacksonMapper); + } + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + assertThatIllegalArgumentException().isThrownBy(() -> new KoblerBidder( + "invalid_url", currencyConversionService, jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldConvertCurrencyIfRequired() { + // given + given(currencyConversionService.convertCurrency(any(), any(), eq("EUR"), eq("USD"))) + .willReturn(BigDecimal.TEN); + + final BidRequest bidRequest = BidRequest.builder() + .imp(List.of(givenImp(imp -> imp.bidfloor(BigDecimal.ONE).bidfloorcur("EUR")))) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBidfloor, Imp::getBidfloorcur) + .containsExactly(tuple(BigDecimal.TEN, "USD")); + } + + @Test + public void makeHttpRequestsShouldSetBidFloorIfConversionNotRequired() { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(List.of(givenImp(imp -> imp.bidfloor(BigDecimal.ONE).bidfloorcur("USD")))) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBidfloor, Imp::getBidfloorcur) + .containsExactly(tuple(BigDecimal.ONE, "USD")); + } + + @Test + public void makeBidsShouldReturnErrorForInvalidResponse() { + // given + final BidderCall httpCall = givenHttpCall(null, "invalid_response"); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> assertThat(error.getMessage()).contains("Failed to decode")); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnEmptyListForEmptyBidResponse() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(null, + mapper.writeValueAsString(BidResponse.builder().build())); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnBannerBid() throws JsonProcessingException { + // given + final BidResponse bidResponse = givenBidResponse(bid -> bid.impid("impId").price(BigDecimal.ONE).mtype(1)); + final BidderCall httpCall = givenHttpCall(null, mapper.writeValueAsString(bidResponse)); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .containsExactly(BidderBid.of(bidResponse.getSeatbid().get(0).getBid().get(0), BidType.banner, "USD")); + } + + private static Imp givenImp(UnaryOperator impCustomizer) { + return impCustomizer.apply( + Imp.builder().id("123").ext(givenImpExt(true))).build(); + } + + private static ObjectNode givenImpExt(boolean test) { + return mapper.valueToTree(ExtPrebid.of(null, ExtImpKobler.of(test))); + } + + private static BidResponse givenBidResponse(Function bidCustomizer) { + return BidResponse.builder() + .cur("USD") + .seatbid(List.of(SeatBid.builder() + .bid(List.of(bidCustomizer.apply(Bid.builder()).build())) + .build())) + .build(); + } + + private static BidderCall givenHttpCall(BidRequest bidRequest, String body) { + return BidderCall.succeededHttp( + HttpRequest.builder().payload(bidRequest).build(), + HttpResponse.of(200, null, body), + null); + } +} + + diff --git a/src/test/java/org/prebid/server/it/KiviAdsTest.java b/src/test/java/org/prebid/server/it/KiviAdsTest.java index 2ee3d792929..b09cbf2d7df 100644 --- a/src/test/java/org/prebid/server/it/KiviAdsTest.java +++ b/src/test/java/org/prebid/server/it/KiviAdsTest.java @@ -19,15 +19,15 @@ public class KiviAdsTest extends IntegrationTest { public void openrtb2AuctionShouldRespondWithBidsFromKiviAds() throws IOException, JSONException { // given WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/kiviads-exchange")) - .withRequestBody(equalToJson(jsonFrom("openrtb2/kiviads/test-kiviads-bid-request.json"))) - .willReturn(aResponse().withBody(jsonFrom("openrtb2/kiviads/test-kiviads-bid-response.json")))); + .withRequestBody(equalToJson(jsonFrom("openrtb2/kiviads/test-kobler-bid-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/kiviads/test-kobler-bid-response.json")))); // when - final Response response = responseFor("openrtb2/kiviads/test-auction-kiviads-request.json", + final Response response = responseFor("openrtb2/kiviads/test-auction-kobler-request.json", Endpoint.openrtb2_auction); // then - assertJsonEquals("openrtb2/kiviads/test-auction-kiviads-response.json", response, + assertJsonEquals("openrtb2/kiviads/test-auction-kobler-response.json", response, singletonList("kiviads")); } } diff --git a/src/test/java/org/prebid/server/it/KoblerTest.java b/src/test/java/org/prebid/server/it/KoblerTest.java new file mode 100644 index 00000000000..7df286f5d77 --- /dev/null +++ b/src/test/java/org/prebid/server/it/KoblerTest.java @@ -0,0 +1,31 @@ +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.*; +import static java.util.Collections.singletonList; + +public class KoblerTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromTheKoblerBidder() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/kobler-exchange")) + .withRequestBody(equalToJson( + jsonFrom("openrtb2/kobler/test-kobler-bid-request.json"))) + .willReturn(aResponse().withBody( + jsonFrom("openrtb2/kobler/test-kobler-bid-response.json")))); + + // when + final Response response = responseFor("openrtb2/kobler/test-auction-kobler-request.json", + Endpoint.openrtb2_auction); + + // then + assertJsonEquals("openrtb2/kobler/test-auction-kobler-response.json", response, singletonList("kobler")); + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/kobler/test-auction-kobler-request.json b/src/test/resources/org/prebid/server/it/openrtb2/kobler/test-auction-kobler-request.json new file mode 100644 index 00000000000..50b8e11ca82 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/kobler/test-auction-kobler-request.json @@ -0,0 +1,28 @@ +{ + "id": "test-request-id", + "imp": [ + { + "id": "imp-1", + "banner": { + "format": [ + { "w": 300, "h": 250 }, + { "w": 728, "h": 90 } + ] + }, + "bidfloor": 0.6, + "bidfloorcur": "USD", + "ext": { + "bidder": { + "test": true + } + } + } + ], + "site": { + "id": "site-1", + "page": "http://test-page.com" + }, + "cur": ["USD"], + "test": 1, + "tmax": 500 +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/kobler/test-auction-kobler-response.json b/src/test/resources/org/prebid/server/it/openrtb2/kobler/test-auction-kobler-response.json new file mode 100644 index 00000000000..8b19bcd079d --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/kobler/test-auction-kobler-response.json @@ -0,0 +1,19 @@ +{ + "id": "test-request-id", + "seatbid": [ + { + "bid": [ + { + "id": "bid-1", + "impid": "imp-1", + "price": 1.0, + "adm": "
Test Ad
", + "crid": "creative-1", + "w": 300, + "h": 250 + } + ] + } + ], + "cur": "USD" +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/kobler/test-kobler-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/kobler/test-kobler-bid-request.json new file mode 100644 index 00000000000..50b8e11ca82 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/kobler/test-kobler-bid-request.json @@ -0,0 +1,28 @@ +{ + "id": "test-request-id", + "imp": [ + { + "id": "imp-1", + "banner": { + "format": [ + { "w": 300, "h": 250 }, + { "w": 728, "h": 90 } + ] + }, + "bidfloor": 0.6, + "bidfloorcur": "USD", + "ext": { + "bidder": { + "test": true + } + } + } + ], + "site": { + "id": "site-1", + "page": "http://test-page.com" + }, + "cur": ["USD"], + "test": 1, + "tmax": 500 +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/kobler/test-kobler-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/kobler/test-kobler-bid-response.json new file mode 100644 index 00000000000..82ab7f669b2 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/kobler/test-kobler-bid-response.json @@ -0,0 +1,19 @@ +{ + "id": "test-request-id", + "seatbid": [ + { + "bid": [ + { + "id": "bid-1", + "impid": "imp-1", + "price": 1.0, + "adm": "
Test Ad from Kobler
", + "crid": "creative-1", + "w": 300, + "h": 250 + } + ] + } + ], + "cur": "USD" +} 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 2e115d00348..e705343676e 100644 --- a/src/test/resources/org/prebid/server/it/test-application.properties +++ b/src/test/resources/org/prebid/server/it/test-application.properties @@ -251,6 +251,8 @@ adapters.colossus.enabled=true adapters.colossus.endpoint=http://localhost:8090/colossus-exchange adapters.colossus.aliases.colossusssp.enabled=true adapters.colossus.aliases.colossusssp.endpoint=http://localhost:8090/colossusssp-exchange +adapters.kobler.enabled=true +adapters.kobler.endpoint=http://localhost:8090/kobler-exchange adapters.krushmedia.enabled=true adapters.krushmedia.endpoint=http://localhost:8090/krushmedia-exchange adapters.lemmadigital.enabled=true