Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/main/java/org/prebid/server/bidder/zentotem/ZentotemBidder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package org.prebid.server.bidder.zentotem;

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.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.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

public class ZentotemBidder implements Bidder<BidRequest> {

private final String endpointUrl;
private final JacksonMapper mapper;

public ZentotemBidder(String endpointUrl, JacksonMapper mapper) {
this.endpointUrl = HttpUtil.validateUrl(Objects.requireNonNull(endpointUrl));
this.mapper = Objects.requireNonNull(mapper);
}

@Override
public Result<List<HttpRequest<BidRequest>>> makeHttpRequests(BidRequest request) {
final List<HttpRequest<BidRequest>> httpRequests = new ArrayList<>();
final List<BidderError> errors = new ArrayList<>();

for (Imp imp : request.getImp()) {
try {
final BidRequest outgoingRequest = request.toBuilder()
.imp(Collections.singletonList(imp))
.build();
httpRequests.add(BidderUtil.defaultRequest(outgoingRequest, endpointUrl, mapper));
} catch (PreBidException e) {
errors.add(BidderError.badInput(e.getMessage()));
}
}

return Result.of(httpRequests, errors);
}

@Override
public Result<List<BidderBid>> makeBids(BidderCall<BidRequest> httpCall, BidRequest bidRequest) {
try {
final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class);
final List<BidderError> errors = new ArrayList<>();
return Result.of(extractBids(bidResponse, errors), errors);
} catch (DecodeException | PreBidException e) {
return Result.withError(BidderError.badServerResponse(e.getMessage()));
}
}

private static List<BidderBid> extractBids(BidResponse bidResponse, List<BidderError> errors) {
if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) {
return Collections.emptyList();
}
return bidsFromResponse(bidResponse, errors);
}

private static List<BidderBid> bidsFromResponse(BidResponse bidResponse, List<BidderError> errors) {
return bidResponse.getSeatbid().stream()
.filter(Objects::nonNull)
.map(SeatBid::getBid)
.filter(Objects::nonNull)
.flatMap(Collection::stream)
.filter(Objects::nonNull)
.map(bid -> makeBidderBid(bid, bidResponse.getCur(), errors))
.filter(Objects::nonNull)
.collect(Collectors.toList());
}

private static BidderBid makeBidderBid(Bid bid, String currency, List<BidderError> errors) {
final BidType bidType = getBidType(bid, errors);
return bidType != null
? BidderBid.of(bid, bidType, currency)
: null;
}

private static BidType getBidType(Bid bid, List<BidderError> errors) {
return switch (bid.getMtype()) {
case 1 -> BidType.banner;
case 2 -> BidType.video;
case 4 -> BidType.xNative;
case null, default -> {
errors.add(BidderError.badServerResponse(
"could not define media type for impression: " + bid.getImpid()));
yield null;
}
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package org.prebid.server.spring.config.bidder;

import org.prebid.server.bidder.BidderDeps;
import org.prebid.server.bidder.zentotem.ZentotemBidder;
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/zentotem.yaml", factory = YamlPropertySourceFactory.class)
public class ZentotemConfiguration {

private static final String BIDDER_NAME = "zentotem";

@Bean("zentotemConfigurationProperties")
@ConfigurationProperties("adapters.zentotem")
BidderConfigurationProperties configurationProperties() {
return new BidderConfigurationProperties();
}

@Bean
BidderDeps zentotemBidderDeps(BidderConfigurationProperties zentotemConfigurationProperties,
@NotBlank @Value("${external-url}") String externalUrl,
JacksonMapper mapper) {

return BidderDepsAssembler.forBidder(BIDDER_NAME)
.withConfig(zentotemConfigurationProperties)
.usersyncerCreator(UsersyncerCreator.create(externalUrl))
.bidderCreator(config -> new ZentotemBidder(config.getEndpoint(), mapper))
.assemble();
}
}
17 changes: 17 additions & 0 deletions src/main/resources/bidder-config/zentotem.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
adapters:
zentotem:
endpoint: https://rtb.zentotem.net/bid?sspuid=cqlnvfk00bhs0b6rci6g
endpoint-compression: gzip
modifying-vast-xml-allowed: true
meta-info:
maintainer-email: support@zentotem.net
app-media-types:
- banner
- video
- native
site-media-types:
- banner
- video
- native
supported-vendors:
vendor-id: 0
7 changes: 7 additions & 0 deletions src/main/resources/static/bidder-params/zentotem.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Zentotem Adapter Params",
"description": "A schema which validates params accepted by the Zentotem adapter",
"type": "object",
"properties": {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package org.prebid.server.bidder.zentotem;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.iab.openrtb.request.BidRequest;
import com.iab.openrtb.request.Imp;
import com.iab.openrtb.response.Bid;
import com.iab.openrtb.response.BidResponse;
import com.iab.openrtb.response.SeatBid;
import org.junit.jupiter.api.Test;
import org.prebid.server.VertxTest;
import org.prebid.server.bidder.model.BidderBid;
import org.prebid.server.bidder.model.BidderCall;
import org.prebid.server.bidder.model.BidderError;
import org.prebid.server.bidder.model.HttpRequest;
import org.prebid.server.bidder.model.HttpResponse;
import org.prebid.server.bidder.model.Result;
import org.prebid.server.proto.openrtb.ext.response.BidType;

import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.function.UnaryOperator;

import static java.util.Collections.singletonList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.prebid.server.bidder.model.BidderError.badServerResponse;

public class ZentotemBidderTest extends VertxTest {

private static final String ENDPOINT_URL = "https://test.endpoint.com";

private final ZentotemBidder target = new ZentotemBidder(ENDPOINT_URL, jacksonMapper);

@Test
public void creationShouldFailOnInvalidEndpointUrl() {
assertThatIllegalArgumentException().isThrownBy(() -> new ZentotemBidder("invalid_url", jacksonMapper));
}

@Test
public void makeHttpRequestsShouldCreateSeparateRequestForEachImp() {
// given
final BidRequest bidRequest = givenBidRequest(
imp -> imp.id("imp1"),
imp -> imp.id("imp2"));

// when
final Result<List<HttpRequest<BidRequest>>> result = target.makeHttpRequests(bidRequest);

// then
assertThat(result.getErrors()).isEmpty();
assertThat(result.getValue()).hasSize(2)
.extracting(HttpRequest::getImpIds)
.containsExactly(Set.of("imp1"), Set.of("imp2"));
}

@Test
public void makeHttpRequestsShouldSetCorrectUriAndBody() {
// given
final BidRequest bidRequest = givenBidRequest(imp -> imp.id("imp1"));

// when
final Result<List<HttpRequest<BidRequest>>> result = target.makeHttpRequests(bidRequest);

// then
final BidRequest expectedRequest = bidRequest.toBuilder()
.imp(singletonList(bidRequest.getImp().getFirst()))
.build();

assertThat(result.getErrors()).isEmpty();
assertThat(result.getValue()).hasSize(1).first()
.satisfies(httpRequest -> {
assertThat(httpRequest.getUri()).isEqualTo(ENDPOINT_URL);
assertThat(httpRequest.getPayload()).isEqualTo(expectedRequest);
assertThat(httpRequest.getBody()).isEqualTo(jacksonMapper.encodeToBytes(expectedRequest));
});
}

@Test
public void makeBidsShouldReturnErrorWhenResponseBodyCouldNotBeParsed() {
// given
final BidderCall<BidRequest> httpCall = givenHttpCall("invalid_json");

// when
final Result<List<BidderBid>> 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 makeBidsShouldReturnEmptyListWhenBidResponseOrSeatBidAreNull() throws JsonProcessingException {
// given
final BidResponse bidResponseWithNullSeatBid = BidResponse.builder().seatbid(null).build();
final BidderCall<BidRequest> httpCallWithNullSeatBid =
givenHttpCall(mapper.writeValueAsString(bidResponseWithNullSeatBid));

// when
final Result<List<BidderBid>> nullSeatBidResult = target.makeBids(httpCallWithNullSeatBid, null);

// then
assertThat(nullSeatBidResult.getErrors()).isEmpty();
assertThat(nullSeatBidResult.getValue()).isEmpty();
}

@Test
public void makeBidsShouldReturnBannerBid() throws JsonProcessingException {
// given
final Bid bannerBid = givenBid(1);
final BidderCall<BidRequest> httpCall = givenHttpCall(givenBidResponse(bannerBid));

// when
final Result<List<BidderBid>> result = target.makeBids(httpCall, null);

// then
assertThat(result.getErrors()).isEmpty();
assertThat(result.getValue()).containsOnly(BidderBid.of(bannerBid, BidType.banner, "USD"));
}

@Test
public void makeBidsShouldReturnVideoBid() throws JsonProcessingException {
// given
final Bid videoBid = givenBid(2);
final BidderCall<BidRequest> httpCall = givenHttpCall(givenBidResponse(videoBid));

// when
final Result<List<BidderBid>> result = target.makeBids(httpCall, null);

// then
assertThat(result.getErrors()).isEmpty();
assertThat(result.getValue()).containsOnly(BidderBid.of(videoBid, BidType.video, "USD"));
}

@Test
public void makeBidsShouldReturnNativeBid() throws JsonProcessingException {
// given
final Bid nativeBid = givenBid(4);
final BidderCall<BidRequest> httpCall = givenHttpCall(givenBidResponse(nativeBid));

// when
final Result<List<BidderBid>> result = target.makeBids(httpCall, null);

// then
assertThat(result.getErrors()).isEmpty();
assertThat(result.getValue()).containsOnly(BidderBid.of(nativeBid, BidType.xNative, "USD"));
}

@Test
public void makeBidsShouldReturnErrorIfMtypeIsUnsupported() throws JsonProcessingException {
// given
final Bid bidWithUnsupportedMtype = givenBid(3);
final BidderCall<BidRequest> httpCall = givenHttpCall(givenBidResponse(bidWithUnsupportedMtype));

// when
final Result<List<BidderBid>> result = target.makeBids(httpCall, null);

// then
assertThat(result.getValue()).isEmpty();
assertThat(result.getErrors()).hasSize(1)
.containsExactly(badServerResponse("could not define media type for impression: impId"));
}

private static BidRequest givenBidRequest(UnaryOperator<Imp.ImpBuilder>... impCustomizers) {
final List<Imp> imps = Arrays.stream(impCustomizers)
.map(ZentotemBidderTest::givenImp)
.toList();
return BidRequest.builder().imp(imps).build();
}

private static Imp givenImp(UnaryOperator<Imp.ImpBuilder> impCustomizer) {
return impCustomizer.apply(Imp.builder().id("impId")).build();
}

private static Bid givenBid(Integer mtype) {
return Bid.builder().id("bidId").impid("impId").price(BigDecimal.ONE).mtype(mtype).build();
}

private static String givenBidResponse(Bid... bids) throws JsonProcessingException {
return mapper.writeValueAsString(BidResponse.builder()
.cur("USD")
.seatbid(singletonList(SeatBid.builder().bid(List.of(bids)).build()))
.build());
}

private static BidderCall<BidRequest> givenHttpCall(String body) {
return BidderCall.succeededHttp(
HttpRequest.<BidRequest>builder().build(),
HttpResponse.of(200, null, body),
null);
}
}
Loading
Loading