diff --git a/src/main/java/org/prebid/server/bidder/goldbach/GoldbachBidder.java b/src/main/java/org/prebid/server/bidder/goldbach/GoldbachBidder.java new file mode 100644 index 00000000000..3a90b151f2b --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/goldbach/GoldbachBidder.java @@ -0,0 +1,240 @@ +package org.prebid.server.bidder.goldbach; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +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 io.netty.handler.codec.http.HttpResponseStatus; +import org.prebid.server.bidder.Bidder; +import org.prebid.server.bidder.goldbach.proto.GoldbachExtImp; +import org.prebid.server.bidder.goldbach.proto.ExtRequestGoldbach; +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.goldbach.ExtImpGoldbach; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.proto.openrtb.ext.response.ExtBidPrebid; +import org.prebid.server.util.BidderUtil; +import org.prebid.server.util.HttpUtil; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Stream; + +public class GoldbachBidder implements Bidder { + + private static final TypeReference> GOLDBACH_EXT_TYPE_REFERENCE = + new TypeReference<>() { + }; + + private static final TypeReference> EXT_PREBID_TYPE_REFERENCE = + new TypeReference<>() { + }; + + private final String endpointUrl; + private final JacksonMapper mapper; + + public GoldbachBidder(String endpointUrl, JacksonMapper mapper) { + this.endpointUrl = HttpUtil.validateUrl(Objects.requireNonNull(endpointUrl)); + this.mapper = Objects.requireNonNull(mapper); + } + + @Override + public Result>> makeHttpRequests(BidRequest request) { + final List errors = new ArrayList<>(); + + final ExtRequestGoldbach extRequestGoldbach = parseRequestExt(request, errors); + final Map> publisherToImps = new HashMap<>(); + for (Imp imp : request.getImp()) { + try { + final ExtImpGoldbach extImp = parseImpExt(imp); + final Imp updatedImp = modifyImp(imp, extImp); + + publisherToImps.computeIfAbsent(extImp.getPublisherId(), k -> new ArrayList<>()) + .add(updatedImp); + } catch (PreBidException e) { + errors.add(BidderError.badInput(e.getMessage())); + } + } + + if (publisherToImps.isEmpty()) { + errors.add(BidderError.badInput("No valid impressions found")); + } + + final List> httpRequests = publisherToImps.entrySet().stream() + .map(publisherIdAndImps -> makeHttpRequestForPublisher( + request, + extRequestGoldbach, + publisherIdAndImps.getKey(), + publisherIdAndImps.getValue())) + .filter(Objects::nonNull) + .toList(); + + return Result.of(httpRequests, errors); + } + + private ExtRequestGoldbach parseRequestExt(BidRequest bidRequest, List errors) { + return Optional.ofNullable(bidRequest.getExt()) + .map(extRequest -> extRequest.getProperty("goldbach")) + .map(extRequestGoldbachRaw -> parseRequestExtGoldbach(extRequestGoldbachRaw, errors)) + .orElse(null); + } + + private ExtRequestGoldbach parseRequestExtGoldbach(JsonNode extRequestGoldbachRaw, List errors) { + try { + return mapper.mapper().treeToValue(extRequestGoldbachRaw, ExtRequestGoldbach.class); + } catch (IllegalArgumentException | JsonProcessingException e) { + errors.add(BidderError.badInput("Failed to deserialize Goldbach bid request extension: " + e.getMessage())); + return null; + } + } + + private ExtImpGoldbach parseImpExt(Imp imp) { + try { + return mapper.mapper().convertValue(imp.getExt(), GOLDBACH_EXT_TYPE_REFERENCE).getBidder(); + } catch (IllegalArgumentException e) { + throw new PreBidException("Failed to deserialize Goldbach imp extension: " + e.getMessage()); + } + } + + private Imp modifyImp(Imp imp, ExtImpGoldbach extImp) { + return imp.toBuilder() + .ext(mapper.mapper().createObjectNode().set("goldbach", mapper.mapper().valueToTree( + GoldbachExtImp.of(extImp.getSlotId(), extImp.getCustomTargeting())))) + .build(); + } + + private HttpRequest makeHttpRequestForPublisher(BidRequest bidRequest, + ExtRequestGoldbach extRequestGoldbach, + String publisherId, + List imps) { + + final BidRequest modifiedBidRequest = modifyBidRequest(bidRequest, extRequestGoldbach, publisherId, imps); + return BidderUtil.defaultRequest(modifiedBidRequest, endpointUrl, mapper); + } + + private BidRequest modifyBidRequest(BidRequest bidRequest, + ExtRequestGoldbach extRequestGoldbach, + String publisherId, + List imps) { + + final ExtRequest modifiedExtRequest = modifyExtRequest(bidRequest.getExt(), extRequestGoldbach, publisherId); + return bidRequest.toBuilder() + .id("%s_%s".formatted(bidRequest.getId(), publisherId)) + .imp(imps) + .ext(modifiedExtRequest) + .build(); + } + + private ExtRequest modifyExtRequest(ExtRequest extRequest, + ExtRequestGoldbach extRequestGoldbach, + String publisherId) { + + final ExtRequestGoldbach modifiedExtRequestGoldbach = ExtRequestGoldbach.of( + publisherId, + extRequestGoldbach != null ? extRequestGoldbach.getMockResponse() : null); + + final ExtRequest modifiedExtRequest; + + if (extRequest != null) { + modifiedExtRequest = ExtRequest.of(extRequest.getPrebid()); + mapper.fillExtension(modifiedExtRequest, extRequest); + } else { + modifiedExtRequest = ExtRequest.empty(); + } + + modifiedExtRequest.addProperty("goldbach", mapper.mapper().valueToTree(modifiedExtRequestGoldbach)); + return modifiedExtRequest; + } + + @Override + public Result> makeBids(BidderCall httpCall, BidRequest bidRequest) { + if (httpCall.getResponse().getStatusCode() != HttpResponseStatus.CREATED.code()) { + return Result.withError( + BidderError.badServerResponse( + "unexpected status code: %d. Run with request.debug = 1 for more info".formatted( + httpCall.getResponse().getStatusCode()))); + } + + final BidResponse bidResponse; + try { + bidResponse = decodeBodyToBidResponse(httpCall); + } catch (PreBidException e) { + return Result.withError(BidderError.badServerResponse(e.getMessage())); + } + + return bidsFromResponse(bidResponse); + } + + private BidResponse decodeBodyToBidResponse(BidderCall httpCall) { + try { + return mapper.decodeValue(httpCall.getResponse().getBody(), BidResponse.class); + } catch (DecodeException e) { + throw new PreBidException("Failed to parse response as BidResponse: " + e.getMessage()); + } + } + + private Result> bidsFromResponse(BidResponse bidResponse) { + final List errors = new ArrayList<>(); + final List bidderBids = Stream.ofNullable(bidResponse) + .map(BidResponse::getSeatbid) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .filter(Objects::nonNull) + .map(SeatBid::getBid) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .filter(Objects::nonNull) + .map(bid -> makeBid(bid, bidResponse, errors)) + .filter(Objects::nonNull) + .toList(); + + if (bidderBids.isEmpty()) { + errors.add(BidderError.badServerResponse("No valid bids found in response")); + } + + return Result.of(bidderBids, errors); + } + + private BidderBid makeBid(Bid bid, BidResponse bidResponse, List errors) { + try { + return BidderBid.of(bid, getBidType(bid), bidResponse.getCur()); + } catch (PreBidException e) { + errors.add(BidderError.badInput(e.getMessage())); + return null; + } + } + + private BidType getBidType(Bid bid) { + return Optional.ofNullable(bid.getExt()) + .map(this::parseBidExt) + .map(ExtPrebid::getPrebid) + .map(ExtBidPrebid::getType) + .orElseThrow(() -> new PreBidException("No media type for bid " + bid.getId())); + } + + private ExtPrebid parseBidExt(ObjectNode bidExt) { + try { + return mapper.mapper().convertValue(bidExt, EXT_PREBID_TYPE_REFERENCE); + } catch (IllegalArgumentException e) { + throw new PreBidException("Failed to deserialize ext for bid: " + e.getMessage()); + } + } +} diff --git a/src/main/java/org/prebid/server/bidder/goldbach/proto/ExtRequestGoldbach.java b/src/main/java/org/prebid/server/bidder/goldbach/proto/ExtRequestGoldbach.java new file mode 100644 index 00000000000..680ef489f17 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/goldbach/proto/ExtRequestGoldbach.java @@ -0,0 +1,14 @@ +package org.prebid.server.bidder.goldbach.proto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Value; + +@Value(staticConstructor = "of") +public class ExtRequestGoldbach { + + @JsonProperty("publisherId") + String publisherId; + + @JsonProperty("mockResponse") + Boolean mockResponse; +} diff --git a/src/main/java/org/prebid/server/bidder/goldbach/proto/GoldbachExtImp.java b/src/main/java/org/prebid/server/bidder/goldbach/proto/GoldbachExtImp.java new file mode 100644 index 00000000000..1d8d6abb9e9 --- /dev/null +++ b/src/main/java/org/prebid/server/bidder/goldbach/proto/GoldbachExtImp.java @@ -0,0 +1,16 @@ +package org.prebid.server.bidder.goldbach.proto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Value; + +import java.util.List; +import java.util.Map; + +@Value(staticConstructor = "of") +public class GoldbachExtImp { + + @JsonProperty("slotId") + String slotId; + + Map> targetings; +} diff --git a/src/main/java/org/prebid/server/proto/openrtb/ext/request/goldbach/ExtImpGoldbach.java b/src/main/java/org/prebid/server/proto/openrtb/ext/request/goldbach/ExtImpGoldbach.java new file mode 100644 index 00000000000..84d3c0cf615 --- /dev/null +++ b/src/main/java/org/prebid/server/proto/openrtb/ext/request/goldbach/ExtImpGoldbach.java @@ -0,0 +1,20 @@ +package org.prebid.server.proto.openrtb.ext.request.goldbach; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Value; + +import java.util.List; +import java.util.Map; + +@Value(staticConstructor = "of") +public class ExtImpGoldbach { + + @JsonProperty("publisherId") + String publisherId; + + @JsonProperty("slotId") + String slotId; + + @JsonProperty("customTargeting") + Map> customTargeting; +} diff --git a/src/main/java/org/prebid/server/spring/config/bidder/GoldbachConfiguration.java b/src/main/java/org/prebid/server/spring/config/bidder/GoldbachConfiguration.java new file mode 100644 index 00000000000..302a25bb5b9 --- /dev/null +++ b/src/main/java/org/prebid/server/spring/config/bidder/GoldbachConfiguration.java @@ -0,0 +1,41 @@ +package org.prebid.server.spring.config.bidder; + +import org.prebid.server.bidder.BidderDeps; +import org.prebid.server.bidder.goldbach.GoldbachBidder; +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/goldbach.yaml", factory = YamlPropertySourceFactory.class) +public class GoldbachConfiguration { + + private static final String BIDDER_NAME = "goldbach"; + + @Bean("goldbachConfigurationProperties") + @ConfigurationProperties("adapters.goldbach") + BidderConfigurationProperties configurationProperties() { + return new BidderConfigurationProperties(); + } + + @Bean + BidderDeps goldbachBidderDeps(BidderConfigurationProperties goldbachConfigurationProperties, + @NotBlank @Value("${external-url}") String externalUrl, + JacksonMapper mapper) { + + return BidderDepsAssembler.forBidder(BIDDER_NAME) + .withConfig(goldbachConfigurationProperties) + .usersyncerCreator(UsersyncerCreator.create(externalUrl)) + .bidderCreator(config -> new GoldbachBidder(config.getEndpoint(), mapper)) + .assemble(); + } +} diff --git a/src/main/resources/bidder-config/goldbach.yaml b/src/main/resources/bidder-config/goldbach.yaml new file mode 100644 index 00000000000..d8982f564fa --- /dev/null +++ b/src/main/resources/bidder-config/goldbach.yaml @@ -0,0 +1,16 @@ +adapters: + goldbach: + endpoint: https://goldlayer-api.prod.gbads.net/openrtb/2.5/auction + endpoint-compression: gzip + meta-info: + maintainer-email: engineering@goldbach.com + app-media-types: + - banner + - video + - native + site-media-types: + - banner + - video + - native + supported-vendors: + vendor-id: 580 diff --git a/src/main/resources/static/bidder-params/goldbach.json b/src/main/resources/static/bidder-params/goldbach.json new file mode 100644 index 00000000000..0a7c2a4bfac --- /dev/null +++ b/src/main/resources/static/bidder-params/goldbach.json @@ -0,0 +1,39 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Goldbach Adapter Params", + "description": "A schema which validates params accepted by the Goldbach adapter", + "type": "object", + "properties": { + "publisherId": { + "type": "string", + "minLength": 1, + "description": "Publisher Environment ID" + }, + "slotId": { + "type": "string", + "minLength": 1, + "description": "Slot Id" + }, + "customTargeting": { + "type": "object", + "description": "Custom Targeting Parameters", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + }, + "required": [ + "publisherId", + "slotId" + ] +} diff --git a/src/test/java/org/prebid/server/bidder/goldbach/GoldbachBidderTest.java b/src/test/java/org/prebid/server/bidder/goldbach/GoldbachBidderTest.java new file mode 100644 index 00000000000..c0419c20b02 --- /dev/null +++ b/src/test/java/org/prebid/server/bidder/goldbach/GoldbachBidderTest.java @@ -0,0 +1,538 @@ +package org.prebid.server.bidder.goldbach; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +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 io.netty.handler.codec.http.HttpHeaderValues; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.vertx.core.MultiMap; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.impl.headers.HeadersMultiMap; +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.ExtRequest; +import org.prebid.server.proto.openrtb.ext.request.goldbach.ExtImpGoldbach; +import org.prebid.server.proto.openrtb.ext.response.BidType; +import org.prebid.server.util.HttpUtil; + +import java.util.List; +import java.util.Map; +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.assertj.core.api.Assertions.tuple; + +public class GoldbachBidderTest extends VertxTest { + + private static final String ENDPOINT_URL = "https://test.endpoint.com/"; + + private final GoldbachBidder target = new GoldbachBidder(ENDPOINT_URL, jacksonMapper); + + @Test + public void creationShouldFailOnInvalidEndpointUrl() { + assertThatIllegalArgumentException().isThrownBy(() -> new GoldbachBidder("invalid_url", jacksonMapper)); + } + + @Test + public void makeHttpRequestsShouldReturnExpectedRequestUrl() { + // given + final BidRequest bidRequest = givenBidRequest(givenImp()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getUri) + .containsExactly(ENDPOINT_URL); + } + + @Test + public void makeHttpRequestsShouldReturnExpectedRequestMethod() { + // given + final BidRequest bidRequest = givenBidRequest(givenImp()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getMethod) + .containsExactly(HttpMethod.POST); + } + + @Test + public void makeHttpRequestsShouldReturnExpectedRequestHeaders() { + // given + final BidRequest bidRequest = givenBidRequest(givenImp()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getHeaders) + .flatExtracting(MultiMap::entries) + .extracting(Map.Entry::getKey, Map.Entry::getValue) + .containsExactlyInAnyOrder( + tuple(HttpUtil.CONTENT_TYPE_HEADER.toString(), HttpUtil.APPLICATION_JSON_CONTENT_TYPE), + tuple( + HttpUtil.ACCEPT_HEADER.toString(), + HttpHeaderValues.APPLICATION_JSON.toString())); + } + + @Test + public void makeHttpRequestsShouldExtendBidRequestIdWithPublisherId() { + // given + final BidRequest bidRequest = givenBidRequest(givenImp()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .extracting(BidRequest::getId) + .containsExactly("testBidRequestId_testPublisherId"); + } + + @Test + public void makeHttpRequestsShouldReturnErrorIfGoldbachBidRequestExtensionIsInvalid() { + // given + final ExtRequest extRequest = ExtRequest.empty(); + extRequest.addProperty("goldbach", TextNode.valueOf("Invalid request.ext")); + final BidRequest bidRequest = givenBidRequest(request -> request.ext(extRequest), givenImp()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).hasSize(1); + assertThat(result.getErrors()).hasSize(1).allSatisfy(error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()).startsWith("Failed to deserialize Goldbach bid request extension: "); + }); + } + + @Test + public void makeHttpRequestsShouldAddPublisherIdToGoldbachBidRequestExtension() { + // given + final BidRequest bidRequest = givenBidRequest(givenImp()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(httpRequest -> httpRequest.getPayload().getExt().getProperties().get("goldbach")) + .containsExactly(mapper.createObjectNode().put("publisherId", "testPublisherId")); + } + + @Test + public void makeHttpRequestsShouldPreserveMockResponseFieldInGoldbachBidRequestExtension() { + // given + final ExtRequest extRequest = ExtRequest.empty(); + extRequest.addProperty("goldbach", mapper.createObjectNode().put("mockResponse", true)); + final BidRequest bidRequest = givenBidRequest(request -> request.ext(extRequest), givenImp()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(httpRequest -> httpRequest.getPayload().getExt().getProperties().get("goldbach")) + .containsExactly(mapper.createObjectNode() + .put("publisherId", "testPublisherId") + .put("mockResponse", true)); + } + + @Test + public void makeHttpRequestsShouldPreserveOtherBidRequestExtensions() { + // given + final ExtRequest extRequest = ExtRequest.empty(); + final JsonNode anotherExtension = TextNode.valueOf("anotherExtensionValue"); + extRequest.addProperty("anotherExtension", anotherExtension); + final BidRequest bidRequest = givenBidRequest(builder -> builder.ext(extRequest), givenImp()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(httpRequest -> httpRequest.getPayload().getExt().getProperties().get("anotherExtension")) + .containsExactly(anotherExtension); + } + + @Test + public void makeHttpRequestsShouldReturnErrorIfImpExtBidderIsInvalid() { + // given + final BidRequest bidRequest = givenBidRequest(givenImp( + mapper.createObjectNode().put("bidder", "Invalid imp.ext.bidder"))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).hasSize(2).satisfiesExactlyInAnyOrder( + error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()).startsWith("Failed to deserialize Goldbach imp extension: "); + }, + error -> + assertThat(error).isEqualTo(BidderError.badInput("No valid impressions found"))); + } + + @Test + public void makeHttpRequestsShouldReplaceImpExt() { + // given + final BidRequest bidRequest = givenBidRequest(givenImp()); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getExt) + .containsExactly(mapper.createObjectNode().set( + "goldbach", + mapper.createObjectNode().put("slotId", "testSlotId"))); + } + + @Test + public void makeHttpRequestsShouldCopyCustomTargetingToOutputImpExt() { + // given + final BidRequest bidRequest = givenBidRequest(givenImp(givenImpExt( + "testPublisherId", + "testSlotId", + Map.of("key", List.of("value1", "value2"))))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + final ObjectNode expectedImpExt = mapper.valueToTree(Map.of("goldbach", Map.of( + "slotId", "testSlotId", + "targetings", Map.of( + "key", List.of("value1", "value2"))))); + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getExt) + .containsExactly(expectedImpExt); + } + + @Test + public void makeHttpRequestsShouldParseSingleStringAsArrayInCustomTargeting() { + // given + final ObjectNode impExt = mapper.valueToTree(Map.of("bidder", Map.of( + "publisherId", "testPublisherId", + "slotId", "testSlotId", + "customTargeting", Map.of( + "key1", "value1", + "key2", List.of("value2", "value3"))))); + final BidRequest bidRequest = givenBidRequest(givenImp(impExt)); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + final ObjectNode expectedImpExt = mapper.valueToTree(Map.of("goldbach", Map.of( + "slotId", "testSlotId", + "targetings", Map.of( + "key1", List.of("value1"), + "key2", List.of("value2", "value3"))))); + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()) + .extracting(HttpRequest::getPayload) + .flatExtracting(BidRequest::getImp) + .extracting(Imp::getExt) + .containsExactly(expectedImpExt); + } + + @Test + public void makeHttpRequestsShouldReturnErrorIfThereAreNoImpressions() { + // given + final BidRequest bidRequest = givenBidRequest(); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getValue()).isEmpty(); + assertThat(result.getErrors()).containsExactly(BidderError.badInput("No valid impressions found")); + } + + @Test + public void makeHttpRequestsShouldGroupImpressionsByPublisherId() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp("imp1", givenImpExt("publisherId1", "slot1")), + givenImp("imp2", givenImpExt("publisherId2", "slot2")), + givenImp("imp3", givenImpExt("publisherId1", "slot3"))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).hasSize(2).satisfiesExactlyInAnyOrder( + request -> { + assertThat(request.getImpIds()).containsExactlyInAnyOrder("imp1", "imp3"); + assertThat(request.getPayload().getId()).isEqualTo("testBidRequestId_publisherId1"); + }, + request -> { + assertThat(request.getImpIds()).containsExactlyInAnyOrder("imp2"); + assertThat(request.getPayload().getId()).isEqualTo("testBidRequestId_publisherId2"); + } + ); + } + + @Test + public void makeHttpRequestsShouldReturnErrorAndRequestWithOtherImpressionsIfThereAreImpressionsWithErrors() { + // given + final BidRequest bidRequest = givenBidRequest( + givenImp("imp1", givenImpExt("publisherId1", "slot1")), + givenImp("imp2", givenImpExt("publisherId2", "slot2")), + givenImp("invalidImp", mapper.createObjectNode().put("bidder", "Invalid imp.ext.bidder"))); + + // when + final Result>> result = target.makeHttpRequests(bidRequest); + + // then + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getValue()).hasSize(2).satisfiesExactlyInAnyOrder( + request -> { + assertThat(request.getImpIds()).containsExactlyInAnyOrder("imp1"); + assertThat(request.getPayload().getId()).isEqualTo("testBidRequestId_publisherId1"); + }, + request -> { + assertThat(request.getImpIds()).containsExactlyInAnyOrder("imp2"); + assertThat(request.getPayload().getId()).isEqualTo("testBidRequestId_publisherId2"); + } + ); + } + + @Test + public void makeBidsShouldReturnErrorIfResponseHasInvalidStatus() { + // given + final BidderCall httpCall = BidderCall.succeededHttp( + null, + HttpResponse.of(HttpResponseStatus.INTERNAL_SERVER_ERROR.code(), HeadersMultiMap.headers(), null), + null); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).containsExactly( + BidderError.badServerResponse("unexpected status code: 500. Run with request.debug = 1 for more info")); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnErrorIfResponseHasInvalidBody() { + // given + final BidderCall httpCall = BidderCall.succeededHttp( + null, + HttpResponse.of(HttpResponseStatus.CREATED.code(), HeadersMultiMap.headers(), "\"Invalid body\""), + null); + + // 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(error.getMessage()).startsWith("Failed to parse response as BidResponse: "); + }); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnErrorIfThereAreNoBids() { + // given + final BidderCall httpCall = givenHttpCall(); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()) + .containsExactly(BidderError.badServerResponse("No valid bids found in response")); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnErrorIfBidHasInvalidExt() { + // given + final BidderCall httpCall = givenHttpCall( + Bid.builder().ext(mapper.createObjectNode().put("prebid", "Invalid")).build()); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).hasSize(2).satisfiesExactlyInAnyOrder( + error -> { + assertThat(error.getType()).isEqualTo(BidderError.Type.bad_input); + assertThat(error.getMessage()).startsWith("Failed to deserialize ext for bid: "); + }, + error -> assertThat(error) + .isEqualTo(BidderError.badServerResponse("No valid bids found in response")) + ); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnErrorIfBidDoesntHaveMediaType() { + // given + final ObjectNode bidExt = mapper.createObjectNode() + .set("prebid", mapper.createObjectNode()); + final BidderCall httpCall = givenHttpCall(Bid.builder().id("testBidId").ext(bidExt).build()); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).containsExactlyInAnyOrder( + BidderError.badInput("No media type for bid testBidId"), + BidderError.badServerResponse("No valid bids found in response") + ); + assertThat(result.getValue()).isEmpty(); + } + + @Test + public void makeBidsShouldReturnBidWithNoErrorsForValidInput() { + // given + final ObjectNode bidExt = mapper.createObjectNode() + .set("prebid", mapper.createObjectNode().put("type", "banner")); + final Bid bid = Bid.builder().id("testBidId").ext(bidExt).build(); + final BidderCall httpCall = givenHttpCall(bid); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).isEmpty(); + assertThat(result.getValue()).containsExactly( + BidderBid.of( + bid, + BidType.banner, + "USD")); + } + + @Test + public void makeBidsShouldReturnValidBidsAndErrorsIfThereAreBothValidAndInvalidBidsInInput() { + // given + final ObjectNode validBidExt = mapper.createObjectNode() + .set("prebid", mapper.createObjectNode().put("type", "banner")); + final ObjectNode invalidBidExt = mapper.createObjectNode() + .set("prebid", mapper.createObjectNode()); + final Bid validBid = Bid.builder().id("validBidId").ext(validBidExt).build(); + final Bid invalidBid = Bid.builder().id("invalidBidId").ext(invalidBidExt).build(); + final BidderCall httpCall = givenHttpCall(validBid, invalidBid); + + // when + final Result> result = target.makeBids(httpCall, null); + + // then + assertThat(result.getErrors()).containsExactly(BidderError.badInput("No media type for bid invalidBidId")); + assertThat(result.getValue()).containsExactly(BidderBid.of(validBid, BidType.banner, "USD")); + } + + private static BidRequest givenBidRequest() { + return givenBidRequest(identity()); + } + + private static BidRequest givenBidRequest(Imp... imps) { + return givenBidRequest(identity(), imps); + } + + private static BidRequest givenBidRequest(UnaryOperator bidRequestCustomizer, + Imp... imps) { + + return bidRequestCustomizer + .apply(BidRequest.builder() + .id("testBidRequestId") + .imp(List.of(imps))) + .build(); + } + + private static Imp givenImp() { + return givenImp(givenImpExt()); + } + + private static Imp givenImp(ObjectNode impExt) { + return givenImp(null, impExt); + } + + private static Imp givenImp(String impId, ObjectNode impExt) { + return Imp.builder() + .id(impId) + .ext(impExt) + .build(); + } + + private static ObjectNode givenImpExt() { + return givenImpExt("testPublisherId", "testSlotId", null); + } + + private static ObjectNode givenImpExt(String publisherId, String slotId) { + return givenImpExt(publisherId, slotId, null); + } + + private static ObjectNode givenImpExt(String publisherId, + String slotId, + Map> customTargeting) { + return mapper.valueToTree(ExtPrebid.of( + null, + ExtImpGoldbach.of(publisherId, slotId, customTargeting))); + } + + private static BidderCall givenHttpCall(Bid... bids) { + return BidderCall.succeededHttp( + HttpRequest.builder().payload(null).build(), + HttpResponse.of(HttpResponseStatus.CREATED.code(), null, givenBidResponse(bids)), + null); + } + + private static String givenBidResponse(Bid... bids) { + try { + return mapper.writeValueAsString(BidResponse.builder() + .cur("USD") + .seatbid(singletonList(SeatBid.builder().bid(List.of(bids)).build())) + .build()); + } catch (JsonProcessingException e) { + throw new RuntimeException("Error encoding BidResponse to json: " + e); + } + } + +} diff --git a/src/test/java/org/prebid/server/it/GoldbachTest.java b/src/test/java/org/prebid/server/it/GoldbachTest.java new file mode 100644 index 00000000000..9b668a108af --- /dev/null +++ b/src/test/java/org/prebid/server/it/GoldbachTest.java @@ -0,0 +1,36 @@ +package org.prebid.server.it; + +import io.netty.handler.codec.http.HttpResponseStatus; +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 GoldbachTest extends IntegrationTest { + + @Test + public void openrtb2AuctionShouldRespondWithBidsFromGoldbach() throws IOException, JSONException { + // given + WIRE_MOCK_RULE.stubFor(post(urlPathEqualTo("/goldbach-exchange")) + .withRequestBody(equalToJson(jsonFrom("openrtb2/goldbach/test-goldbach-bid-request.json"))) + .willReturn(aResponse() + .withStatus(HttpResponseStatus.CREATED.code()) + .withBody(jsonFrom("openrtb2/goldbach/test-goldbach-bid-response.json")))); + + // when + final Response response = responseFor("openrtb2/goldbach/test-auction-goldbach-request.json", + Endpoint.openrtb2_auction); + + // then + assertJsonEquals("openrtb2/goldbach/test-auction-goldbach-response.json", response, + singletonList("goldbach")); + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/goldbach/test-auction-goldbach-request.json b/src/test/resources/org/prebid/server/it/openrtb2/goldbach/test-auction-goldbach-request.json new file mode 100644 index 00000000000..de9e0e18bb8 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/goldbach/test-auction-goldbach-request.json @@ -0,0 +1,23 @@ +{ + "id": "723f8906-7f59-4f50-bba9-ed9115ab1663", + "imp": [ + { + "id": "test-imp-123", + "banner": { + "h": 250, + "w": 300, + "pos": 0 + }, + "ext": { + "goldbach": { + "slotId": "12345678/de-example.ch/slot-id/some-page", + "publisherId": "de-example.ch" + } + } + } + ], + "tmax": 5000, + "regs": { + "gdpr": 0 + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/goldbach/test-auction-goldbach-response.json b/src/test/resources/org/prebid/server/it/openrtb2/goldbach/test-auction-goldbach-response.json new file mode 100644 index 00000000000..5d9375becbc --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/goldbach/test-auction-goldbach-response.json @@ -0,0 +1,40 @@ +{ + "id": "723f8906-7f59-4f50-bba9-ed9115ab1663", + "seatbid": [ + { + "bid": [ + { + "id": "b334cb75-41d8-4f61-801c-1e785a2fe38d", + "impid": "test-imp-123", + "price": 2.5, + "adm": "test-ad-content", + "adid": "456", + "cid": "1234", + "crid": "1234", + "exp": 300, + "ext": { + "prebid": { + "type": "banner", + "meta": { + "adaptercode": "goldbach" + } + }, + "origbidcpm": 2.5 + } + } + ], + "seat": "goldbach", + "group": 0 + } + ], + "cur": "USD", + "ext": { + "responsetimemillis": { + "goldbach": "{{ goldbach.response_time_ms }}" + }, + "tmaxrequest": 5000, + "prebid": { + "auctiontimestamp": 0 + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/goldbach/test-goldbach-bid-request.json b/src/test/resources/org/prebid/server/it/openrtb2/goldbach/test-goldbach-bid-request.json new file mode 100644 index 00000000000..5dfc52fa333 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/goldbach/test-goldbach-bid-request.json @@ -0,0 +1,59 @@ +{ + "id": "723f8906-7f59-4f50-bba9-ed9115ab1663_de-example.ch", + "imp": [ + { + "ext": { + "goldbach": { + "slotId": "12345678/de-example.ch/slot-id/some-page" + } + }, + "id": "test-imp-123", + "secure": 1, + "banner": { + "h": 250, + "w": 300, + "pos": 0 + } + } + ], + "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" + ], + "source": { + "tid": "${json-unit.any-string}" + }, + "regs": { + "ext": { + "gdpr": 0 + } + }, + "ext": { + "prebid": { + "server": { + "externalurl": "http://localhost:8080", + "gvlid": 1, + "datacenter": "local", + "endpoint": "/openrtb2/auction" + } + }, + "goldbach": { + "publisherId": "de-example.ch" + } + } +} diff --git a/src/test/resources/org/prebid/server/it/openrtb2/goldbach/test-goldbach-bid-response.json b/src/test/resources/org/prebid/server/it/openrtb2/goldbach/test-goldbach-bid-response.json new file mode 100644 index 00000000000..495ff11aed0 --- /dev/null +++ b/src/test/resources/org/prebid/server/it/openrtb2/goldbach/test-goldbach-bid-response.json @@ -0,0 +1,23 @@ +{ + "id": "c04f2011-87e9-4cde-9152-78fa2a63078a", + "seatbid": [ + { + "bid": [ + { + "id": "b334cb75-41d8-4f61-801c-1e785a2fe38d", + "price": 2.5, + "adm": "test-ad-content", + "adid": "456", + "impid": "test-imp-123", + "crid": "1234", + "cid": "1234", + "ext": { + "prebid": { + "type": "banner" + } + } + } + ] + } + ] +} 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 1a073dcda06..c2b79789b3b 100644 --- a/src/test/resources/org/prebid/server/it/test-application.properties +++ b/src/test/resources/org/prebid/server/it/test-application.properties @@ -275,6 +275,8 @@ adapters.gamoshi.enabled=true adapters.gamoshi.endpoint=http://localhost:8090/gamoshi-exchange adapters.globalsun.enabled=true adapters.globalsun.endpoint=http://localhost:8090/globalsun-exchange +adapters.goldbach.enabled=true +adapters.goldbach.endpoint=http://localhost:8090/goldbach-exchange adapters.gothamads.enabled=true adapters.gothamads.endpoint=http://localhost:8090/gothamads-exchange adapters.gothamads.aliases.intenze.enabled=true