diff --git a/sdk/src/main/java/com/adzerk/android/sdk/gson/MatchedPointsDeserializer.java b/sdk/src/main/java/com/adzerk/android/sdk/gson/MatchedPointsDeserializer.java index f7d35bd..82706a8 100644 --- a/sdk/src/main/java/com/adzerk/android/sdk/gson/MatchedPointsDeserializer.java +++ b/sdk/src/main/java/com/adzerk/android/sdk/gson/MatchedPointsDeserializer.java @@ -40,11 +40,11 @@ public List deserialize(JsonElement json, Type typeOfT, JsonDeserializ JsonObject jsonObject = jsonElement.getAsJsonObject(); Set attributes = jsonObject.keySet(); if (!attributes.contains(LAT_ATTRIBUTE) || !attributes.contains(LON_ATTRIBUTE)) { - throw new JsonParseException("Unable to parse matchedPoint - 'lat' and 'lon' string values are expected"); + throw new JsonParseException("Unable to parse matchedPoint - 'lat' and 'lon' values are expected"); } - String lat = jsonObject.get(LAT_ATTRIBUTE).getAsString(); - String lon = jsonObject.get(LON_ATTRIBUTE).getAsString(); + double lat = getCoordinate(jsonObject, LAT_ATTRIBUTE); + double lon = getCoordinate(jsonObject, LON_ATTRIBUTE); Location matchPoint = getLocation(lat, lon); locations.add(matchPoint); } @@ -52,14 +52,30 @@ public List deserialize(JsonElement json, Type typeOfT, JsonDeserializ return locations; } - public Location getLocation(String lat, String lon) { - Location location = new Location(BuildConfig.LIBRARY_PACKAGE_NAME); + /** + * Reads a coordinate. The API sends these as numbers, but they used to be sent as strings, + * so both are accepted. + */ + private double getCoordinate(JsonObject jsonObject, String attribute) { try { - location.setLatitude(Double.parseDouble(lat)); - location.setLongitude(Double.parseDouble(lon)); + return jsonObject.get(attribute).getAsDouble(); } catch (Exception e) { + throw new JsonParseException("Unable to parse matchedPoint - '" + attribute + "' is expected to be a number"); + } + } + + public Location getLocation(String lat, String lon) { + try { + return getLocation(Double.parseDouble(lat), Double.parseDouble(lon)); + } catch (NumberFormatException e) { throw new JsonParseException("Unable to parse matchedPoint - 'lat' and 'lon' string values expected to be parsable as doubles"); } + } + + public Location getLocation(double lat, double lon) { + Location location = new Location(BuildConfig.LIBRARY_PACKAGE_NAME); + location.setLatitude(lat); + location.setLongitude(lon); return location; } } diff --git a/sdk/src/main/java/com/adzerk/android/sdk/rest/Decision.java b/sdk/src/main/java/com/adzerk/android/sdk/rest/Decision.java index 1802ca4..11fef5c 100644 --- a/sdk/src/main/java/com/adzerk/android/sdk/rest/Decision.java +++ b/sdk/src/main/java/com/adzerk/android/sdk/rest/Decision.java @@ -6,6 +6,7 @@ import com.google.gson.annotations.JsonAdapter; import java.util.List; +import java.util.Map; /** * A Decision represents the ad that was selected to be served for a given {@link Placement}. @@ -42,9 +43,27 @@ public class Decision { // url endpoint that, using a GET, triggers the recording of the impression String impressionUrl; + // height of the selected ad, if the creative supplies it + Integer height; + + // width of the selected ad, if the creative supplies it + Integer width; + + // custom metadata configured on the ad; only present if set + Map externalMetadata; + + // ecpm partition of the matched impression; only present if set + String ecpmPartition; + + // when multiple ads are selected for a non-multi-winner placement, the ads beyond the first + List adChain; + @JsonAdapter(MatchedPointsDeserializer.class) List matchedPoints; + // pricing details; only present when the Request sets includePricingData to true + PricingData pricing; + /** * Returns id for the ad that was selected * @return ad id @@ -117,7 +136,57 @@ public List getEvents() { return events; } + /** + * Returns the height of the selected ad, or null if the creative does not supply one + * @return ad height + */ + public Integer getHeight() { + return height; + } + + /** + * Returns the width of the selected ad, or null if the creative does not supply one + * @return ad width + */ + public Integer getWidth() { + return width; + } + + /** + * Returns the custom metadata configured on the ad, or null if none is set + * @return map of custom metadata + */ + public Map getExternalMetadata() { + return externalMetadata; + } + + /** + * Returns the ecpm partition of the matched impression, or null if none is set + * @return ecpm partition + */ + public String getEcpmPartition() { + return ecpmPartition; + } + + /** + * Returns the additional ads beyond the first when multiple ads were selected for a + * non-multi-winner {@link Placement}, or null if there are none + * @return list of additional decisions + */ + public List getAdChain() { + return adChain; + } + public List getMatchedPoints() { return matchedPoints; } + + /** + * Returns the {@link PricingData} for the selected ad, or null if the {@link Request} did not + * set the includePricingData option + * @return pricing details + */ + public PricingData getPricing() { + return pricing; + } } diff --git a/sdk/src/main/java/com/adzerk/android/sdk/rest/PricingData.java b/sdk/src/main/java/com/adzerk/android/sdk/rest/PricingData.java new file mode 100644 index 0000000..b297723 --- /dev/null +++ b/sdk/src/main/java/com/adzerk/android/sdk/rest/PricingData.java @@ -0,0 +1,103 @@ +package com.adzerk.android.sdk.rest; + +import com.google.gson.annotations.SerializedName; + +/** + * Pricing details for the ad that was selected for a {@link Placement}. + *

+ * Only present when the ad {@link Request} sets the includePricingData option to true, and + * individual fields are only present when they apply to the matched impression. + * + * @see Decision + */ +public class PricingData { + + // price of the impression + Float price; + + // price the impression cleared at + Float clearPrice; + + // only present when a bid modifier applied to the impression + Float modifiedPrice; + + // only present when the flight has a targetROAS configured + Float optimizedPrice; + + // multiplier applied to the value of the impression's events + Float eventMultiplier; + + // revenue recorded for the impression + Float revenue; + + // rate type of the flight that served the impression + Integer rateType; + + // effective cost per thousand impressions + @SerializedName("eCPM") + Float eCPM; + + /** + * Returns the price of the impression, or null if not present + * @return price + */ + public Float getPrice() { + return price; + } + + /** + * Returns the price the impression cleared at, or null if not present + * @return clear price + */ + public Float getClearPrice() { + return clearPrice; + } + + /** + * Returns the modified price, only present when a bid modifier applied to the impression + * @return modified price + */ + public Float getModifiedPrice() { + return modifiedPrice; + } + + /** + * Returns the optimized price, only present when the flight has a targetROAS configured + * @return optimized price + */ + public Float getOptimizedPrice() { + return optimizedPrice; + } + + /** + * Returns the multiplier applied to the value of the impression's events, or null if not present + * @return event multiplier + */ + public Float getEventMultiplier() { + return eventMultiplier; + } + + /** + * Returns the revenue recorded for the impression, or null if not present + * @return revenue + */ + public Float getRevenue() { + return revenue; + } + + /** + * Returns the rate type of the flight that served the impression, or null if not present + * @return rate type + */ + public Integer getRateType() { + return rateType; + } + + /** + * Returns the effective cost per thousand impressions, or null if not present + * @return eCPM + */ + public Float getECPM() { + return eCPM; + } +} diff --git a/sdk/src/test/java/com/adzerk/android/sdk/gson/MatchedPointsDeserializerTest.java b/sdk/src/test/java/com/adzerk/android/sdk/gson/MatchedPointsDeserializerTest.java index e768a2d..a93c9be 100644 --- a/sdk/src/test/java/com/adzerk/android/sdk/gson/MatchedPointsDeserializerTest.java +++ b/sdk/src/test/java/com/adzerk/android/sdk/gson/MatchedPointsDeserializerTest.java @@ -34,6 +34,20 @@ public void setUp() throws Exception { gson = new GsonBuilder().create(); } + @Test + public void ShouldSucceed_WhenLatLonAreNumbers() { + MatchedPointsContainer result = gson.fromJson(JSON_VALID_NUMERIC_MATCHEDPOINTS, MatchedPointsContainer.class); + + assertThat(result).isNotNull(); + assertThat(result.matchedPoints).isNotNull().isNotEmpty().hasSize(3); + assertThat(result.matchedPoints.get(0).getLatitude()).isEqualTo(35.995063); + assertThat(result.matchedPoints.get(0).getLongitude()).isEqualTo(-78.908187); + assertThat(result.matchedPoints.get(1).getLatitude()).isEqualTo(40.689188); + assertThat(result.matchedPoints.get(1).getLongitude()).isEqualTo(-74.044562); + assertThat(result.matchedPoints.get(2).getLatitude()).isEqualTo(29.979188); + assertThat(result.matchedPoints.get(2).getLongitude()).isEqualTo(31.134188); + } + @Test public void ShouldSucceed_WhenJsonIsValidMatchedPoints() { MatchedPointsContainer result = gson.fromJson(JSON_VALID_MATCHEDPOINTS, MatchedPointsContainer.class); @@ -55,7 +69,7 @@ public void ShouldThrow_WhenJsonIsNotArray() { } @Test(expected = JsonParseException.class) - public void ShouldThrow_WhenLatLonAreNotStrings() { + public void ShouldThrow_WhenLatLonAreNotNumeric() { gson.fromJson(JSON_INVALID_2, MatchedPointsContainer.class); } @@ -94,12 +108,29 @@ public void ShouldThrow_WhenLatLonMissing() { " };"; - static String JSON_INVALID_2 = + static String JSON_VALID_NUMERIC_MATCHEDPOINTS = " {\"matchedPoints\": [" + " {" + " \"lat\": 35.995063," + " \"lon\": -78.908187" + " }," + + " {" + + " \"lat\": 40.689188," + + " \"lon\": -74.044562" + + " }," + + " {" + + " \"lat\": 29.979188," + + " \"lon\": 31.134188" + + " }" + + " ]" + + " }"; + + static String JSON_INVALID_2 = + " {\"matchedPoints\": [" + + " {" + + " \"lat\": { \"degrees\": 35 }," + + " \"lon\": -78.908187" + + " }" + " ]" + " }"; diff --git a/sdk/src/test/java/com/adzerk/android/sdk/rest/DecisionResponseTest.java b/sdk/src/test/java/com/adzerk/android/sdk/rest/DecisionResponseTest.java index 00cf571..b71ff77 100644 --- a/sdk/src/test/java/com/adzerk/android/sdk/rest/DecisionResponseTest.java +++ b/sdk/src/test/java/com/adzerk/android/sdk/rest/DecisionResponseTest.java @@ -104,6 +104,96 @@ public void itShouldDeserializeMatchedPoints() { assertThat(matchedPoints).isNotNull().isNotEmpty().hasSize(3); } + @Test + public void itShouldDeserializeNumericMatchedPoints() { + AdzerkSdk sdk = AdzerkSdk.createInstance(new MockClient(JSON_DECISION_WITH_NEW_FIELDS).buildClient()); + + DecisionResponse response = sdk.requestPlacementSynchronous(createTestRequest()); + assertThat(response).isNotNull(); + + List matchedPoints = response.getDecisions("div1").get(0).getMatchedPoints(); + assertThat(matchedPoints).isNotNull().isNotEmpty().hasSize(2); + assertThat(matchedPoints.get(0).getLatitude()).isEqualTo(35.995063); + assertThat(matchedPoints.get(0).getLongitude()).isEqualTo(-78.908187); + assertThat(matchedPoints.get(1).getLatitude()).isEqualTo(40.689188); + assertThat(matchedPoints.get(1).getLongitude()).isEqualTo(-74.044562); + } + + @Test + public void itShouldDeserializeDimensions() { + AdzerkSdk sdk = AdzerkSdk.createInstance(new MockClient(JSON_DECISION_WITH_NEW_FIELDS).buildClient()); + + DecisionResponse response = sdk.requestPlacementSynchronous(createTestRequest()); + Decision div1 = response.getDecisions("div1").get(0); + + assertThat(div1.getHeight()).isEqualTo(250); + assertThat(div1.getWidth()).isEqualTo(300); + } + + @Test + public void itShouldDeserializeExternalMetadata() { + AdzerkSdk sdk = AdzerkSdk.createInstance(new MockClient(JSON_DECISION_WITH_NEW_FIELDS).buildClient()); + + DecisionResponse response = sdk.requestPlacementSynchronous(createTestRequest()); + Decision div1 = response.getDecisions("div1").get(0); + + assertThat(div1.getExternalMetadata()).isNotNull().hasSize(2); + assertThat(div1.getExternalMetadata().get("campaignTag")).isEqualTo("summer-sale"); + assertThat(div1.getExternalMetadata().get("priority")).isEqualTo(3.0); + } + + @Test + public void itShouldDeserializeEcpmPartition() { + AdzerkSdk sdk = AdzerkSdk.createInstance(new MockClient(JSON_DECISION_WITH_NEW_FIELDS).buildClient()); + + DecisionResponse response = sdk.requestPlacementSynchronous(createTestRequest()); + + assertThat(response.getDecisions("div1").get(0).getEcpmPartition()).isEqualTo("partition-a"); + } + + @Test + public void itShouldDeserializeAdChain() { + AdzerkSdk sdk = AdzerkSdk.createInstance(new MockClient(JSON_DECISION_WITH_NEW_FIELDS).buildClient()); + + DecisionResponse response = sdk.requestPlacementSynchronous(createTestRequest()); + List adChain = response.getDecisions("div1").get(0).getAdChain(); + + assertThat(adChain).isNotNull().isNotEmpty().hasSize(1); + assertThat(adChain.get(0).getAdId()).isEqualTo(999); + assertThat(adChain.get(0).getCreativeId()).isEqualTo(888); + } + + @Test + public void itShouldDeserializePricingData() { + AdzerkSdk sdk = AdzerkSdk.createInstance(new MockClient(JSON_DECISION_WITH_NEW_FIELDS).buildClient()); + + DecisionResponse response = sdk.requestPlacementSynchronous(createTestRequest()); + PricingData pricing = response.getDecisions("div1").get(0).getPricing(); + + assertThat(pricing).isNotNull(); + assertThat(pricing.getPrice()).isEqualTo(3.0f); + assertThat(pricing.getClearPrice()).isEqualTo(2.0f); + assertThat(pricing.getModifiedPrice()).isEqualTo(1.5f); + assertThat(pricing.getOptimizedPrice()).isEqualTo(2.25f); + assertThat(pricing.getEventMultiplier()).isEqualTo(1.1f); + assertThat(pricing.getRevenue()).isEqualTo(0.003f); + assertThat(pricing.getRateType()).isEqualTo(2); + assertThat(pricing.getECPM()).isEqualTo(3.0f); + } + + @Test + public void itShouldLeaveNewFieldsNullWhenAbsent() { + DecisionResponse response = sdk.requestPlacementSynchronous(createTestRequest()); + Decision div1 = response.getDecisions("div1").get(0); + + assertThat(div1.getHeight()).isNull(); + assertThat(div1.getWidth()).isNull(); + assertThat(div1.getExternalMetadata()).isNull(); + assertThat(div1.getEcpmPartition()).isNull(); + assertThat(div1.getAdChain()).isNull(); + assertThat(div1.getPricing()).isNull(); + } + @Test public void itShouldFirePixelSuccessfully() { AdzerkSdk sdk = new AdzerkSdk.Builder().networkId(9792L).build(); @@ -170,6 +260,47 @@ private Request createTestRequest() { " }" + "}"; + static final String JSON_DECISION_WITH_NEW_FIELDS = "{" + + " \"user\": { \"key\": \"ad39231daeb043f2a9610414f08394b5\" }," + + " \"decisions\": {" + + " \"div1\": {" + + " \"adId\": 111," + + " \"creativeId\": 222," + + " \"height\": 250," + + " \"width\": 300," + + " \"ecpmPartition\": \"partition-a\"," + + " \"externalMetadata\": {" + + " \"campaignTag\": \"summer-sale\"," + + " \"priority\": 3" + + " }," + + " \"contents\": []," + + " \"events\": []," + + " \"adChain\": [" + + " {" + + " \"adId\": 999," + + " \"creativeId\": 888," + + " \"contents\": []," + + " \"events\": []" + + " }" + + " ]," + + " \"matchedPoints\": [" + + " { \"lat\": 35.995063, \"lon\": -78.908187 }," + + " { \"lat\": 40.689188, \"lon\": -74.044562 }" + + " ]," + + " \"pricing\": {" + + " \"price\": 3.0," + + " \"clearPrice\": 2.0," + + " \"modifiedPrice\": 1.5," + + " \"optimizedPrice\": 2.25," + + " \"eventMultiplier\": 1.1," + + " \"revenue\": 0.003," + + " \"rateType\": 2," + + " \"eCPM\": 3.0" + + " }" + + " }" + + " }" + + "}"; + static final String JSON_MULTI_WINNERS = "{" + " \"user\": {" + " \"key\": \"ue1-a58d96713f6a41edb42695d24178e224\"" +