diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java new file mode 100644 index 000000000..ad8667eb4 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPConfig.java @@ -0,0 +1,74 @@ +/** + * + * Copyright 2022, Optimizely + * + * 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.Collections; +import java.util.List; + +public class ODPConfig { + + private String apiKey; + + private String apiHost; + + private List allSegments; + + public ODPConfig(String apiKey, String apiHost, List allSegments) { + this.apiKey = apiKey; + this.apiHost = apiHost; + this.allSegments = allSegments; + } + + public ODPConfig(String apiKey, String apiHost) { + this(apiKey, apiHost, Collections.emptyList()); + } + + public synchronized Boolean isReady() { + return !( + this.apiKey == null || this.apiKey.isEmpty() + || this.apiHost == null || this.apiHost.isEmpty() + ); + } + + public synchronized Boolean hasSegments() { + return allSegments != null && !allSegments.isEmpty(); + } + + public synchronized void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public synchronized void setApiHost(String apiHost) { + this.apiHost = apiHost; + } + + public synchronized String getApiKey() { + return apiKey; + } + + public synchronized String getApiHost() { + return apiHost; + } + + public synchronized List getAllSegments() { + return allSegments; + } + + public synchronized void setAllSegments(List allSegments) { + this.allSegments = allSegments; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java new file mode 100644 index 000000000..ffda9c19c --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentManager.java @@ -0,0 +1,108 @@ +/** + * + * Copyright 2022, Optimizely + * + * 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.internal.Cache; +import com.optimizely.ab.internal.DefaultLRUCache; +import com.optimizely.ab.odp.parser.ResponseJsonParser; +import com.optimizely.ab.odp.parser.ResponseJsonParserFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.List; + +public class ODPSegmentManager { + + private static final Logger logger = LoggerFactory.getLogger(ODPSegmentManager.class); + + private static final String SEGMENT_URL_PATH = "/v3/graphql"; + + private final ODPApiManager apiManager; + + private final ODPConfig odpConfig; + + private final Cache> segmentsCache; + + public ODPSegmentManager(ODPConfig odpConfig, ODPApiManager apiManager) { + this(odpConfig, apiManager, Cache.DEFAULT_MAX_SIZE, Cache.DEFAULT_TIMEOUT_SECONDS); + } + + public ODPSegmentManager(ODPConfig odpConfig, ODPApiManager apiManager, Cache> cache) { + this.apiManager = apiManager; + this.odpConfig = odpConfig; + this.segmentsCache = cache; + } + + public ODPSegmentManager(ODPConfig odpConfig, ODPApiManager apiManager, Integer cacheSize, Integer cacheTimeoutSeconds) { + this.apiManager = apiManager; + this.odpConfig = odpConfig; + this.segmentsCache = new DefaultLRUCache<>(cacheSize, cacheTimeoutSeconds); + } + + public List getQualifiedSegments(String fsUserId) { + return getQualifiedSegments(ODPUserKey.FS_USER_ID, fsUserId, Collections.emptyList()); + } + public List getQualifiedSegments(String fsUserId, List options) { + return getQualifiedSegments(ODPUserKey.FS_USER_ID, fsUserId, options); + } + + public List getQualifiedSegments(ODPUserKey userKey, String userValue) { + return getQualifiedSegments(userKey, userValue, Collections.emptyList()); + } + + public List getQualifiedSegments(ODPUserKey userKey, String userValue, List options) { + if (!odpConfig.isReady()) { + logger.error("Audience segments fetch failed (ODP is not enabled)"); + return Collections.emptyList(); + } + + if (!odpConfig.hasSegments()) { + logger.debug("No Segments are used in the project, Not Fetching segments. Returning empty list"); + return Collections.emptyList(); + } + + List qualifiedSegments; + String cacheKey = getCacheKey(userKey.getKeyString(), userValue); + + if (options.contains(ODPSegmentOption.RESET_CACHE)) { + segmentsCache.reset(); + } else if (!options.contains(ODPSegmentOption.IGNORE_CACHE)) { + qualifiedSegments = segmentsCache.lookup(cacheKey); + if (qualifiedSegments != null) { + logger.debug("ODP Cache Hit. Returning segments from Cache."); + return qualifiedSegments; + } + } + + logger.debug("ODP Cache Miss. Making a call to ODP Server."); + + ResponseJsonParser parser = ResponseJsonParserFactory.getParser(); + String qualifiedSegmentsResponse = apiManager.fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + SEGMENT_URL_PATH, userKey.getKeyString(), userValue, odpConfig.getAllSegments()); + qualifiedSegments = parser.parseQualifiedSegments(qualifiedSegmentsResponse); + + if (qualifiedSegments != null && !options.contains(ODPSegmentOption.IGNORE_CACHE)) { + segmentsCache.save(cacheKey, qualifiedSegments); + } + + return qualifiedSegments; + } + + private String getCacheKey(String userKey, String userValue) { + return userKey + "-$-" + userValue; + } +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentOption.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentOption.java new file mode 100644 index 000000000..8e2eb901b --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPSegmentOption.java @@ -0,0 +1,25 @@ +/** + * + * Copyright 2022, Optimizely + * + * 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; + +public enum ODPSegmentOption { + + IGNORE_CACHE, + + RESET_CACHE; + +} diff --git a/core-api/src/main/java/com/optimizely/ab/odp/ODPUserKey.java b/core-api/src/main/java/com/optimizely/ab/odp/ODPUserKey.java new file mode 100644 index 000000000..d7cdbb641 --- /dev/null +++ b/core-api/src/main/java/com/optimizely/ab/odp/ODPUserKey.java @@ -0,0 +1,34 @@ +/** + * + * Copyright 2022, Optimizely + * + * 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; + +public enum ODPUserKey { + + VUID("vuid"), + + FS_USER_ID("fs_user_id"); + + private final String keyString; + + ODPUserKey(String keyString) { + this.keyString = keyString; + } + + public String getKeyString() { + return keyString; + } +} diff --git a/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java b/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java new file mode 100644 index 000000000..f784d53d0 --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/odp/ODPSegmentManagerTest.java @@ -0,0 +1,207 @@ +/** + * + * Copyright 2022, Optimizely + * + * 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.internal.Cache; +import com.optimizely.ab.internal.LogbackVerifier; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.Mockito; + +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; +import static org.junit.Assert.*; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class ODPSegmentManagerTest { + + @Rule + public LogbackVerifier logbackVerifier = new LogbackVerifier(); + + @Mock + Cache> mockCache; + + @Mock + ODPApiManager mockApiManager; + + private static final String API_RESPONSE = "{\"data\":{\"customer\":{\"audiences\":{\"edges\":[{\"node\":{\"name\":\"segment1\",\"state\":\"qualified\"}},{\"node\":{\"name\":\"segment2\",\"state\":\"qualified\"}}]}}}}"; + + @Before + public void setup() { + mockCache = mock(Cache.class); + mockApiManager = mock(ODPApiManager.class); + } + + @Test + public void cacheHit() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", Arrays.asList("segment1", "segment2")); + ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + List segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId"); + + // Cache lookup called with correct key + verify(mockCache, times(1)).lookup("fs_user_id-$-testId"); + + // Cache hit! No api call was made to the server. + verify(mockApiManager, times(0)).fetchQualifiedSegments(any(), any(), any(), any(), any()); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.DEBUG, "ODP Cache Hit. Returning segments from Cache."); + + assertEquals(Arrays.asList("segment1-cached", "segment2-cached"), segments); + } + + @Test + public void cacheMiss() { + Mockito.when(mockCache.lookup(any())).thenReturn(null); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anyList())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", Arrays.asList("segment1", "segment2")); + ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + List segments = segmentManager.getQualifiedSegments(ODPUserKey.VUID, "testId"); + + // Cache lookup called with correct key + verify(mockCache, times(1)).lookup("vuid-$-testId"); + + // Cache miss! Make api call and save to cache + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "vuid", "testId", Arrays.asList("segment1", "segment2")); + verify(mockCache, times(1)).save("vuid-$-testId", Arrays.asList("segment1", "segment2")); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.DEBUG, "ODP Cache Miss. Making a call to ODP Server."); + + assertEquals(Arrays.asList("segment1", "segment2"), segments); + } + + @Test + public void ignoreCache() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anyList())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", Arrays.asList("segment1", "segment2")); + ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + List segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", Collections.singletonList(ODPSegmentOption.IGNORE_CACHE)); + + // Cache Ignored! lookup should not be called + verify(mockCache, times(0)).lookup(any()); + + // Cache Ignored! Make API Call but do NOT save because of cacheIgnore + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", Arrays.asList("segment1", "segment2")); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + assertEquals(Arrays.asList("segment1", "segment2"), segments); + } + + @Test + public void resetCache() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anyList())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", Arrays.asList("segment1", "segment2")); + ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + List segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", Collections.singletonList(ODPSegmentOption.RESET_CACHE)); + + // Call reset + verify(mockCache, times(1)).reset(); + + // Cache Reset! lookup should not be called becaues cache would be empty. + verify(mockCache, times(0)).lookup(any()); + + // Cache reset but not Ignored! Make API Call and save to cache + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", Arrays.asList("segment1", "segment2")); + verify(mockCache, times(1)).save("fs_user_id-$-testId", Arrays.asList("segment1", "segment2")); + + assertEquals(Arrays.asList("segment1", "segment2"), segments); + } + + @Test + public void resetAndIgnoreCache() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + Mockito.when(mockApiManager.fetchQualifiedSegments(anyString(), anyString(), anyString(), anyString(), anyList())) + .thenReturn(API_RESPONSE); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", Arrays.asList("segment1", "segment2")); + ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + List segments = segmentManager + .getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId", Arrays.asList(ODPSegmentOption.RESET_CACHE, ODPSegmentOption.IGNORE_CACHE)); + + // Call reset + verify(mockCache, times(1)).reset(); + + verify(mockCache, times(0)).lookup(any()); + + // Cache is also Ignored! Make API Call but do not save + verify(mockApiManager, times(1)) + .fetchQualifiedSegments(odpConfig.getApiKey(), odpConfig.getApiHost() + "/v3/graphql", "fs_user_id", "testId", Arrays.asList("segment1", "segment2")); + verify(mockCache, times(0)).save(any(), any()); + + assertEquals(Arrays.asList("segment1", "segment2"), segments); + } + + @Test + public void odpConfigNotReady() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + + ODPConfig odpConfig = new ODPConfig(null, null, Arrays.asList("segment1", "segment2")); + ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + List segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId"); + + // No further methods should be called. + verify(mockCache, times(0)).lookup("fs_user_id-$-testId"); + verify(mockApiManager, times(0)).fetchQualifiedSegments(any(), any(), any(), any(), any()); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.ERROR, "Audience segments fetch failed (ODP is not enabled)"); + + assertEquals(Collections.emptyList(), segments); + } + + @Test + public void noSegmentsInProject() { + Mockito.when(mockCache.lookup(any())).thenReturn(Arrays.asList("segment1-cached", "segment2-cached")); + + ODPConfig odpConfig = new ODPConfig("testKey", "testHost", null); + ODPSegmentManager segmentManager = new ODPSegmentManager(odpConfig, mockApiManager, mockCache); + List segments = segmentManager.getQualifiedSegments(ODPUserKey.FS_USER_ID, "testId"); + + // No further methods should be called. + verify(mockCache, times(0)).lookup("fs_user_id-$-testId"); + verify(mockApiManager, times(0)).fetchQualifiedSegments(any(), any(), any(), any(), any()); + verify(mockCache, times(0)).save(any(), any()); + verify(mockCache, times(0)).reset(); + + logbackVerifier.expectMessage(Level.DEBUG, "No Segments are used in the project, Not Fetching segments. Returning empty list"); + + assertEquals(Collections.emptyList(), segments); + } +}