8000 feat: Add ODP GraphQL Api Interface by zashraf1985 · Pull Request #481 · optimizely/java-sdk · GitHub
[go: up one dir, main page]

Skip to content

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

Merged
merged 6 commits into from
Aug 2, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Added tests for graphQL API call.
  • Loading branch information
zashraf1985 committed Jul 29, 2022
commit a7c66fc8c340dfb2292eed73103e0868345a1ff9
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;
Expand All @@ -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) {
Expand Down Expand Up @@ -107,19 +119,17 @@ public String fetchQualifiedSegments(String apiKey, String apiEndpoint, String u
HttpPost request = new HttpPost(apiEndpoint);
Copy link
Contributor

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.

Copy link
Contributor Author

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?

Copy link
Contributor

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.

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;
Expand All @@ -128,14 +138,27 @@ public String fetchQualifiedSegments(String apiKey, String apiEndpoint, String u
if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) {
Copy link
Contributor

Choose a reason for hiding this comment

The 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);
}
}
Copy link

Choose a reason for hiding this comment

The 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?

0