diff --git a/core-api/src/main/java/com/optimizely/ab/internal/JsonParserProvider.java b/core-api/src/main/java/com/optimizely/ab/internal/JsonParserProvider.java new file mode 100644 index 000000000..6f6de6516 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/internal/JsonParserProvider.java @@ -0,0 +1,74 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import com.optimizely.ab.config.parser.MissingJsonParserException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public enum JsonParserProvider { + GSON_CONFIG_PARSER("com.google.gson.Gson"), + JACKSON_CONFIG_PARSER("com.fasterxml.jackson.databind.ObjectMapper" ), + JSON_CONFIG_PARSER("org.json.JSONObject"), + JSON_SIMPLE_CONFIG_PARSER("org.json.simple.JSONObject"); + + private static final Logger logger = LoggerFactory.getLogger(JsonParserProvider.class); + + private final String className; + + JsonParserProvider(String className) { + this.className = className; + } + + private boolean isAvailable() { + try { + Class.forName(className); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + public static JsonParserProvider getDefaultParser() { + String defaultParserName = PropertyUtils.get("default_parser"); + + if (defaultParserName != null) { + try { + JsonParserProvider parser = JsonParserProvider.valueOf(defaultParserName); + if (parser.isAvailable()) { + logger.debug("using json parser: {}, based on override config", parser.className); + return parser; + } + + logger.warn("configured parser {} is not available in the classpath", defaultParserName); + } catch (IllegalArgumentException e) { + logger.warn("configured parser {} is not a valid value", defaultParserName); + } + } + + for (JsonParserProvider parser: JsonParserProvider.values()) { + if (!parser.isAvailable()) { + continue; + } + + logger.info("using json parser: {}", parser.className); + return parser; + } + + throw new MissingJsonParserException("unable to locate a JSON parser. " + + "Please see for more information"); + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java new file mode 100644 index 000000000..ed40e37fd --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPApiManager.java @@ -0,0 +1,22 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import java.util.List; + +public interface ODPApiManager { + String fetchQualifiedSegments(String apiKey, String apiEndpoint, String userKey, String userValue, List segmentsToCheck); +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParser.java new file mode 100644 index 000000000..d494a78d0 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParser.java @@ -0,0 +1,22 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser; + +import java.util.List; + +public interface ResponseJsonParser { + public List parseQualifiedSegments(String responseJson); +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactory.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactory.java new file mode 100644 index 000000000..7762cef0e --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactory.java @@ -0,0 +1,49 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser; + +import com.optimizely.ab.internal.JsonParserProvider; +import com.optimizely.ab.odp.parser.impl.GsonParser; +import com.optimizely.ab.odp.parser.impl.JacksonParser; +import com.optimizely.ab.odp.parser.impl.JsonParser; +import com.optimizely.ab.odp.parser.impl.JsonSimpleParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ResponseJsonParserFactory { + private static final Logger logger = LoggerFactory.getLogger(ResponseJsonParserFactory.class); + + public static ResponseJsonParser getParser() { + JsonParserProvider parserProvider = JsonParserProvider.getDefaultParser(); + ResponseJsonParser jsonParser = null; + switch (parserProvider) { + case GSON_CONFIG_PARSER: + jsonParser = new GsonParser(); + break; + case JACKSON_CONFIG_PARSER: + jsonParser = new JacksonParser(); + break; + case JSON_CONFIG_PARSER: + jsonParser = new JsonParser(); + break; + case JSON_SIMPLE_CONFIG_PARSER: + jsonParser = new JsonSimpleParser(); + break; + } + logger.info("Using " + parserProvider.toString() + " parser"); + return jsonParser; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/GsonParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/GsonParser.java new file mode 100644 index 000000000..b27d65078 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/GsonParser.java @@ -0,0 +1,62 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser.impl; + +import com.google.gson.*; +import com.google.gson.JsonParser; +import com.optimizely.ab.odp.parser.ResponseJsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +public class GsonParser implements ResponseJsonParser { + private static final Logger logger = LoggerFactory.getLogger(GsonParser.class); + + @Override + public List parseQualifiedSegments(String responseJson) { + List parsedSegments = new ArrayList<>(); + try { + JsonObject root = JsonParser.parseString(responseJson).getAsJsonObject(); + + if (root.has("errors")) { + JsonArray errors = root.getAsJsonArray("errors"); + StringBuilder logMessage = new StringBuilder(); + for (int i = 0; i < errors.size(); i++) { + if (i > 0) { + logMessage.append(", "); + } + logMessage.append(errors.get(i).getAsJsonObject().get("message").getAsString()); + } + logger.error(logMessage.toString()); + return null; + } + + JsonArray edges = root.getAsJsonObject("data").getAsJsonObject("customer").getAsJsonObject("audiences").getAsJsonArray("edges"); + for (int i = 0; i < edges.size(); i++) { + JsonObject node = edges.get(i).getAsJsonObject().getAsJsonObject("node"); + if (node.has("state") && node.get("state").getAsString().equals("qualified")) { + parsedSegments.add(node.get("name").getAsString()); + } + } + return parsedSegments; + } catch (JsonSyntaxException e) { + logger.error("Error parsing qualified segments from response", e); + return null; + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JacksonParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JacksonParser.java new file mode 100644 index 000000000..f1a38eca7 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JacksonParser.java @@ -0,0 +1,66 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.optimizely.ab.odp.parser.ResponseJsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; + +import java.util.List; + +public class JacksonParser implements ResponseJsonParser { + private static final Logger logger = LoggerFactory.getLogger(JacksonParser.class); + + @Override + public List parseQualifiedSegments(String responseJson) { + ObjectMapper objectMapper = new ObjectMapper(); + List parsedSegments = new ArrayList<>(); + JsonNode root; + try { + root = objectMapper.readTree(responseJson); + + if (root.has("errors")) { + JsonNode errors = root.path("errors"); + StringBuilder logMessage = new StringBuilder(); + for (int i = 0; i < errors.size(); i++) { + if (i > 0) { + logMessage.append(", "); + } + logMessage.append(errors.get(i).path("message")); + } + logger.error(logMessage.toString()); + return null; + } + + JsonNode edges = root.path("data").path("customer").path("audiences").path("edges"); + for (JsonNode edgeNode : edges) { + String state = edgeNode.path("node").path("state").asText(); + if (state.equals("qualified")) { + parsedSegments.add(edgeNode.path("node").path("name").asText()); + } + } + return parsedSegments; + } catch (JsonProcessingException e) { + logger.error("Error parsing qualified segments from response", e); + return null; + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonParser.java new file mode 100644 index 000000000..fcae748a4 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonParser.java @@ -0,0 +1,63 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser.impl; + +import com.optimizely.ab.odp.parser.ResponseJsonParser; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +public class JsonParser implements ResponseJsonParser { + private static final Logger logger = LoggerFactory.getLogger(JsonParser.class); + + @Override + public List parseQualifiedSegments(String responseJson) { + List parsedSegments = new ArrayList<>(); + try { + JSONObject root = new JSONObject(responseJson); + + if (root.has("errors")) { + JSONArray errors = root.getJSONArray("errors"); + StringBuilder logMessage = new StringBuilder(); + for (int i = 0; i < errors.length(); i++) { + if (i > 0) { + logMessage.append(", "); + } + logMessage.append(errors.getJSONObject(i).getString("message")); + } + logger.error(logMessage.toString()); + return null; + } + + JSONArray edges = root.getJSONObject("data").getJSONObject("customer").getJSONObject("audiences").getJSONArray("edges"); + for (int i = 0; i < edges.length(); i++) { + JSONObject node = edges.getJSONObject(i).getJSONObject("node"); + if (node.has("state") && node.getString("state").equals("qualified")) { + parsedSegments.add(node.getString("name")); + } + } + return parsedSegments; + } catch (JSONException e) { + logger.error("Error parsing qualified segments from response", e); + return null; + } + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonSimpleParser.java b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonSimpleParser.java new file mode 100644 index 000000000..1bee81b0a --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/parser/impl/JsonSimpleParser.java @@ -0,0 +1,66 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser.impl; + +import com.optimizely.ab.odp.parser.ResponseJsonParser; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.parser.JSONParser; +import org.json.simple.parser.ParseException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +public class JsonSimpleParser implements ResponseJsonParser { + private static final Logger logger = LoggerFactory.getLogger(JsonSimpleParser.class); + + @Override + public List parseQualifiedSegments(String responseJson) { + List parsedSegments = new ArrayList<>(); + JSONParser parser = new JSONParser(); + JSONObject root = null; + try { + root = (JSONObject) parser.parse(responseJson); + + if (root.containsKey("errors")) { + JSONArray errors = (JSONArray) root.get("errors"); + StringBuilder logMessage = new StringBuilder(); + for (int i = 0; i < errors.size(); i++) { + if (i > 0) { + logMessage.append(", "); + } + logMessage.append((String)((JSONObject) errors.get(i)).get("message")); + } + logger.error(logMessage.toString()); + return null; + } + + JSONArray edges = (JSONArray)((JSONObject)((JSONObject)(((JSONObject) root.get("data"))).get("customer")).get("audiences")).get("edges"); + for (int i = 0; i < edges.size(); i++) { + JSONObject node = (JSONObject) ((JSONObject) edges.get(i)).get("node"); + if (node.containsKey("state") && (node.get("state")).equals("qualified")) { + parsedSegments.add((String) node.get("name")); + } + } + return parsedSegments; + } catch (ParseException | NullPointerException e) { + logger.error("Error parsing qualified segments from response", e); + return null; + } + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/internal/JsonParserProviderTest.java b/core-api/src/test/java/com/optimizely/ab/internal/JsonParserProviderTest.java new file mode 100644 index 000000000..a65e9b6f5 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/internal/JsonParserProviderTest.java @@ -0,0 +1,46 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.*; + +public class JsonParserProviderTest { + @Before + @After + public void clearParserSystemProperty() { + PropertyUtils.clear("default_parser"); + } + + @Test + public void getGsonParserProviderWhenNoDefaultIsSet() { + assertEquals(JsonParserProvider.GSON_CONFIG_PARSER, JsonParserProvider.getDefaultParser()); + } + + @Test + public void getCorrectParserProviderWhenValidDefaultIsProvided() { + PropertyUtils.set("default_parser", "JSON_CONFIG_PARSER"); + assertEquals(JsonParserProvider.JSON_CONFIG_PARSER, JsonParserProvider.getDefaultParser()); + } + + @Test + public void getGsonParserWhenProvidedDefaultParserDoesNotExist() { + PropertyUtils.set("default_parser", "GARBAGE_VALUE"); + assertEquals(JsonParserProvider.GSON_CONFIG_PARSER, JsonParserProvider.getDefaultParser()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactoryTest.java b/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactoryTest.java new file mode 100644 index 000000000..f5d8e8c89 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserFactoryTest.java @@ -0,0 +1,51 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser; + +import com.optimizely.ab.internal.JsonParserProvider; +import com.optimizely.ab.internal.PropertyUtils; +import com.optimizely.ab.odp.parser.impl.GsonParser; +import com.optimizely.ab.odp.parser.impl.JsonParser; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class ResponseJsonParserFactoryTest { + @Before + @After + public void clearParserSystemProperty() { + PropertyUtils.clear("default_parser"); + } + + @Test + public void getGsonParserWhenNoDefaultIsSet() { + assertEquals(GsonParser.class, ResponseJsonParserFactory.getParser().getClass()); + } + + @Test + public void getCorrectParserWhenValidDefaultIsProvided() { + PropertyUtils.set("default_parser", "JSON_CONFIG_PARSER"); + assertEquals(JsonParser.class, ResponseJsonParserFactory.getParser().getClass()); + } + + @Test + public void getGsonParserWhenGivenDefaultParserDoesNotExist() { + PropertyUtils.set("default_parser", "GARBAGE_VALUE"); + assertEquals(GsonParser.class, ResponseJsonParserFactory.getParser().getClass()); + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserTest.java b/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserTest.java new file mode 100644 index 000000000..1acd5cf15 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/parser/ResponseJsonParserTest.java @@ -0,0 +1,92 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp.parser; + +import ch.qos.logback.classic.Level; +import com.optimizely.ab.internal.LogbackVerifier; +import static junit.framework.TestCase.assertEquals; + +import com.optimizely.ab.odp.parser.impl.*; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import java.util.Arrays; +import java.util.List; + +@RunWith(Parameterized.class) +public class ResponseJsonParserTest { + private final ResponseJsonParser jsonParser; + + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + public ResponseJsonParserTest(ResponseJsonParser jsonParser) { + super(); + this.jsonParser = jsonParser; + } + + @Parameterized.Parameters + public static List input() { + return Arrays.asList(new GsonParser(), new JsonParser(), new JsonSimpleParser(), new JacksonParser()); + } + + @Test + public void returnSegmentsListWhenResponseIsCorrect() { + String responseToParse = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"has_email\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"has_email_opted_in\",\"state\":\"qualified\"}}]}}}}"; + List parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + assertEquals(Arrays.asList("has_email", "has_email_opted_in"), parsedSegments); + } + + @Test + public void excludeSegmentsWhenStateNotQualified() { + String responseToParse = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"has_email\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"has_email_opted_in\",\"state\":\"not_qualified\"}}]}}}}"; + List parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + assertEquals(Arrays.asList("has_email"), parsedSegments); + } + + @Test + public void returnEmptyListWhenResponseHasEmptyArray() { + String responseToParse = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[]}}}}"; + List parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + assertEquals(Arrays.asList(), parsedSegments); + } + + @Test + public void returnNullWhenJsonFormatIsValidButUnexpectedData() { + String responseToParse = "{\"data\"\"consumer\":{\"randomKey\":{\"edges\":[{\"node\":{\"name\":\"has_email\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"has_email_opted_in\",\"state\":\"qualified\"}}]}}}}"; + List parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + logbackVerifier.expectMessage(Level.ERROR, "Error parsing qualified segments from response"); + assertEquals(null, parsedSegments); + } + + @Test + public void returnNullWhenJsonIsMalformed() { + String responseToParse = "{\"data\"\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"has_email\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"has_email_opted_in\",\"state\":\"qualified\"}}]}}}}"; + List parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + logbackVerifier.expectMessage(Level.ERROR, "Error parsing qualified segments from response"); + assertEquals(null, parsedSegments); + } + + @Test + public void returnNullAndLogCorrectErrorWhenErrorResponseIsReturned() { + String responseToParse = "{\"errors\":[{\"message\":\"Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = wrong_id\",\"locations\":[{\"line\":2,\"column\":3}],\"path\":[\"customer\"],\"extensions\":{\"classification\":\"InvalidIdentifierException\"}}],\"data\":{\"customer\":null}}"; + List parsedSegments = jsonParser.parseQualifiedSegments(responseToParse); + logbackVerifier.expectMessage(Level.ERROR, "Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = wrong_id"); + assertEquals(null, parsedSegments); + } +} diff --git a/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java new file mode 100644 index 000000000..f0703dcb0 --- /dev/null +++ b/core-httpclient-impl/src/main/java/com/optimizely/ab/odp/DefaultODPApiManager.java @@ -0,0 +1,179 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import com.optimizely.ab.OptimizelyHttpClient; +import com.optimizely.ab.annotations.VisibleForTesting; +import org.apache.http.HttpStatus; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.util.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.util.List; + +public class DefaultODPApiManager implements ODPApiManager { + private static final Logger logger = LoggerFactory.getLogger(DefaultODPApiManager.class); + + private final OptimizelyHttpClient httpClient; + + public DefaultODPApiManager() { + this(OptimizelyHttpClient.builder().build()); + } + + @VisibleForTesting + DefaultODPApiManager(OptimizelyHttpClient httpClient) { + this.httpClient = httpClient; + } + + @VisibleForTesting + String getSegmentsStringForRequest(List segmentsList) { + StringBuilder segmentsString = new StringBuilder(); + for (int i = 0; i < segmentsList.size(); i++) { + if (i > 0) { + segmentsString.append(", "); + } + segmentsString.append("\\\"").append(segmentsList.get(i)).append("\\\""); + } + return segmentsString.toString(); + } + + // ODP GraphQL API + // - https://api.zaius.com/v3/graphql + // - test ODP public API key = "W4WzcEs-ABgXorzY7h1LCQ" + /* + + [GraphQL Request] + + // fetch info with fs_user_id for ["has_email", "has_email_opted_in", "push_on_sale"] segments + curl -i -H 'Content-Type: application/json' -H 'x-api-key: W4WzcEs-ABgXorzY7h1LCQ' -X POST -d '{"query":"query {customer(fs_user_id: \"tester-101\") {audiences(subset:[\"has_email\",\"has_email_opted_in\",\"push_on_sale\"]) {edges {node {name state}}}}}"}' https://api.zaius.com/v3/graphql + // fetch info with vuid for ["has_email", "has_email_opted_in", "push_on_sale"] segments + curl -i -H 'Content-Type: application/json' -H 'x-api-key: W4WzcEs-ABgXorzY7h1LCQ' -X POST -d '{"query":"query {customer(vuid: \"d66a9d81923d4d2f99d8f64338976322\") {audiences(subset:[\"has_email\",\"has_email_opted_in\",\"push_on_sale\"]) {edges {node {name state}}}}}"}' https://api.zaius.com/v3/graphql + query MyQuery { + customer(vuid: "d66a9d81923d4d2f99d8f64338976322") { + audiences(subset:["has_email","has_email_opted_in","push_on_sale"]) { + edges { + node { + name + state + } + } + } + } + } + [GraphQL Response] + + { + "data": { + "customer": { + "audiences": { + "edges": [ + { + "node": { + "name": "has_email", + "state": "qualified", + } + }, + { + "node": { + "name": "has_email_opted_in", + "state": "qualified", + } + }, + ... + ] + } + } + } + } + + [GraphQL Error Response] + { + "errors": [ + { + "message": "Exception while fetching data (/customer) : java.lang.RuntimeException: could not resolve _fs_user_id = asdsdaddddd", + "locations": [ + { + "line": 2, + "column": 3 + } + ], + "path": [ + "customer" + ], + "extensions": { + "classification": "InvalidIdentifierException" + } + } + ], + "data": { + "customer": null + } + } + */ + @Override + public String fetchQualifiedSegments(String apiKey, String apiEndpoint, String userKey, String userValue, List segmentsToCheck) { + HttpPost request = new HttpPost(apiEndpoint); + String segmentsString = getSegmentsStringForRequest(segmentsToCheck); + String requestPayload = String.format("{\"query\": \"query {customer(%s: \\\"%s\\\") {audiences(subset: [%s]) {edges {node {name state}}}}}\"}", userKey, userValue, segmentsString); + try { + request.setEntity(new StringEntity(requestPayload)); + } catch (UnsupportedEncodingException e) { + logger.warn("Error encoding request payload", e); + } + request.setHeader("x-api-key", apiKey); + request.setHeader("content-type", "application/json"); + + CloseableHttpResponse response = null; + try { + response = httpClient.execute(request); + } catch (IOException e) { + logger.error("Error retrieving response from ODP service", e); + return null; + } + + if (response.getStatusLine().getStatusCode() >= 400) { + StatusLine statusLine = response.getStatusLine(); + logger.error(String.format("Unexpected response from ODP server, Response code: %d, %s", statusLine.getStatusCode(), statusLine.getReasonPhrase())); + closeHttpResponse(response); + return null; + } + + try { + return EntityUtils.toString(response.getEntity()); + } catch (IOException e) { + logger.error("Error converting ODP segments response to string", e); + } finally { + closeHttpResponse(response); + } + return null; + } + + private static void closeHttpResponse(CloseableHttpResponse response) { + if (response != null) { + try { + response.close(); + } catch (IOException e) { + logger.warn(e.getLocalizedMessage()); + } + } + } +} diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java new file mode 100644 index 000000000..25154e97d --- /dev/null +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/internal/LogbackVerifier.java @@ -0,0 +1,168 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.internal; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.core.AppenderBase; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.slf4j.LoggerFactory; + +import java.util.LinkedList; +import java.util.List; +import java.util.ListIterator; + +import static org.junit.Assert.fail; + +/** + * TODO As a usability improvement we should require expected messages be added after the message are expected to be + * logged. This will allow us to map the failure immediately back to the test line number as opposed to the async + * validation now that happens at the end of each individual test. + * + * From http://techblog.kenshoo.com/2013/08/junit-rule-for-verifying-logback-logging.html + */ +public class LogbackVerifier implements TestRule { + + private List expectedEvents = new LinkedList(); + + private CaptureAppender appender; + + @Override + public Statement apply(final Statement base, Description description) { + return new Statement() { + @Override + public void evaluate() throws Throwable { + before(); + try { + base.evaluate(); + verify(); + } finally { + after(); + } + } + }; + } + + public void expectMessage(Level level) { + expectMessage(level, ""); + } + + public void expectMessage(Level level, String msg) { + expectMessage(level, msg, (Class) null); + } + + public void expectMessage(Level level, String msg, Class throwableClass) { + expectMessage(level, msg, null, 1); + } + + public void expectMessage(Level level, String msg, int times) { + expectMessage(level, msg, null, times); + } + + public void expectMessage(Level level, + String msg, + Class throwableClass, + int times) { + for (int i = 0; i < times; i++) { + expectedEvents.add(new ExpectedLogEvent(level, msg, throwableClass)); + } + } + + private void before() { + appender = new CaptureAppender(); + appender.setName("MOCK"); + appender.start(); + ((Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME)).addAppender(appender); + } + + private void verify() throws Throwable { + ListIterator actualIterator = appender.getEvents().listIterator(); + + for (final ExpectedLogEvent expectedEvent : expectedEvents) { + boolean found = false; + while (actualIterator.hasNext()) { + ILoggingEvent actual = actualIterator.next(); + + if (expectedEvent.matches(actual)) { + found = true; + break; + } + } + + if (!found) { + fail(expectedEvent.toString()); + } + } + } + + private void after() { + ((Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME)).detachAppender(appender); + } + + private static class CaptureAppender extends AppenderBase { + + List actualLoggingEvent = new LinkedList<>(); + + @Override + protected void append(ILoggingEvent eventObject) { + actualLoggingEvent.add(eventObject); + } + + public List getEvents() { + return actualLoggingEvent; + } + } + + private final static class ExpectedLogEvent { + private final String message; + private final Level level; + private final Class throwableClass; + + private ExpectedLogEvent(Level level, + String message, + Class throwableClass) { + this.message = message; + this.level = level; + this.throwableClass = throwableClass; + } + + private boolean matches(ILoggingEvent actual) { + boolean match = actual.getFormattedMessage().contains(message); + match &= actual.getLevel().equals(level); + match &= matchThrowables(actual); + return match; + } + + private boolean matchThrowables(ILoggingEvent actual) { + IThrowableProxy eventProxy = actual.getThrowableProxy(); + return throwableClass == null || eventProxy != null && throwableClass.getName().equals(eventProxy.getClassName()); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("ExpectedLogEvent{"); + sb.append("level=").append(level); + sb.append(", message='").append(message).append('\''); + sb.append(", throwableClass=").append(throwableClass); + sb.append('}'); + return sb.toString(); + } + } +} diff --git a/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java b/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java new file mode 100644 index 000000000..c81af2dce --- /dev/null +++ b/core-httpclient-impl/src/test/java/com/optimizely/ab/odp/DefaultODPApiManagerTest.java @@ -0,0 +1,116 @@ +/** + * Copyright 2022, Optimizely Inc. and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.odp; + +import ch.qos.logback.classic.Level; +import com.optimizely.ab.OptimizelyHttpClient; +import com.optimizely.ab.internal.LogbackVerifier; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.util.EntityUtils; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.util.ArrayList; +import java.util.Arrays; + +import static org.junit.Assert.*; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.*; + +public class DefaultODPApiManagerTest { + private static final String validResponse = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"has_email\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"has_email_opted_in\",\"state\":\"qualified\"}}]}}}}"; + + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + OptimizelyHttpClient mockHttpClient; + + @Before + public void setUp() throws Exception { + setupHttpClient(200); + } + + private void setupHttpClient(int statusCode) throws Exception { + mockHttpClient = mock(OptimizelyHttpClient.class); + CloseableHttpResponse httpResponse = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + + when(statusLine.getStatusCode()).thenReturn(statusCode); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(httpResponse.getEntity()).thenReturn(new StringEntity(validResponse)); + + when(mockHttpClient.execute(any(HttpPost.class))) + .thenReturn(httpResponse); + } + + @Test + public void generateCorrectSegmentsStringWhenListHasOneItem() { + DefaultODPApiManager apiManager = new DefaultODPApiManager(); + String expected = "\\\"only_segment\\\""; + String actual = apiManager.getSegmentsStringForRequest(Arrays.asList("only_segment")); + assertEquals(expected, actual); + } + + @Test + public void generateCorrectSegmentsStringWhenListHasMultipleItems() { + DefaultODPApiManager apiManager = new DefaultODPApiManager(); + String expected = "\\\"segment_1\\\", \\\"segment_2\\\", \\\"segment_3\\\""; + String actual = apiManager.getSegmentsStringForRequest(Arrays.asList("segment_1", "segment_2", "segment_3")); + assertEquals(expected, actual); + } + + @Test + public void generateEmptyStringWhenGivenListIsEmpty() { + DefaultODPApiManager apiManager = new DefaultODPApiManager(); + String actual = apiManager.getSegmentsStringForRequest(new ArrayList<>()); + assertEquals("", actual); + } + + @Test + public void generateCorrectRequestBody() throws Exception { + ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); + apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", Arrays.asList("segment_1", "segment_2")); + verify(mockHttpClient, times(1)).execute(any(HttpPost.class)); + + String expectedResponse = "{\"query\": \"query {customer(fs_user_id: \\\"test_user\\\") {audiences(subset: [\\\"segment_1\\\", \\\"segment_2\\\"]) {edges {node {name state}}}}}\"}"; + ArgumentCaptor request = ArgumentCaptor.forClass(HttpPost.class); + verify(mockHttpClient).execute(request.capture()); + assertEquals(expectedResponse, EntityUtils.toString(request.getValue().getEntity())); + } + + @Test + public void returnResponseStringWhenStatusIs200() throws Exception { + ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); + String responseString = apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", Arrays.asList("segment_1", "segment_2")); + verify(mockHttpClient, times(1)).execute(any(HttpPost.class)); + assertEquals(validResponse, responseString); + } + + @Test + public void returnNullWhenStatusIsNot200AndLogError() throws Exception { + setupHttpClient(500); + ODPApiManager apiManager = new DefaultODPApiManager(mockHttpClient); + String responseString = apiManager.fetchQualifiedSegments("key", "endPoint", "fs_user_id", "test_user", Arrays.asList("segment_1", "segment_2")); + verify(mockHttpClient, times(1)).execute(any(HttpPost.class)); + logbackVerifier.expectMessage(Level.ERROR, "Unexpected response from ODP server, Response code: 500, null"); + assertEquals(null, responseString); + } +}