Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sonobi Bidder: Native and Currency Conversion Support #3492

Merged
merged 1 commit into from
Oct 11, 2024
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
74 changes: 47 additions & 27 deletions src/main/java/org/prebid/server/bidder/sonobi/SonobiBidder.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@
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.StringUtils;
import org.prebid.server.bidder.Bidder;
import org.prebid.server.bidder.model.BidderBid;
import org.prebid.server.bidder.model.BidderCall;
import org.prebid.server.bidder.model.BidderError;
import org.prebid.server.bidder.model.HttpRequest;
import org.prebid.server.bidder.model.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;
Expand All @@ -22,6 +24,7 @@
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;
Expand All @@ -34,10 +37,17 @@ public class SonobiBidder implements Bidder<BidRequest> {
new TypeReference<>() {
};

private static final String BIDDER_CURRENCY = "USD";

private final CurrencyConversionService currencyConversionService;
private final String endpointUrl;
private final JacksonMapper mapper;

public SonobiBidder(String endpointUrl, JacksonMapper mapper) {
public SonobiBidder(CurrencyConversionService currencyConversionService,
String endpointUrl,
JacksonMapper mapper) {

this.currencyConversionService = currencyConversionService;
this.endpointUrl = HttpUtil.validateUrl(Objects.requireNonNull(endpointUrl));
this.mapper = Objects.requireNonNull(mapper);
}
Expand All @@ -50,7 +60,7 @@ public Result<List<HttpRequest<BidRequest>>> makeHttpRequests(BidRequest bidRequ
for (Imp imp : bidRequest.getImp()) {
try {
final ExtImpSonobi extImpSonobi = parseImpExt(imp);
final Imp modifiedImp = modifyImp(imp, extImpSonobi.getTagId());
final Imp modifiedImp = modifyImp(bidRequest, imp, extImpSonobi.getTagId());
requests.add(makeRequest(bidRequest, modifiedImp));
} catch (PreBidException e) {
errors.add(BidderError.badInput(e.getMessage()));
Expand All @@ -68,34 +78,51 @@ private ExtImpSonobi parseImpExt(Imp imp) throws PreBidException {
}
}

private static Imp modifyImp(Imp imp, String tagId) {
return imp.toBuilder().tagid(tagId).build();
private Imp modifyImp(BidRequest bidRequest, Imp imp, String tagId) {
final Price bidFloor = resolveBidFloor(bidRequest, imp);
return imp.toBuilder()
.tagid(tagId)
.bidfloor(bidFloor.getValue())
.bidfloorcur(bidFloor.getCurrency())
.build();
}

private Price resolveBidFloor(BidRequest bidRequest, Imp imp) {
final BigDecimal bidFloor = imp.getBidfloor();
final String bidFloorCurrency = imp.getBidfloorcur();

if (BidderUtil.isValidPrice(bidFloor)
&& StringUtils.isNotBlank(bidFloorCurrency)
&& !StringUtils.equalsIgnoreCase(bidFloorCurrency, BIDDER_CURRENCY)) {
return Price.of(
BIDDER_CURRENCY,
currencyConversionService.convertCurrency(bidFloor, bidRequest, bidFloorCurrency, BIDDER_CURRENCY));
}

return Price.of(bidFloorCurrency, bidFloor);
}

private HttpRequest<BidRequest> makeRequest(BidRequest bidRequest, Imp imp) {
final BidRequest modifiedBidRequest = bidRequest.toBuilder().imp(Collections.singletonList(imp)).build();
final BidRequest modifiedBidRequest = bidRequest.toBuilder()
.cur(Collections.singletonList(BIDDER_CURRENCY))
.imp(Collections.singletonList(imp))
.build();

return BidderUtil.defaultRequest(modifiedBidRequest, endpointUrl, mapper);
}

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

final List<BidderError> errors = new ArrayList<>();
final List<BidderBid> bids = extractBids(httpCall.getRequest().getPayload(), bidResponse, errors);

return Result.of(bids, errors);
}

private static List<BidderBid> extractBids(BidRequest bidRequest,
BidResponse bidResponse,
List<BidderError> errors) {
BidResponse bidResponse) {

if (bidResponse == null || CollectionUtils.isEmpty(bidResponse.getSeatbid())) {
return Collections.emptyList();
Expand All @@ -107,26 +134,19 @@ private static List<BidderBid> extractBids(BidRequest bidRequest,
.filter(Objects::nonNull)
.flatMap(Collection::stream)
.filter(Objects::nonNull)
.map(bid -> makeBidderBid(bid, bidRequest.getImp(), bidResponse.getCur(), errors))
.filter(Objects::nonNull)
.map(bid -> BidderBid.of(bid, resolveBidType(bid.getImpid(), bidRequest.getImp()), BIDDER_CURRENCY))
.toList();
}

private static BidderBid makeBidderBid(Bid bid, List<Imp> imps, String currency, List<BidderError> errors) {
try {
return BidderBid.of(bid, resolveBidType(bid.getImpid(), imps), currency);
} catch (PreBidException e) {
errors.add(BidderError.badServerResponse(e.getMessage()));
return null;
}
}

private static BidType resolveBidType(String impId, List<Imp> imps) throws PreBidException {
for (Imp imp : imps) {
if (Objects.equals(impId, imp.getId())) {
if (imp.getBanner() == null && imp.getVideo() != null) {
return BidType.video;
}
if (imp.getBanner() == null && imp.getVideo() == null && imp.getXNative() != null) {
return BidType.xNative;
}
return BidType.banner;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import org.prebid.server.bidder.BidderDeps;
import org.prebid.server.bidder.sonobi.SonobiBidder;
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;
Expand Down Expand Up @@ -29,13 +30,14 @@ BidderConfigurationProperties configurationProperties() {

@Bean
BidderDeps sonobiBidderDeps(BidderConfigurationProperties sonobiConfigurationProperties,
CurrencyConversionService currencyConversionService,
@NotBlank @Value("${external-url}") String externalUrl,
JacksonMapper mapper) {

return BidderDepsAssembler.forBidder(BIDDER_NAME)
.withConfig(sonobiConfigurationProperties)
.usersyncerCreator(UsersyncerCreator.create(externalUrl))
.bidderCreator(config -> new SonobiBidder(config.getEndpoint(), mapper))
.bidderCreator(config -> new SonobiBidder(currencyConversionService, config.getEndpoint(), mapper))
.assemble();
}
}
6 changes: 6 additions & 0 deletions src/main/resources/bidder-config/sonobi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ adapters:
app-media-types:
- banner
- video
- native
site-media-types:
- banner
- video
- native
supported-vendors:
vendor-id: 104
usersync:
cookie-family-name: sonobi
iframe:
url: https://sync.go.sonobi.com/uc.html?gdpr={{gdpr}}&gdpr_consent={{gdpr_consent}}&us_privacy={{us_privacy}}&loc={{redirect_url}}
support-cors: false
uid-macro: '[UID]'
redirect:
url: https://sync.go.sonobi.com/us.gif?gdpr={{gdpr}}&gdpr_consent={{gdpr_consent}}&us_privacy={{us_privacy}}&loc={{redirect_url}}
support-cors: false
Expand Down
120 changes: 109 additions & 11 deletions src/test/java/org/prebid/server/bidder/sonobi/SonobiBidderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,23 @@
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.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.currency.CurrencyConversionService;
import org.prebid.server.proto.openrtb.ext.ExtPrebid;
import org.prebid.server.proto.openrtb.ext.request.sonobi.ExtImpSonobi;

import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;
import java.util.function.Function;
Expand All @@ -27,18 +33,31 @@
import static java.util.function.Function.identity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.prebid.server.proto.openrtb.ext.response.BidType.banner;
import static org.prebid.server.proto.openrtb.ext.response.BidType.video;

@ExtendWith(MockitoExtension.class)
public class SonobiBidderTest extends VertxTest {

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

private final SonobiBidder target = new SonobiBidder(ENDPOINT_URL, jacksonMapper);
@Mock
private CurrencyConversionService currencyConversionService;

private SonobiBidder target;

@BeforeEach
public void before() {
target = new SonobiBidder(currencyConversionService, ENDPOINT_URL, jacksonMapper);
}

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

@Test
Expand All @@ -60,22 +79,98 @@ public void makeHttpRequestsShouldReturnErrorIfImpExtCouldNotBeParsed() {
}

@Test
public void makeHttpRequestsShouldReturnExpectedBidRequest() {
public void makeHttpRequestsShouldReturnImpWithSetTagId() {
// given
final BidRequest bidRequest = givenBidRequest(identity());

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

// then
final BidRequest expectedRequest = bidRequest.toBuilder()
.imp(singletonList(bidRequest.getImp().getFirst().toBuilder()
.tagid("tagidString").build()))
.build();
assertThat(result.getErrors()).isEmpty();
assertThat(result.getValue()).hasSize(1)
.extracting(httpRequest -> mapper.readValue(httpRequest.getBody(), BidRequest.class))
.containsOnly(expectedRequest);
.extracting(HttpRequest::getPayload)
.flatExtracting(BidRequest::getImp)
.extracting(Imp::getTagid)
.containsOnly("tagidString");
}

@Test
public void makeHttpRequestsShouldNotConvertBidfloorWhenBidfloorHasUSDCurrency() {
// given
final BidRequest bidRequest = givenBidRequest(imp -> imp.bidfloor(BigDecimal.TEN).bidfloorcur("USD"));

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

// then
assertThat(result.getErrors()).isEmpty();
assertThat(result.getValue())
.extracting(HttpRequest::getPayload)
.flatExtracting(BidRequest::getImp)
.extracting(Imp::getBidfloor, Imp::getBidfloorcur)
.containsOnly(tuple(BigDecimal.TEN, "USD"));

verifyNoInteractions(currencyConversionService);
}

@Test
public void makeHttpRequestsShouldNotConvertBidfloorWhenBidfloorHasInvalidPrice() {
// given
final BidRequest bidRequest = givenBidRequest(imp -> imp.bidfloor(BigDecimal.ZERO).bidfloorcur("GBR"));

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

// then
assertThat(result.getErrors()).isEmpty();
assertThat(result.getValue())
.extracting(HttpRequest::getPayload)
.flatExtracting(BidRequest::getImp)
.extracting(Imp::getBidfloor, Imp::getBidfloorcur)
.containsOnly(tuple(BigDecimal.ZERO, "GBR"));

verifyNoInteractions(currencyConversionService);
}

@Test
public void makeHttpRequestsShouldNotConvertBidfloorWhenBidfloorHasEmptyCurrency() {
// given
final BidRequest bidRequest = givenBidRequest(imp -> imp.bidfloor(BigDecimal.TEN).bidfloorcur(null));

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

// then
assertThat(result.getErrors()).isEmpty();
assertThat(result.getValue())
.extracting(HttpRequest::getPayload)
.flatExtracting(BidRequest::getImp)
.extracting(Imp::getBidfloor, Imp::getBidfloorcur)
.containsOnly(tuple(BigDecimal.TEN, null));

verifyNoInteractions(currencyConversionService);
}

@Test
public void makeHttpRequestsShouldConvertBidfloorToUSDWhenBidfloorHasAnotherCurrency() {
// given
final BidRequest bidRequest = givenBidRequest(imp -> imp.bidfloor(BigDecimal.TEN).bidfloorcur("EUR"));

given(currencyConversionService.convertCurrency(BigDecimal.TEN, bidRequest, "EUR", "USD"))
.willReturn(BigDecimal.ONE);

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

// then
assertThat(result.getErrors()).isEmpty();
assertThat(result.getValue())
.extracting(HttpRequest::getPayload)
.flatExtracting(BidRequest::getImp)
.extracting(Imp::getBidfloor, Imp::getBidfloorcur)
.containsOnly(tuple(BigDecimal.ONE, "USD"));

}

@Test
Expand Down Expand Up @@ -204,7 +299,7 @@ public void makeBidsShouldReturnErrorsForBidsThatDoesNotMatchImp() throws JsonPr
final Result<List<BidderBid>> result = target.makeBids(httpCall, null);

// then
assertThat(result.getValue()).containsExactly(BidderBid.of(Bid.builder().impid("123").build(), banner, "USD"));
assertThat(result.getValue()).isEmpty();
assertThat(result.getErrors()).hasSize(1)
.extracting(BidderError::getMessage)
.containsExactly("Failed to find impression for ID: 456");
Expand All @@ -214,7 +309,10 @@ private static BidRequest givenBidRequest(
Function<Imp.ImpBuilder, Imp.ImpBuilder> impCustomizer,
Function<BidRequest.BidRequestBuilder, BidRequest.BidRequestBuilder> requestCustomizer) {

return requestCustomizer.apply(BidRequest.builder().imp(singletonList(givenImp(impCustomizer)))).build();
return requestCustomizer.apply(BidRequest.builder()
.cur(singletonList("USD"))
.imp(singletonList(givenImp(impCustomizer))))
.build();
}

private static BidRequest givenBidRequest(Function<Imp.ImpBuilder, Imp.ImpBuilder> impCustomizer) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"prebid": {
"type": "banner"
},
"origbidcpm": 1.25
"origbidcpm": 1.25,
"origbidcur": "USD"
}
}
],
Expand Down
Loading