Skip to content

Commit

Permalink
caching improvements
Browse files Browse the repository at this point in the history
  • Loading branch information
dili91 committed Jan 9, 2025
1 parent fd9a36c commit e3b591d
Show file tree
Hide file tree
Showing 10 changed files with 147 additions and 120 deletions.
10 changes: 7 additions & 3 deletions src/main/java/com/truelayer/java/TrueLayerClientBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ public TrueLayerClientBuilder withCredentialsCaching() {

/**
* Utility to enable a custom cache for Oauth credentials.
* @param credentialsCache the custom cache implementation
* @return the instance of the client builder used
*/
public TrueLayerClientBuilder withCredentialsCaching(ICredentialsCache credentialsCache) {
Expand Down Expand Up @@ -223,7 +224,7 @@ public TrueLayerClient build() {
throw new TrueLayerException("client credentials must be set");
}

if (inMemoryCredentialsCachingEnabled && isNotEmpty(customCredentialsCachingImplementation)){
if (inMemoryCredentialsCachingEnabled && isNotEmpty(customCredentialsCachingImplementation)) {
throw new TrueLayerException("Invalid caching configuration");
}

Expand Down Expand Up @@ -254,7 +255,10 @@ public TrueLayerClient build() {
// We're building a client which has the authentication handler and the options to cache the token.
// this one represents the baseline for the client used for Signup+ and Payments
OkHttpClient authenticatedApiClient = httpClientFactory.buildAuthenticatedApiClient(
authServerApiHttpClient, authenticationHandler, getCredentialsCacheImplementation());
clientCredentials.clientId,
authServerApiHttpClient,
authenticationHandler,
getCredentialsCacheImplementation());
ISignupPlusApi signupPlusApi = RetrofitFactory.build(authenticatedApiClient, environment.getPaymentsApiUri())
.create(ISignupPlusApi.class);
SignupPlusHandler.SignupPlusHandlerBuilder signupPlusHandlerBuilder =
Expand Down Expand Up @@ -347,7 +351,7 @@ private boolean customScopesPresent() {

private ICredentialsCache getCredentialsCacheImplementation() {
if (this.inMemoryCredentialsCachingEnabled) {
return new InMemoryCredentialsCache(this.clientCredentials.clientId(), Clock.systemUTC());
return new InMemoryCredentialsCache(Clock.systemUTC());
}

if (isNotEmpty(this.customCredentialsCachingImplementation)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ public OkHttpClient buildAuthServerApiClient(OkHttpClient baseHttpClient, Client
}

public OkHttpClient buildAuthenticatedApiClient(
String clientId,
OkHttpClient authServerApiClient,
IAuthenticationHandler authenticationHandler,
ICredentialsCache credentialsCache) {
Expand All @@ -118,7 +119,7 @@ public OkHttpClient buildAuthenticatedApiClient(
OkHttpClient.Builder authenticatedApiClientBuilder = authServerApiClient.newBuilder();

AccessTokenManager.AccessTokenManagerBuilder accessTokenManagerBuilder =
AccessTokenManager.builder().authenticationHandler(authenticationHandler);
AccessTokenManager.builder().clientId(clientId).authenticationHandler(authenticationHandler);

// setup credentials caching if required
if (isNotEmpty(credentialsCache)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.truelayer.java.auth.IAuthenticationHandler;
import com.truelayer.java.auth.entities.AccessToken;
import com.truelayer.java.entities.RequestScopes;
import com.truelayer.java.http.auth.cache.CredentialsCacheHelper;
import com.truelayer.java.http.auth.cache.ICredentialsCache;
import com.truelayer.java.http.entities.ApiResponse;
import java.util.Optional;
Expand All @@ -13,6 +14,8 @@
@Builder
public class AccessTokenManager implements IAccessTokenManager {

private final String clientId;

private final IAuthenticationHandler authenticationHandler;

private final ICredentialsCache credentialsCache;
Expand All @@ -23,10 +26,12 @@ private Optional<ICredentialsCache> getCredentialsCache() {

@Override
public AccessToken getToken(RequestScopes scopes) {
String cacheKey = CredentialsCacheHelper.buildKey(clientId, scopes);

if (getCredentialsCache().isPresent()) {
return getCredentialsCache().get().getToken(scopes).orElseGet(() -> {
return getCredentialsCache().get().getToken(cacheKey).orElseGet(() -> {
AccessToken token = tryGetToken(scopes);
credentialsCache.storeToken(scopes, token);
credentialsCache.storeToken(cacheKey, token);
return token;
});
}
Expand All @@ -37,7 +42,8 @@ public AccessToken getToken(RequestScopes scopes) {
@Override
@Synchronized
public void invalidateToken(RequestScopes scopes) {
getCredentialsCache().ifPresent(iCredentialsCache -> iCredentialsCache.clearToken(scopes));
String cacheKey = CredentialsCacheHelper.buildKey(clientId, scopes);
getCredentialsCache().ifPresent(iCredentialsCache -> iCredentialsCache.clearToken(cacheKey));
}

private AccessToken tryGetToken(RequestScopes scopes) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.truelayer.java.http.auth.cache;

import com.truelayer.java.entities.RequestScopes;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class CredentialsCacheHelper {
private static final String CACHE_KEY_PREFIX = "tl-auth-token";

public static String buildKey(String clientId, RequestScopes requestScopes) {
List<String> scopes = new ArrayList<>(requestScopes.getScopes());
Collections.sort(scopes);
return MessageFormat.format("{0}:{1}:{2}", CACHE_KEY_PREFIX, clientId, scopes.hashCode());
}
}
Original file line number Diff line number Diff line change
@@ -1,28 +1,13 @@
package com.truelayer.java.http.auth.cache;

import com.truelayer.java.auth.entities.AccessToken;
import com.truelayer.java.entities.RequestScopes;
import java.util.Optional;

public interface ICredentialsCache {

/**
* Gets the cached access token for the given request scopes.
* @param scopes the requested scopes
* @return an optional access token. If the token is expired an empty optional is returned
*/
Optional<AccessToken> getToken(RequestScopes scopes);
Optional<AccessToken> getToken(String key);

/**
* Stores an access token in cache for the given request scopes.
* @param token the new token to store
* @param scopes the requested scopes
*/
void storeToken(RequestScopes scopes, AccessToken token);
void storeToken(String key, AccessToken token);

/**
* Remove the entry in the cache for the given request scopes.
* @param scopes the requested scopes
*/
void clearToken(RequestScopes scopes);
void clearToken(String key);
}
Original file line number Diff line number Diff line change
@@ -1,45 +1,25 @@
package com.truelayer.java.http.auth.cache;

import com.truelayer.java.auth.entities.AccessToken;
import com.truelayer.java.entities.RequestScopes;
import java.text.MessageFormat;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.*;
import lombok.Getter;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import lombok.RequiredArgsConstructor;

/**
* Default in-memory cache implementation.
* Class constructor accepts a Clock instance for improved testing purposes. Please note that this is not a monotonic implementation.
* @see <a href="https://www.thoughtworks.com/insights/blog/test-driven-development-best-thing-has-happened-software-design">this article</a> for more context,
*/
@RequiredArgsConstructor
public class InMemoryCredentialsCache implements ICredentialsCache {

/**
* internal state
*/
private final Map<String, AccessTokenRecord> tokenRecords = new HashMap<>();

private final Clock clock;

private final String clientId;

public static final String CACHE_KEY_PREFIX = "tl-auth-token";

/**
* Constructor for this class.
* @param clock clock instance
* internal state
*/
public InMemoryCredentialsCache(String clientId, Clock clock) {
this.clientId = clientId;
this.clock = clock;
}
private final Map<String, AccessTokenRecord> tokenRecords = new ConcurrentHashMap<>();

@Override
public Optional<AccessToken> getToken(RequestScopes scopes) {
String key = computeCacheKey(scopes);

public Optional<AccessToken> getToken(String key) {
AccessTokenRecord tokenRecord = tokenRecords.get(key);
if (tokenRecord == null || !LocalDateTime.now(clock).isBefore(tokenRecord.expiresAt)) {
return Optional.empty();
Expand All @@ -49,28 +29,21 @@ public Optional<AccessToken> getToken(RequestScopes scopes) {
}

@Override
public void storeToken(RequestScopes scopes, AccessToken token) {
public void storeToken(String key, AccessToken token) {
AccessTokenRecord tokenRecord =
new AccessTokenRecord(token, LocalDateTime.now(clock).plusSeconds(token.getExpiresIn()));

tokenRecords.put(computeCacheKey(scopes), tokenRecord);
tokenRecords.put(key, tokenRecord);
}

@Override
public void clearToken(RequestScopes scopes) {
tokenRecords.remove(computeCacheKey(scopes));
public void clearToken(String key) {
tokenRecords.remove(key);
}

@Getter
@RequiredArgsConstructor
public static class AccessTokenRecord {
private final AccessToken token;
private final LocalDateTime expiresAt;
}

private String computeCacheKey(RequestScopes scopes) {
List<String> sortedScopes = new ArrayList<>(scopes.getScopes());
Collections.sort(sortedScopes);
return MessageFormat.format("{0}:{1}:{2}", CACHE_KEY_PREFIX, this.clientId, sortedScopes.hashCode());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,7 @@ public void itShouldBuildAClientWithCustomLoggingAndCaching() {
.clientCredentials(getClientCredentials())
.signingOptions(getSigningOptions())
.withHttpLogs(System.out::println)
.withCredentialsCaching(mock(ICredentialsCache.class))
.withCredentialsCaching();

.withCredentialsCaching(mock(ICredentialsCache.class));
assertDoesNotThrow(sut::build);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import java.net.Proxy;
import java.net.URI;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
Expand Down Expand Up @@ -208,9 +209,10 @@ public void shouldCreateAnAuthenticatedApiClient() {
.clientCredentials(getClientCredentials())
.httpClient(RetrofitFactory.build(baseHttpClient, URI.create("http://localhost")))
.build();
String clientId = UUID.randomUUID().toString();

OkHttpClient authenticatedApiClient =
getOkHttpClientFactory().buildAuthenticatedApiClient(authServerApiClient, authenticationHandler, null);
OkHttpClient authenticatedApiClient = getOkHttpClientFactory()
.buildAuthenticatedApiClient(clientId, authServerApiClient, authenticationHandler, null);

assertNotNull(authenticatedApiClient);
assertTrue(
Expand All @@ -236,10 +238,11 @@ public void shouldCreateAPaymentsApiClient() {
.clientCredentials(getClientCredentials())
.httpClient(RetrofitFactory.build(baseHttpClient, URI.create("http://localhost")))
.build();
String clientId = UUID.randomUUID().toString();
OkHttpClient authApiClient =
getOkHttpClientFactory().buildAuthServerApiClient(baseHttpClient, getClientCredentials());
OkHttpClient authenticatedApiClient =
getOkHttpClientFactory().buildAuthenticatedApiClient(authApiClient, authenticationHandler, null);
OkHttpClient authenticatedApiClient = getOkHttpClientFactory()
.buildAuthenticatedApiClient(clientId, authApiClient, authenticationHandler, null);

OkHttpClient paymentClient =
getOkHttpClientFactory().buildPaymentsApiClient(authenticatedApiClient, TestUtils.getSigningOptions());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
import com.truelayer.java.auth.IAuthenticationHandler;
import com.truelayer.java.auth.entities.AccessToken;
import com.truelayer.java.entities.RequestScopes;
import com.truelayer.java.http.auth.cache.ICredentialsCache;
import com.truelayer.java.http.auth.cache.InMemoryCredentialsCache;
import com.truelayer.java.http.auth.cache.*;
import com.truelayer.java.http.entities.ApiResponse;
import java.util.*;
import java.util.concurrent.CompletableFuture;
Expand All @@ -23,79 +22,88 @@ class AccessTokenManagerTests {
@Test
@DisplayName("It should get a cached token")
public void itShouldGetACachedToken() {
String clientId = UUID.randomUUID().toString();
AccessToken expectedToken = buildAccessToken().getData();
ICredentialsCache cache = mock(InMemoryCredentialsCache.class);
RequestScopes scopes = RequestScopes.builder().scope(PAYMENTS).build();
when(cache.getToken(scopes)).thenReturn(Optional.of(expectedToken));
String cacheKey = CredentialsCacheHelper.buildKey(clientId, scopes);
when(cache.getToken(cacheKey)).thenReturn(Optional.of(expectedToken));
IAuthenticationHandler authenticationHandler = mock(AuthenticationHandler.class);
AccessTokenManager sut = new AccessTokenManager(authenticationHandler, cache);
AccessTokenManager sut = new AccessTokenManager(clientId, authenticationHandler, cache);

AccessToken actualToken = sut.getToken(scopes);

assertEquals(expectedToken, actualToken);
verify(cache, times(1)).getToken(scopes);
verify(cache, times(1)).getToken(cacheKey);
verify(authenticationHandler, never()).getOauthToken(eq(scopes.getScopes()));
}

@Test
@DisplayName("It should get a new token and store it in cache when requested scopes are not cached")
public void itShouldGetAFreshTokenWhenRequestedScopesAreNotCached() {
String clientId = UUID.randomUUID().toString();
AccessToken expectedToken = buildAccessToken().getData();
ICredentialsCache cache = mock(InMemoryCredentialsCache.class);
RequestScopes scopes = RequestScopes.builder().scope(PAYMENTS).build();
when(cache.getToken(scopes)).thenReturn(Optional.empty());
String cacheKey = CredentialsCacheHelper.buildKey(clientId, scopes);
when(cache.getToken(cacheKey)).thenReturn(Optional.empty());
IAuthenticationHandler authenticationHandler = mock(AuthenticationHandler.class);
when(authenticationHandler.getOauthToken(eq(scopes.getScopes())))
.thenReturn(CompletableFuture.completedFuture(
ApiResponse.<AccessToken>builder().data(expectedToken).build()));
AccessTokenManager sut = new AccessTokenManager(authenticationHandler, cache);
AccessTokenManager sut = new AccessTokenManager(clientId, authenticationHandler, cache);

AccessToken actualToken = sut.getToken(scopes);

assertEquals(expectedToken, actualToken);
verify(cache, times(1)).getToken(scopes);
verify(cache, times(1)).getToken(cacheKey);
verify(authenticationHandler, times(1)).getOauthToken(eq(scopes.getScopes()));
verify(cache, times(1)).storeToken(eq(scopes), eq(expectedToken));
verify(cache, times(1)).storeToken(eq(cacheKey), eq(expectedToken));
}

@Test
@DisplayName("It should get a new token and store it in cache when different requested scopes are cached")
public void itShouldGetAFreshTokenWhenDifferentRequestedScopesAreCached() {
String clientId = UUID.randomUUID().toString();
AccessToken expectedToken = buildAccessToken().getData();
AccessToken storedPaymentsToken = buildAccessToken().getData();
ICredentialsCache cache = mock(InMemoryCredentialsCache.class);
RequestScopes paymentsScopes = RequestScopes.builder().scope(PAYMENTS).build();
RequestScopes vrpScopes =
RequestScopes.builder().scope(RECURRING_PAYMENTS_SWEEPING).build();
when(cache.getToken(paymentsScopes)).thenReturn(Optional.of(storedPaymentsToken));
when(cache.getToken(vrpScopes)).thenReturn(Optional.empty());
String paymentsCacheKey = CredentialsCacheHelper.buildKey(clientId, paymentsScopes);
String vrpCacheKey = CredentialsCacheHelper.buildKey(clientId, vrpScopes);
when(cache.getToken(paymentsCacheKey)).thenReturn(Optional.of(storedPaymentsToken));
when(cache.getToken(vrpCacheKey)).thenReturn(Optional.empty());
IAuthenticationHandler authenticationHandler = mock(AuthenticationHandler.class);
when(authenticationHandler.getOauthToken(eq(vrpScopes.getScopes())))
.thenReturn(CompletableFuture.completedFuture(
ApiResponse.<AccessToken>builder().data(expectedToken).build()));
AccessTokenManager sut = new AccessTokenManager(authenticationHandler, cache);
AccessTokenManager sut = new AccessTokenManager(clientId, authenticationHandler, cache);

AccessToken actualToken = sut.getToken(vrpScopes);

assertEquals(expectedToken, actualToken);
verify(cache, times(1)).getToken(vrpScopes);
verify(cache, times(1)).getToken(vrpCacheKey);
verify(authenticationHandler, times(1)).getOauthToken(eq(vrpScopes.getScopes()));
verify(cache, times(1)).storeToken(eq(vrpScopes), eq(expectedToken));
verify(cache, times(1)).storeToken(eq(vrpCacheKey), eq(expectedToken));
}

@Test
@DisplayName("It should invalidate an existing token")
public void itShouldInvalidateExistingToken() {
String clientId = UUID.randomUUID().toString();
ICredentialsCache cache = mock(InMemoryCredentialsCache.class);
IAuthenticationHandler authenticationHandler = mock(AuthenticationHandler.class);
AccessTokenManager sut = new AccessTokenManager(authenticationHandler, cache);
AccessTokenManager sut = new AccessTokenManager(clientId, authenticationHandler, cache);
RequestScopes scopes = RequestScopes.builder().scope(PAYMENTS).build();
String cacheKey = CredentialsCacheHelper.buildKey(clientId, scopes);

AccessToken accessToken = buildAccessToken().getData();
cache.storeToken(scopes, accessToken);
cache.storeToken(cacheKey, accessToken);

sut.invalidateToken(scopes);

verify(cache, times(1)).clearToken(scopes);
verify(cache, times(1)).clearToken(cacheKey);
}
}
Loading

0 comments on commit e3b591d

Please sign in to comment.