diff --git a/src/main/java/org/prebid/server/bidder/aps/ApsBidder.java b/src/main/java/org/prebid/server/bidder/aps/ApsBidder.java new file mode 100644 index 00000000000..559ae7bf790 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/aps/ApsBidder.java @@ -0,0 +1,289 @@ +package org.prebid.server.bidder.aps; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.iab.openrtb.request.Banner; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Format; +import com.iab.openrtb.request.Geo; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.User; +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.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.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.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.aps.ExtImpAps; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.util.BidderUtil; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +public class ApsBidder implements Bidder { + + private static final String ADAPTER_VERSION = "1.0.0"; + private static final String SOURCE = "prebid-server"; + private static final String DEFAULT_CURRENCY = "USD"; + private static final String DEFAULT_REGION = "na"; + private static final Set VALID_REGIONS = Set.of("na", "eu", "fe"); + private static final String REGION_MACRO = "{{Region}}"; + + private static final TypeReference> APS_EXT_TYPE_REFERENCE = + new TypeReference<>() { }; + + private final String endpointUrl; + private final JacksonMapper mapper; + + public ApsBidder(String endpointUrl, JacksonMapper mapper) { + this.endpointUrl = Objects.requireNonNull(endpointUrl); + this.mapper = Objects.requireNonNull(mapper); + } + + /* + * Sample OpenRTB request this adapter accepts (dummy values): + * { + * "id": "req-1", + * "test": 1, + * "site": {"page": "https://example.com"}, + * "imp": [ + * {"id": "imp-1", "banner": {"format": [{"w": 300, "h": 250}]}, + * "ext": {"prebid": {"bidder": {"aps": {"accountID": "1234", "region": "na"}}}}}, + * {"id": "imp-2", "video": {"mimes": ["video/mp4"], "w": 640, "h": 480, "protocols": [2, 3, 5, 6]}, + * "ext": {"prebid": {"bidder": {"aps": {"accountID": "1234"}}}}} + * ] + * } + * accountID is a per-imp bidder param (or set once via ext.prebid.bidderparams.aps.accountID); all imps + * must resolve to the same account. region is an optional per-imp param (default na); it fills the + * endpoint macro and is validated against a fixed set of valid regions (na, eu, fe); others are rejected. + * test:1 appends amzn_debug_mode=1 to the endpoint. + */ + @Override + public Result>> makeHttpRequests(BidRequest bidRequest) { + if (bidRequest.getApp() != null) { + return Result.withError(BidderError.badInput( + "the APS adapter supports web (site) inventory only; app requests are not supported")); + } + + final List errors = new ArrayList<>(); + final List validImps = new ArrayList<>(); + String accountID = null; + String region = DEFAULT_REGION; + + for (Imp imp : bidRequest.getImp()) { + final Imp sanitizedImp; + final ImpParams params; + try { + sanitizedImp = sanitizeMediaTypes(imp); + params = parseImpParams(imp); + } catch (PreBidException e) { + errors.add(BidderError.badInput(e.getMessage())); + continue; + } + + if (accountID == null) { + accountID = params.accountID(); + } else if (!accountID.equals(params.accountID())) { + errors.add(BidderError.badInput( + "imp %s: all imps in a request must use the same APS accountID (got %s and %s)" + .formatted(imp.getId(), accountID, params.accountID()))); + return Result.withErrors(errors); + } + region = params.region(); + + validImps.add(sanitizedImp.getBanner() != null + ? backfillBannerSize(sanitizedImp) + : sanitizedImp); + } + + if (validImps.isEmpty()) { + return Result.withErrors(errors); + } + + final String resolvedEndpoint = resolveEndpoint(region); + final String requestUrl = isTestMode(bidRequest) ? appendDebugMode(resolvedEndpoint) : resolvedEndpoint; + final HttpRequest httpRequest = BidderUtil.defaultRequest( + modifyBidRequest(bidRequest, validImps, accountID), requestUrl, mapper); + + return Result.of(Collections.singletonList(httpRequest), errors); + } + + private ImpParams parseImpParams(Imp imp) { + final ExtImpAps extImpAps; + try { + extImpAps = mapper.mapper().convertValue(imp.getExt(), APS_EXT_TYPE_REFERENCE).getBidder(); + } catch (IllegalArgumentException e) { + throw new PreBidException( + "imp %s: invalid aps bidder params: %s".formatted(imp.getId(), e.getMessage())); + } + final String accountID = extImpAps == null ? null : StringUtils.trimToNull(extImpAps.getAccountID()); + if (accountID == null) { + throw new PreBidException( + "imp %s: the APS bidder param \"accountID\" is required".formatted(imp.getId())); + } + final String rawRegion = StringUtils.trimToNull(extImpAps.getRegion()); + final String region = rawRegion == null ? DEFAULT_REGION : rawRegion; + if (!VALID_REGIONS.contains(region)) { + throw new PreBidException( + "imp %s: invalid APS region \"%s\"".formatted(imp.getId(), region)); + } + return new ImpParams(accountID, region); + } + + private String resolveEndpoint(String region) { + return endpointUrl.replace(REGION_MACRO, region); + } + + private record ImpParams(String accountID, String region) { + } + + private static boolean isTestMode(BidRequest bidRequest) { + return Objects.equals(bidRequest.getTest(), 1); + } + + // Appends the amzn_debug_mode=1 query param for test requests. + private static String appendDebugMode(String url) { + return url + (url.contains("?") ? "&" : "?") + "amzn_debug_mode=1"; + } + + private static Imp sanitizeMediaTypes(Imp imp) { + if (imp.getBanner() == null && imp.getVideo() == null) { + throw new PreBidException( + "imp %s: the APS adapter supports only banner and video media types".formatted(imp.getId())); + } + return imp.toBuilder() + .audio(null) + .xNative(null) + .build(); + } + + private static Imp backfillBannerSize(Imp imp) { + final Banner banner = imp.getBanner(); + if (CollectionUtils.isEmpty(banner.getFormat()) + || (banner.getW() != null && banner.getH() != null)) { + return imp; + } + + final Format firstFormat = banner.getFormat().get(0); + final Banner modifiedBanner = banner.toBuilder() + .w(firstFormat.getW()) + .h(firstFormat.getH()) + .build(); + return imp.toBuilder().banner(modifiedBanner).build(); + } + + private BidRequest modifyBidRequest(BidRequest bidRequest, List imps, String accountID) { + return bidRequest.toBuilder() + .imp(imps) + .user(sanitizeUserObject(bidRequest.getUser())) + .device(sanitizeDeviceObject(bidRequest.getDevice())) + .cur(resolveCurrencies(bidRequest.getCur())) + .ext(modifyExtRequest(bidRequest.getExt(), accountID)) + .build(); + } + + private static User sanitizeUserObject(User user) { + return user == null + ? null + : user.toBuilder() + .gender(null) + .yob(null) + .customdata(null) + .geo(null) + .build(); + } + + private static Device sanitizeDeviceObject(Device device) { + if (device == null || device.getGeo() == null) { + return device; + } + final Geo strippedGeo = device.getGeo().toBuilder() + .lat(null) + .lon(null) + .build(); + return device.toBuilder().geo(strippedGeo).build(); + } + + private static List resolveCurrencies(List currencies) { + return CollectionUtils.isEmpty(currencies) + ? Collections.singletonList(DEFAULT_CURRENCY) + : currencies; + } + + private ExtRequest modifyExtRequest(ExtRequest extRequest, String accountID) { + final ExtRequest baseExt = ObjectUtils.defaultIfNull(extRequest, ExtRequest.empty()); + + final ObjectNode apsNode = mapper.mapper().createObjectNode(); + apsNode.put("account", accountID); + final ObjectNode sdkNode = apsNode.putObject("sdk"); + sdkNode.put("version", ADAPTER_VERSION); + sdkNode.put("source", SOURCE); + + return mapper.fillExtension(baseExt, apsNode); + } + + @Override + public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + try { + final BidResponse bidResponse = mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); + final List errors = new ArrayList<>(); + 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 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) + .toList(); + } + + private static BidderBid makeBidderBid(Bid bid, String currency, List errors) { + final BidType bidType = resolveBidType(bid.getMtype()); + if (bidType == null) { + errors.add(BidderError.badServerResponse( + "Unsupported MType for impression %s".formatted(bid.getImpid()))); + return null; + } + return BidderBid.of(bid, bidType, currency); + } + + private static BidType resolveBidType(Integer mtype) { + if (mtype == null) { + return null; + } + return switch (mtype) { + case 1 -> BidType.banner; + case 2 -> BidType.video; + default -> null; + }; + } +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/aps/ExtImpAps.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/aps/ExtImpAps.java new file mode 100644 index 00000000000..901a5fb2823 --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/aps/ExtImpAps.java @@ -0,0 +1,17 @@ +package org.prebid.server.proto.openrtb.ext.request.aps; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Value; + +/** + * Defines bidrequest.imp[i].ext.prebid.bidder.aps. + */ +@Value(staticConstructor = "of") +public class ExtImpAps { + + @JsonProperty("accountID") + String accountID; + + @JsonProperty("region") + String region; +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/ApsConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/ApsConfiguration.java new file mode 100644 index 00000000000..37abe73677c --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/ApsConfiguration.java @@ -0,0 +1,47 @@ +package org.prebid.server.spring.config.bidder; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.aps.ApsBidder; +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.env.YamlPropertySourceFactory; +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/aps.yaml", factory = YamlPropertySourceFactory.class) +public class ApsConfiguration { + + private static final String BIDDER_NAME = "aps"; + + @Bean("apsConfigurationProperties") + @ConfigurationProperties("adapters.aps") + ApsConfigurationProperties configurationProperties() { + return new ApsConfigurationProperties(); + } + + @Bean + BidderDeps apsBidderDeps(ApsConfigurationProperties apsConfigurationProperties, + JacksonMapper mapper) { + + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(apsConfigurationProperties) + .bidderCreator(config -> new ApsBidder(config.getEndpoint(), mapper)) + .assemble(); + } + + // The APS account is a per-request bidder param (imp.ext.prebid.bidder.aps.accountID), not a + // host-level setting, so one Prebid Server host can serve multiple publishers. No adapter-specific + // host config is needed beyond the standard bidder properties. + @Data + @EqualsAndHashCode(callSuper = true) + @NoArgsConstructor + private static class ApsConfigurationProperties extends BidderConfigurationProperties { + } +} diff --git a/src/main/resources/bidder-config/aps.yaml b/src/main/resources/bidder-config/aps.yaml new file mode 100644 index 00000000000..2439d3e7384 --- /dev/null +++ b/src/main/resources/bidder-config/aps.yaml @@ -0,0 +1,13 @@ +adapters: + aps: + # {{Region}} comes from the aps.region imp param (default na). + endpoint: https://s2s.prebid.bid-{{Region}}.ads.aps.amazon-adsystem.com/e/pb/bid + geoscope: + - USA + meta-info: + maintainer-email: aps-prebid@amazon.com + site-media-types: + - banner + - video + supported-vendors: + vendor-id: 793 diff --git a/src/main/resources/static/bidder-params/aps.json b/src/main/resources/static/bidder-params/aps.json new file mode 100644 index 00000000000..e7d1659be20 --- /dev/null +++ b/src/main/resources/static/bidder-params/aps.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "APS Adapter Params", + "description": "A schema which validates params accepted by the APS adapter", + "type": "object", + "properties": { + "accountID": { + "type": "string", + "description": "The APS publisher account id." + }, + "region": { + "type": "string", + "description": "APS regional S2S origin to route to. Optional; defaults to na." + } + }, + "required": ["accountID"] +} diff --git a/src/test/java/org/prebid/server/bidder/aps/ApsBidderTest.java b/src/test/java/org/prebid/server/bidder/aps/ApsBidderTest.java new file mode 100644 index 00000000000..31291e4d2ef --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/aps/ApsBidderTest.java @@ -0,0 +1,519 @@ +package org.prebid.server.bidder.aps; + +import com.fasterxml.jackson.databind.JsonNode; +import com.iab.openrtb.request.App; +import com.iab.openrtb.request.Audio; +import com.iab.openrtb.request.Banner; +import com.iab.openrtb.request.BidRequest; +import com.iab.openrtb.request.Device; +import com.iab.openrtb.request.Format; +import com.iab.openrtb.request.Geo; +import com.iab.openrtb.request.Imp; +import com.iab.openrtb.request.Native; +import com.iab.openrtb.request.User; +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.aps.ExtImpAps; + +import java.util.List; +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.prebid.server.proto.openrtb.ext.response.BidType.banner; +import static org.prebid.server.proto.openrtb.ext.response.BidType.video; + +public class ApsBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "https://s2s.prebid.bid-{{Region}}.ads.aps.amazon-adsystem.com/e/pb/bid"; + private static final String RESOLVED_ENDPOINT_URL = "https://s2s.prebid.bid-na.ads.aps.amazon-adsystem.com/e/pb/bid"; + private static final String TEST_ACCOUNT = "test-account"; + + private final ApsBidder target = new ApsBidder(ENDPOINT_URL, jacksonMapper); + + @Test + public void makeHttpRequestsShouldFailWhenAccountIdIsMissing() { + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> impBuilder + .ext(mapper.valueToTree(ExtPrebid.of(null, mapper.createObjectNode())))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()) + .extracting(BidderError::getMessage) + .containsExactly("imp test-imp-id: the APS bidder param \"accountID\" is required"); + } + + @Test + public void makeHttpRequestsShouldRejectImpsWithMismatchedAccountIds() { + // given + final Imp imp1 = givenImp("imp-1", impBuilder -> impBuilder + .banner(Banner.builder().w(300).h(250).build()) + .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpAps.of("acct-1", null))))); + final Imp imp2 = givenImp("imp-2", impBuilder -> impBuilder + .banner(Banner.builder().w(300).h(250).build()) + .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpAps.of("acct-2", null))))); + final BidRequest bidRequest = BidRequest.builder().imp(List.of(imp1, imp2)).build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then the whole request is rejected; no request is fired under a partial account set. + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()) + .extracting(BidderError::getMessage) + .containsExactly( + "imp imp-2: all imps in a request must use the same APS accountID (got acct-1 and acct-2)"); + } + + @Test + public void makeHttpRequestsShouldCreateExpectedUrl() { + // given + final BidRequest bidRequest = givenBidRequest(identity()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getUri) + .containsExactly(RESOLVED_ENDPOINT_URL); + } + + @Test + public void makeHttpRequestsShouldAppendDebugModeToUrlWhenTestModeSet() { + // given + final BidRequest bidRequest = givenBidRequest(request -> request.test(1), identity()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getUri) + .containsExactly(RESOLVED_ENDPOINT_URL + "?amzn_debug_mode=1"); + } + + @Test + public void makeHttpRequestsShouldRejectRegionOutsideAllowlist() { + // given a region outside {na, eu, fe} — including host-injection attempts + final BidRequest bidRequest = givenBidRequest(impBuilder -> impBuilder + .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpAps.of(TEST_ACCOUNT, "na.evil.com/"))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then it is rejected before reaching the endpoint host substitution (host-injection guard). + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()) + .extracting(BidderError::getMessage) + .containsExactly("imp test-imp-id: invalid APS region \"na.evil.com/\""); + } + + @Test + public void makeHttpRequestsShouldResolveEndpointForEuRegion() { + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> impBuilder + .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpAps.of(TEST_ACCOUNT, "eu"))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then eu is a supported region and fills the endpoint macro. + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getUri) + .containsExactly("https://s2s.prebid.bid-eu.ads.aps.amazon-adsystem.com/e/pb/bid"); + } + + @Test + public void makeHttpRequestsShouldResolveRegionMacroForExplicitNa() { + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> impBuilder + .ext(mapper.valueToTree(ExtPrebid.of(null, ExtImpAps.of(TEST_ACCOUNT, "na"))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getUri) + .containsExactly(RESOLVED_ENDPOINT_URL); + } + + @Test + public void makeHttpRequestsShouldSetHostAccountAndSdkInExt() { + // given + final BidRequest bidRequest = givenBidRequest(identity()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .allSatisfy(payload -> { + assertThat(payload.getExt().getProperty("account").asText()).isEqualTo(TEST_ACCOUNT); + final JsonNode sdk = payload.getExt().getProperty("sdk"); + assertThat(sdk.get("version").asText()).isEqualTo("1.0.0"); + assertThat(sdk.get("source").asText()).isEqualTo("prebid-server"); + }); + } + + @Test + public void makeHttpRequestsShouldDefaultCurrencyToUsd() { + // given + final BidRequest bidRequest = givenBidRequest(identity()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getCur) + .containsExactly(singletonList("USD")); + } + + @Test + public void makeHttpRequestsShouldStripSensitiveUserDataAndDeviceGeo() { + // given + final BidRequest bidRequest = givenBidRequest(request -> request + .user(User.builder() + .gender("M") + .yob(1990) + .customdata("data") + .geo(Geo.builder().lat(1.0f).lon(2.0f).build()) + .build()) + .device(Device.builder() + .geo(Geo.builder().lat(3.0f).lon(4.0f).country("USA").build()) + .build()), identity()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .allSatisfy(payload -> { + assertThat(payload.getUser().getGender()).isNull(); + assertThat(payload.getUser().getYob()).isNull(); + assertThat(payload.getUser().getCustomdata()).isNull(); + assertThat(payload.getUser().getGeo()).isNull(); + assertThat(payload.getDevice().getGeo().getLat()).isNull(); + assertThat(payload.getDevice().getGeo().getLon()).isNull(); + assertThat(payload.getDevice().getGeo().getCountry()).isEqualTo("USA"); + }); + } + + @Test + public void makeHttpRequestsShouldBackfillBannerSizeFromFirstFormat() { + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> impBuilder.banner(Banner.builder() + .format(List.of(Format.builder().w(300).h(250).build())) + .build())); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBanner) + .allSatisfy(bannerObj -> { + assertThat(bannerObj.getW()).isEqualTo(300); + assertThat(bannerObj.getH()).isEqualTo(250); + }); + } + + @Test + public void makeHttpRequestsShouldOverwriteBothBannerDimsFromFirstFormatWhenOneMissing() { + // given + final BidRequest bidRequest = givenBidRequest(impBuilder -> impBuilder.banner(Banner.builder() + .w(300) + .format(List.of(Format.builder().w(728).h(90).build())) + .build())); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then, matching the Prebid.js APS adapter, both dims are backfilled from format[0] + // unless both were already set. + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getBanner) + .allSatisfy(bannerObj -> { + assertThat(bannerObj.getW()).isEqualTo(728); + assertThat(bannerObj.getH()).isEqualTo(90); + }); + } + + @Test + public void makeHttpRequestsShouldPreserveImpExtBidderBlock() { + // given + final BidRequest bidRequest = givenBidRequest(identity()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .allSatisfy(imp -> assertThat(imp.getExt().has("bidder")).isTrue()); + } + + @Test + public void makeHttpRequestsShouldReturnErrorForAppRequest() { + // given + final BidRequest bidRequest = givenBidRequest(identity()) + .toBuilder().app(App.builder().bundle("com.example.app").build()).build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()).contains("web (site) inventory only"); + }); + } + + @Test + public void makeHttpRequestsShouldReturnSingleRequestForMultipleImps() { + // given + final Imp bannerImp = givenImp("slot-banner", + impBuilder -> impBuilder.banner(Banner.builder().w(300).h(250).build())); + final Imp videoImp = givenImp("slot-video", + impBuilder -> impBuilder.video(Video.builder().mimes(List.of("video/mp4")).build())); + final BidRequest bidRequest = BidRequest.builder().imp(List.of(bannerImp, videoImp)).build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .allSatisfy(payload -> { + assertThat(payload.getImp()).extracting(Imp::getId) + .containsExactly("slot-banner", "slot-video"); + assertThat(payload.getExt().getProperty("account").asText()).isEqualTo(TEST_ACCOUNT); + }); + } + + @Test + public void makeHttpRequestsShouldForwardServiceableImpWhenAnotherIsDroppedForUnsupportedMedia() { + // given + final Imp nativeImp = givenImp("imp-native", + impBuilder -> impBuilder.xNative(Native.builder().request("{}").build())); + final Imp bannerImp = givenImp("imp-banner", + impBuilder -> impBuilder.banner(Banner.builder().w(300).h(250).build())); + final BidRequest bidRequest = BidRequest.builder().imp(List.of(nativeImp, bannerImp)).build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .allSatisfy(payload -> { + assertThat(payload.getImp()).extracting(Imp::getId).containsExactly("imp-banner"); + assertThat(payload.getExt().getProperty("account").asText()).isEqualTo(TEST_ACCOUNT); + }); + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()).contains("only banner and video"); + }); + } + + @Test + public void makeHttpRequestsShouldRejectImpWithoutBannerOrVideo() { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(givenImp("imp-1", + impBuilder -> impBuilder.xNative(Native.builder().request("{}").build())))) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()).contains("only banner and video"); + }); + } + + @Test + public void makeHttpRequestsShouldStripAudioAndNativeFromImp() { + // given + final BidRequest bidRequest = BidRequest.builder() + .imp(singletonList(givenImp("imp-1", impBuilder -> impBuilder + .banner(Banner.builder().w(300).h(250).build()) + .audio(Audio.builder().mimes(List.of("audio/mp4")).build()) + .xNative(Native.builder().request("{}").build())))) + .build(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(1) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .allSatisfy(imp -> { + assertThat(imp.getBanner()).isNotNull(); + assertThat(imp.getAudio()).isNull(); + assertThat(imp.getXNative()).isNull(); + }); + } + + @Test + public void makeBidsShouldReturnErrorIfResponseBodyCouldNotBeParsed() { + // given + final BidderCall httpCall = givenHttpCall("invalid"); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response)); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnEmptyListIfBidResponseSeatBidIsNull() throws Exception { + // 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 makeBidsShouldReturnBannerBidForMtype1() throws Exception { + // given + final BidderCall httpCall = givenHttpCall( + mapper.writeValueAsString(givenBidResponse(bid -> bid.impid("test-imp-id").mtype(1)))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType) + .containsExactly(banner); + } + + @Test + public void makeBidsShouldReturnVideoBidForMtype2() throws Exception { + // given + final BidderCall httpCall = givenHttpCall( + mapper.writeValueAsString(givenBidResponse(bid -> bid.impid("test-imp-id").mtype(2)))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(BidderBid::getType) + .containsExactly(video); + } + + @Test + public void makeBidsShouldReturnErrorForUnsupportedMtype() throws Exception { + // given + final BidderCall httpCall = givenHttpCall( + mapper.writeValueAsString(givenBidResponse(bid -> bid.impid("test-imp-id").mtype(3)))); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(1) + .allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_server_response); + assertThat(error.getMessage()).contains("Unsupported MType"); + }); + } + + private static BidRequest givenBidRequest(UnaryOperator impCustomizer) { + return givenBidRequest(identity(), impCustomizer); + } + + private static BidRequest givenBidRequest(UnaryOperator requestCustomizer, + UnaryOperator impCustomizer) { + return requestCustomizer.apply(BidRequest.builder() + .imp(singletonList(impCustomizer.apply( + Imp.builder() + .id("test-imp-id") + .banner(Banner.builder().w(300).h(250).build()) + .ext(givenImpExt())) + .build()))) + .build(); + } + + private static Imp givenImp(String impId, UnaryOperator impCustomizer) { + return impCustomizer.apply(Imp.builder() + .id(impId) + .ext(givenImpExt())) + .build(); + } + + private static com.fasterxml.jackson.databind.node.ObjectNode givenImpExt() { + return mapper.valueToTree(ExtPrebid.of(null, ExtImpAps.of(TEST_ACCOUNT, null))); + } + + 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(String body) { + return BidderCall.succeededHttp( + HttpRequest.builder().build(), + HttpResponse.of(200, null, body), + null); + } +} diff --git a/src/test/java/org/prebid/server/it/ApsTest.java b/src/test/java/org/prebid/server/it/ApsTest.java new file mode 100644 index 00000000000..5c19e01ffbc --- /dev/null +++ b/src/test/java/org/prebid/server/it/ApsTest.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 ApsTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromAps() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/aps-exchange")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/aps/test-aps-bid-request.json"))) + .willReturn(aResponse().withBody(jsonFrom("openrtb2/aps/test-aps-bid-response.json")))); + + // when + final Response response = responseFor("openrtb2/aps/test-auction-aps-request.json", + Endpoint.openrtb2_auction); + + // then + assertJsonEquals("openrtb2/aps/test-auction-aps-response.json", response, singletonList("aps")); + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/aps/test-aps-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/aps/test-aps-bid-request.json new file mode 100644 index 00000000000..9bdb96d8a37 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/aps/test-aps-bid-request.json @@ -0,0 +1,60 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "secure": 1, + "banner": { + "w": 320, + "h": 250 + }, + "ext": { + "bidder": {"accountID": "test-account"}, + "tid": "${json-unit.any-string}" + } + } + ], + "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": { + "account": "test-account", + "sdk": { + "version": "1.0.0", + "source": "prebid-server" + }, + "prebid": { + "server": { + "externalurl": "http://localhost:8080", + "gvlid": 1, + "datacenter": "local", + "http_method": "POST", + "endpoint": "/openrtb2/auction" + } + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/aps/test-aps-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/aps/test-aps-bid-response.json new file mode 100644 index 00000000000..bbab934ab5b --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/aps/test-aps-bid-response.json @@ -0,0 +1,18 @@ +{ + "id": "tid", + "cur": "USD", + "seatbid": [ + { + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "price": 0.5, + "adm": "
banner ad
", + "crid": "creative-1", + "mtype": 1 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/org/prebid/server/it/openrtb2/aps/test-auction-aps-request.json b/src/test/resources/org/prebid/server/it/openrtb2/aps/test-auction-aps-request.json new file mode 100644 index 00000000000..2e69bec6efc --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/aps/test-auction-aps-request.json @@ -0,0 +1,21 @@ +{ + "id": "request_id", + "imp": [ + { + "id": "imp_id", + "banner": { + "w": 320, + "h": 250 + }, + "ext": { + "aps": {"accountID": "test-account"} + } + } + ], + "tmax": 5000, + "regs": { + "ext": { + "gdpr": 0 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/aps/test-auction-aps-response.json b/src/test/resources/org/prebid/server/it/openrtb2/aps/test-auction-aps-response.json new file mode 100644 index 00000000000..c46af46d14f --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/aps/test-auction-aps-response.json @@ -0,0 +1,40 @@ +{ + "id": "request_id", + "seatbid": [ + { + "bid": [ + { + "id": "bid_id", + "impid": "imp_id", + "price": 0.5, + "adm": "
banner ad
", + "crid": "creative-1", + "exp": 300, + "mtype": 1, + "ext": { + "prebid": { + "type": "banner", + "meta": { + "adaptercode": "aps" + } + }, + "origbidcpm": 0.5, + "origbidcur": "USD" + } + } + ], + "seat": "aps", + "group": 0 + } + ], + "cur": "USD", + "ext": { + "responsetimemillis": { + "aps": "{{ aps.response_time_ms }}" + }, + "prebid": { + "auctiontimestamp": 0 + }, + "tmaxrequest": 5000 + } +} \ No newline at end of file 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 841d458c749..ecad161c9ce 100644 --- a/src/test/resources/org/prebid/server/it/test-application.properties +++ b/src/test/resources/org/prebid/server/it/test-application.properties @@ -140,6 +140,8 @@ adapters.appnexus.aliases.mediafuse.enabled=true adapters.appnexus.aliases.mediafuse.endpoint=http://localhost:8090/mediafuse-exchange adapters.appush.enabled=true adapters.appush.endpoint=http://localhost:8090/appush-exchange +adapters.aps.enabled=true +adapters.aps.endpoint=http://localhost:8090/aps-exchange adapters.aso.enabled=true adapters.aso.endpoint=http://localhost:8090/aso-exchange adapters.aso.aliases.bcmint.enabled=true