-
Notifications
You must be signed in to change notification settings - Fork 32
feat: Add ODP GraphQL Api Interface #481
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
6034617
899fa88
b14562e
a7c66fc
889957c
f793afc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,12 @@ | ||
package com.optimizely.ab.odp; | ||
|
||
import org.apache.http.HttpResponse; | ||
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.HttpClient; | ||
import org.apache.http.client.methods.CloseableHttpResponse; | ||
import org.apache.http.client.methods.HttpPost; | ||
import org.apache.http.entity.StringEntity; | ||
import org.apache.http.impl.client.HttpClientBuilder; | ||
import org.apache.http.util.EntityUtils; | ||
import org.slf4j.Logger; | ||
import org.slf4j.LoggerFactory; | ||
|
@@ -18,7 +18,19 @@ | |
public class DefaultODPApiManager implements ODPApiManager { | ||
private static final Logger logger = LoggerFactory.getLogger(DefaultODPApiManager.class); | ||
|
||
private String getSegmentsStringForRequest(List<String> segmentsList) { | ||
private final OptimizelyHttpClient httpClient; | ||
|
||
public DefaultODPApiManager() { | ||
this(OptimizelyHttpClient.builder().build()); | ||
} | ||
|
||
@VisibleForTesting | ||
DefaultODPApiManager(OptimizelyHttpClient httpClient) { | ||
this.httpClient = httpClient; | ||
} | ||
|
||
@VisibleForTesting | ||
String getSegmentsStringForRequest(List<String> segmentsList) { | ||
StringBuilder segmentsString = new StringBuilder(); | ||
for (int i = 0; i < segmentsList.size(); i++) { | ||
if (i > 0) { | ||
|
@@ -107,19 +119,17 @@ public String fetchQualifiedSegments(String apiKey, String apiEndpoint, String u | |
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"); | ||
HttpClient client = HttpClientBuilder.create().build(); | ||
|
||
HttpResponse response = null; | ||
CloseableHttpResponse response = null; | ||
try { | ||
response = client.execute(request); | ||
response = httpClient.execute(request); | ||
} catch (IOException e) { | ||
logger.error("Error retrieving response from ODP service", e); | ||
return null; | ||
|
@@ -128,14 +138,27 @@ public String fetchQualifiedSegments(String apiKey, String apiEndpoint, String u | |
if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about more generous error checking like ">= 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()); | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<ExpectedLogEvent> expectedEvents = new LinkedList<ExpectedLogEvent>(); | ||
|
||
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<? extends Throwable>) null); | ||
} | ||
|
||
public void expectMessage(Level level, String msg, Class<? extends Throwable> 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<? extends Throwable> 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<ILoggingEvent> 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<ILoggingEvent> { | ||
|
||
List<ILoggingEvent> actualLoggingEvent = new LinkedList<>(); | ||
|
||
@Override | ||
protected void append(ILoggingEvent eventObject) { | ||
actualLoggingEvent.add(eventObject); | ||
} | ||
|
||
public List<ILoggingEvent> getEvents() { | ||
return actualLoggingEvent; | ||
} | ||
} | ||
|
||
private final static class ExpectedLogEvent { | ||
private final String message; | ||
private final Level level; | ||
private final Class<? extends Throwable> throwableClass; | ||
|
||
private ExpectedLogEvent(Level level, | ||
String message, | ||
Class<? extends Throwable> 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(); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
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<HttpPost> 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); | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about the test for network issues, such as internet is down, where status code is not returned. Request is not reaching the server and bounces off? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure about the plan to feed endpoint, but needs "/v3/graphql" appended for graphql to the shred apiEndpoint.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see this as the lowest level method that will make the actual call that is why it's getting the full endPoint. Users might provide their own implementation so it made more sense for it to expect the full endPoint. My plan is to build this endpoint using the host and path in the
ODPSegmentManager
and pass it to this one. What do you think?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's what I thought. If we plan to support custom endpoints, this makes sense. Let's move on and review again at the top level.
One thing we also should consider is that if clients want to implement their own segment services (replacing all our default ODPManager), they can determine segments using their own solution and directly feed the "qualifedSegments" into OptimizelyUserContext, instead of injecting own ODPManager into our SDKs.