diff --git a/src/main/java/org/prebid/server/bidder/loopme/LoopmeBidder.java b/src/main/java/org/prebid/server/bidder/loopme/LoopmeBidder.java new file mode 100644 index 00000000000..3774ef5e060 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/loopme/LoopmeBidder.java @@ -0,0 +1,95 @@ +package org.prebid.server.bidder.loopme; + +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.response.BidResponse; +import com.iab.openrtb.response.SeatBid; +import io.vertx.core.http.HttpMethod; +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.response.BidType; +import org.prebid.server.util.BidderUtil; +import org.prebid.server.util.HttpUtil; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +public class LoopmeBidder implements Bidder { + + private final String endpointUrl; + + private final JacksonMapper mapper; + + public LoopmeBidder(String endpointUrl, JacksonMapper mapper) { + this.endpointUrl = HttpUtil.validateUrl(Objects.requireNonNull(endpointUrl)); + this.mapper = Objects.requireNonNull(mapper); + } + + @Override + public Result>> makeHttpRequests(BidRequest request) { + + return Result.withValue(HttpRequest.builder() + .method(HttpMethod.POST) + .uri(endpointUrl) + .headers(HttpUtil.headers()) + .impIds(BidderUtil.impIds(request)) + .body(mapper.encodeToBytes(request)) + .payload(request) + .build()); + } + + @Override + public final Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + try { + final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); + return Result.of(extractBids(httpCall.getRequest().getPayload(), bidResponse), Collections.emptyList()); + } catch (DecodeException | PreBidException e) { + return Result.withError(BidderError.badServerResponse(e.getMessage())); + } + } + + private static List extractBids(BidRequest bidRequest, BidResponse bidResponse) { + if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) { + return Collections.emptyList(); + } + return bidsFromResponse(bidRequest, bidResponse); + } + + private static List bidsFromResponse(BidRequest bidRequest, BidResponse bidResponse) { + return bidResponse.getSeatbid().stream() + .filter(Objects::nonNull) + .map(SeatBid::getBid) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .map(bid -> BidderBid.of(bid, getBidType(bid.getImpid(), bidRequest.getImp()), bidResponse.getCur())) + .collect(Collectors.toList()); + } + + private static BidType getBidType(String impId, List imps) { + for (Imp imp : imps) { + if (imp.getId().equals(impId)) { + if (imp.getBanner() != null) { + return BidType.banner; + } else if (imp.getVideo() != null) { + return BidType.video; + } else if (imp.getAudio() != null) { + return BidType.audio; + } else if (imp.getXNative() != null) { + return BidType.xNative; + } + } + } + return BidType.banner; + } +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/loopme/ExtImpLoopme.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/loopme/ExtImpLoopme.java index f572890ab72..b07a6741019 100644 --- a/src/main/java/org/prebid/server/proto/openrtb/ext/request/loopme/ExtImpLoopme.java +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/loopme/ExtImpLoopme.java @@ -1,16 +1,18 @@ package org.prebid.server.proto.openrtb.ext.request.loopme; import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.AllArgsConstructor; import lombok.Value; -/** - * Defines the contract for bidrequest.imp[i].ext.loopme - */ -@AllArgsConstructor(staticName = "of") -@Value +@Value(staticConstructor = "of") public class ExtImpLoopme { - @JsonProperty("accountId") - String accountId; + @JsonProperty("publisherId") + String publisherId; + + @JsonProperty("bundleId") + String bundleId; + + @JsonProperty("placementId") + String placementId; + } diff --git a/src/main/java/org/prebid/server/spring/config/bidder/LoopmeConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/LoopmeConfiguration.java new file mode 100644 index 00000000000..757f8059ed7 --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/LoopmeConfiguration.java @@ -0,0 +1,40 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.loopme.LoopmeBidder; +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 javax.validation.constraints.NotBlank; + +@Configuration +@PropertySource(value = "classpath:/bidder-config/loopme.yaml", factory = YamlPropertySourceFactory.class) +public class LoopmeConfiguration { + + private static final String BIDDER_NAME = "loopme"; + + @Bean("loopmeConfigurationProperties") + @ConfigurationProperties("adapters.loopme") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps loopmeBidderDeps(BidderConfigurationProperties loopmeConfigurationProperties, + @NotBlank @Value("${external-url}") String externalUrl, + JacksonMapper mapper) { + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(loopmeConfigurationProperties) + .usersyncerCreator(UsersyncerCreator.create(externalUrl)) + .bidderCreator(config -> new LoopmeBidder(loopmeConfigurationProperties.getEndpoint(), mapper)) + .assemble(); + } +} diff --git a/src/main/resources/bidder-config/generic.yaml b/src/main/resources/bidder-config/generic.yaml index 172069d1a2e..b52c7ac21b1 100644 --- a/src/main/resources/bidder-config/generic.yaml +++ b/src/main/resources/bidder-config/generic.yaml @@ -41,21 +41,6 @@ adapters: - video supported-vendors: vendor-id: 0 - loopme: - enabled: false - endpoint: http://prebid-eu.loopmertb.com - meta-info: - maintainer-email: support@loopme.com - app-media-types: - - banner - - video - - native - site-media-types: - - banner - - video - - native - supported-vendors: - vendor-id: 109 zeta_global_ssp: enabled: false endpoint: https://ssp.disqus.com/bid/prebid-server?sid=GET_SID_FROM_ZETA diff --git a/src/main/resources/bidder-config/loopme.yaml b/src/main/resources/bidder-config/loopme.yaml new file mode 100644 index 00000000000..bee027b1b18 --- /dev/null +++ b/src/main/resources/bidder-config/loopme.yaml @@ -0,0 +1,23 @@ +adapters: + loopme: + endpoint: http://prebid.loopmertb.com + meta-info: + maintainer-email: prebid@loopme.com + app-media-types: + - banner + - video + - audio + - native + site-media-types: + - banner + - video + - audio + - native + supported-vendors: + vendor-id: 109 + usersync: + url: https://csync.loopme.me/?pubid=11393&gdpr={{gdpr}}&gdpr_consent={{gdpr_consent}}&us_privacy={{us_privacy}}&redirect={{redirect_url}} + redirect-url: /setuid?bidder=loopme&gdpr={{gdpr}}&gdpr_consent={{gdpr_consent}}&us_privacy={{us_privacy}}&uid={udid} + cookie-family-name: loopme + type: redirect + support-cors: false diff --git a/src/main/resources/static/bidder-params/loopme.json b/src/main/resources/static/bidder-params/loopme.json index f6b4a0a8b2e..89d95d8c011 100644 --- a/src/main/resources/static/bidder-params/loopme.json +++ b/src/main/resources/static/bidder-params/loopme.json @@ -3,13 +3,22 @@ "title": "Loopme Adapter Params", "description": "A schema which validates params accepted by the Loopme adapter", "type": "object", - "properties": { - "accountId": { + "publisherId": { "type": "string", - "description": "Account ID" + "description": "An id which identifies Loopme partner", + "minLength": 1 + }, + "bundleId": { + "type": "string", + "description": "An id which identifies app/site in Loopme", + "minLength": 1 + }, + "placementId": { + "type": "string", + "description": "A placement id in Loopme", + "minLength": 1 } }, - - "required": ["accountId"] -} \ No newline at end of file + "required": ["publisherId", "bundleId", "placementId"] +} 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 7598fd4cfae..aa10ebaf7a4 100644 --- a/src/test/groovy/org/prebid/server/functional/testcontainers/PbsConfig.groovy +++ b/src/test/groovy/org/prebid/server/functional/testcontainers/PbsConfig.groovy @@ -127,8 +127,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.loopme.meta-info.app-media-types" : "", - "adapters.generic.aliases.loopme.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" : "", diff --git a/src/test/java/org/prebid/server/bidder/loopme/LoopmeBidderTest.java b/src/test/java/org/prebid/server/bidder/loopme/LoopmeBidderTest.java new file mode 100644 index 00000000000..793b01f9333 --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/loopme/LoopmeBidderTest.java @@ -0,0 +1,250 @@ +package org.prebid.server.bidder.loopme; + +import com.fasterxml.jackson.core.JsonProcessingException; +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.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.loopme.ExtImpLoopme; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; +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.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 LoopmeBidderTest extends VertxTest { + + public static final String ENDPOINT_URL = "https://test.endpoint.com"; + + private final LoopmeBidder loopmeBidder = new LoopmeBidder(ENDPOINT_URL, jacksonMapper); + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + assertThatIllegalArgumentException().isThrownBy( + () -> new LoopmeBidder("invalid_url", jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldReturnExpectedHeaders() { + // given + final BidRequest bidRequest = givenBidRequest(identity(), identity()); + + // when + final Result>> result = loopmeBidder.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 makeHttpRequestsShouldUseCorrectUri() { + // given + final BidRequest bidRequest = givenBidRequest(identity(), identity()); + + // when + final Result>> result = loopmeBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getUri) + .containsExactly("https://test.endpoint.com"); + } + + @Test + public void makeHttpRequestsShouldHaveImpIds() { + // given + final Imp givenImp1 = givenImp(imp -> imp.id("givenImp1")); + final Imp givenImp2 = givenImp(imp -> imp.id("givenImp2")); + final BidRequest bidRequest = BidRequest.builder().imp(List.of(givenImp1, givenImp2)).build(); + + //when + final Result>> result = loopmeBidder.makeHttpRequests(bidRequest); + + //then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getImpIds) + .containsExactlyInAnyOrder(Set.of("givenImp1", "givenImp2")); + } + + @Test + public void makeHttpRequestsShouldMakeOneRequestForAllImps() { + // given + final BidRequest bidRequest = givenBidRequest( + identity(), + requestBuilder -> requestBuilder.imp(Arrays.asList( + givenImp(identity()), + givenImp(identity())))); + + // when + final Result>> result = loopmeBidder.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .hasSize(2); + } + + @Test + public void makeBidsShouldReturnBannerBidByDefault() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + BidRequest.builder().imp(singletonList(Imp.builder().id("123").build())).build(), + mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.impid("123")))); + + // when + final Result> result = loopmeBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .containsExactly(BidderBid.of(Bid.builder().impid("123").build(), banner, "USD")); + } + + @Test + public void makeBidsShouldReturnVideoBidIfNoBannerAndHasVideo() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + BidRequest.builder() + .imp(singletonList(Imp.builder().video(Video.builder().build()).id("123").build())) + .build(), + mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.impid("123")))); + + // when + final Result> result = loopmeBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .containsExactly(BidderBid.of(Bid.builder().impid("123").build(), video, "USD")); + } + + @Test + public void makeBidsShouldReturnBannerBidIfHasBothBannerAndVideo() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + BidRequest.builder() + .imp(singletonList(givenImp(identity()))) + .build(), + mapper.writeValueAsString(givenBidResponse(bidBuilder -> bidBuilder.impid("123")))); + + // when + final Result> result = loopmeBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .containsExactly(BidderBid.of(Bid.builder().impid("123").build(), banner, "USD")); + } + + @Test + public void makeBidsShouldReturnNativeBidIfNativeIsPresentInRequestImp() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall( + BidRequest.builder() + .imp(singletonList(Imp.builder().id("123").xNative(Native.builder().build()).build())) + .build(), + mapper.writeValueAsString( + givenBidResponse(bidBuilder -> bidBuilder.impid("123")))); + + // when + final Result> result = loopmeBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .containsExactly(BidderBid.of(Bid.builder().impid("123").build(), xNative, "USD")); + } + + @Test + public void makeBidsShouldReturnEmptyListIfBidResponseIsNull() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(null, mapper.writeValueAsString(null)); + + // when + final Result> result = loopmeBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnEmptyListIfBidResponseSeatBidIsNull() throws JsonProcessingException { + // given + final BidderCall httpCall = givenHttpCall(null, + mapper.writeValueAsString(BidResponse.builder().build())); + + // when + final Result> result = loopmeBidder.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).isEmpty(); + } + + private static BidRequest givenBidRequest( + UnaryOperator impCustomizer, + UnaryOperator requestCustomizer) { + return requestCustomizer.apply(BidRequest.builder() + .imp(singletonList(givenImp(impCustomizer)))) + .build(); + } + + private static Imp givenImp(UnaryOperator impCustomizer) { + return impCustomizer.apply(Imp.builder() + .id("123")) + .banner(Banner.builder().build()) + .video(Video.builder().build()) + .ext(mapper.valueToTree(ExtPrebid.of(null, + ExtImpLoopme.of("somePublisherId", "someBundleId", "somePlacementId")))) + .build(); + } + + private static BidResponse givenBidResponse(UnaryOperator bidCustomizer) { + return BidResponse.builder() + .cur("USD") + .seatbid(singletonList(SeatBid.builder() + .bid(singletonList(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/resources/org/prebid/server/it/openrtb2/loopme/test-auction-loopme-request.json b/src/test/resources/org/prebid/server/it/openrtb2/loopme/test-auction-loopme-request.json index ad5f545a942..bbff1db6fc9 100644 --- a/src/test/resources/org/prebid/server/it/openrtb2/loopme/test-auction-loopme-request.json +++ b/src/test/resources/org/prebid/server/it/openrtb2/loopme/test-auction-loopme-request.json @@ -9,7 +9,9 @@ }, "ext": { "loopme": { - "accountId": "testAccountId" + "publisherId": "testPublisherId", + "bundleId": "testBundleId", + "placementId": "testPlacementId" } } } diff --git a/src/test/resources/org/prebid/server/it/openrtb2/loopme/test-loopme-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/loopme/test-loopme-bid-request.json index 67e2f510ea6..d669ee5881f 100644 --- a/src/test/resources/org/prebid/server/it/openrtb2/loopme/test-loopme-bid-request.json +++ b/src/test/resources/org/prebid/server/it/openrtb2/loopme/test-loopme-bid-request.json @@ -3,15 +3,17 @@ "imp": [ { "id": "imp_id", - "secure": 1, "banner": { "w": 300, "h": 250 }, + "secure": 1, "ext": { "tid": "${json-unit.any-string}", "bidder": { - "accountId": "testAccountId" + "publisherId": "testPublisherId", + "bundleId": "testBundleId", + "placementId": "testPlacementId" } } } 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 3e43d0e35e2..50562212bd7 100644 --- a/src/test/resources/org/prebid/server/it/test-application.properties +++ b/src/test/resources/org/prebid/server/it/test-application.properties @@ -10,8 +10,6 @@ adapters.generic.aliases.ccx.enabled=true adapters.generic.aliases.ccx.endpoint=http://localhost:8090/ccx-exchange adapters.generic.aliases.infytv.enabled=true adapters.generic.aliases.infytv.endpoint=http://localhost:8090/infytv-exchange -adapters.generic.aliases.loopme.enabled=true -adapters.generic.aliases.loopme.endpoint=http://localhost:8090/loopme-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 @@ -281,6 +279,8 @@ adapters.logan.enabled=true adapters.logan.endpoint=http://localhost:8090/logan-exchange adapters.logicad.enabled=true adapters.logicad.endpoint=http://localhost:8090/logicad-exchange +adapters.loopme.enabled=true +adapters.loopme.endpoint=http://localhost:8090/loopme-exchange adapters.loyal.enabled=true adapters.loyal.endpoint=http://localhost:8090/loyal-exchange adapters.lunamedia.enabled=true