diff --git a/README.md b/README.md
index b87f68cb..05638e76 100644
--- a/README.md
+++ b/README.md
@@ -23,14 +23,14 @@ And then add the artifact `incognia-api-client` **or** `incognia-api-client-shad
com.incognia
incognia-api-client
- 3.16.0
+ 3.17.0
```
```xml
com.incognia
incognia-api-client-shaded
- 3.16.0
+ 3.17.0
```
@@ -47,13 +47,13 @@ repositories {
And then add the dependency
```gradle
dependencies {
- implementation 'com.incognia:incognia-api-client:3.16.0'
+ implementation 'com.incognia:incognia-api-client:3.17.0'
}
```
OR
```gradle
dependencies {
- implementation 'com.incognia:incognia-api-client-shaded:3.16.0'
+ implementation 'com.incognia:incognia-api-client-shaded:3.17.0'
}
```
@@ -137,9 +137,45 @@ The implementation is based on the [Incognia API Reference](https://dash.incogni
#### Authentication
-Authentication is done transparently, so you don't need to worry about it.
+Authentication is handled automatically by default, including refreshing expired tokens during API calls.
-If you are curious about how we handle it, you can check the `TokenAwareNetworkingClient` class
+For latency-sensitive services, you can take control of when refresh happens by creating a `ManualRefreshTokenProvider` and passing it through `CustomOptions`. The library does not create background threads for token refresh, so your application stays in full control of scheduling.
+
+```java
+CustomOptions sharedOptions =
+ CustomOptions.builder()
+ .timeoutMillis(5_000L)
+ .maxConnections(10)
+ .keepAliveSeconds(60L)
+ .build();
+
+ManualRefreshTokenProvider tokenProvider =
+ new ManualRefreshTokenProvider("client-id", "client-secret", sharedOptions);
+
+IncogniaAPI api =
+ IncogniaAPI.init(
+ "client-id",
+ "client-secret",
+ sharedOptions.toBuilder()
+ .tokenProvider(tokenProvider)
+ .build());
+
+ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
+executor.scheduleAtFixedRate(
+ () -> {
+ try {
+ Token token = tokenProvider.refresh();
+ System.out.println("token refreshed until " + token.getExpiresAt());
+ } catch (IncogniaException e) {
+ System.out.println("could not refresh token");
+ }
+ },
+ 0,
+ 1,
+ TimeUnit.MINUTES);
+```
+
+If you are curious about how we handle it, you can check the `TokenAwareNetworkingClient` class.
#### Registering Signup
diff --git a/build.gradle b/build.gradle
index bced639a..de327cbc 100644
--- a/build.gradle
+++ b/build.gradle
@@ -8,7 +8,7 @@ plugins {
}
group = "com.incognia"
-version = "3.16.0"
+version = "3.17.0"
task createProjectVersionFile {
def projectVersionDir = "$projectDir/src/main/java/com/incognia/api"
diff --git a/src/main/java/com/incognia/api/IncogniaAPI.java b/src/main/java/com/incognia/api/IncogniaAPI.java
index df950a8a..183faa22 100644
--- a/src/main/java/com/incognia/api/IncogniaAPI.java
+++ b/src/main/java/com/incognia/api/IncogniaAPI.java
@@ -1,6 +1,9 @@
package com.incognia.api;
+import com.incognia.api.clients.AutoRefreshTokenProvider;
+import com.incognia.api.clients.NetworkingClient;
import com.incognia.api.clients.TokenAwareNetworkingClient;
+import com.incognia.api.clients.TokenProvider;
import com.incognia.common.Address;
import com.incognia.common.exceptions.IncogniaAPIException;
import com.incognia.common.exceptions.IncogniaException;
@@ -62,20 +65,22 @@ public class IncogniaAPI {
IncogniaAPI(String clientId, String clientSecret, CustomOptions options, String apiUrl) {
Asserts.assertNotEmpty(clientId, "client id");
Asserts.assertNotEmpty(clientSecret, "client secret");
+ Asserts.assertNotNull(options, "custom options");
Asserts.assertNotEmpty(apiUrl, "api url");
- tokenAwareNetworkingClient =
- new TokenAwareNetworkingClient(
- new OkHttpClient.Builder()
- .callTimeout(options.getTimeoutMillis(), TimeUnit.MILLISECONDS)
- .connectionPool(
- new ConnectionPool(
- options.getMaxConnections(),
- options.getKeepAliveSeconds(),
- TimeUnit.SECONDS))
- .build(),
- apiUrl,
- clientId,
- clientSecret);
+ OkHttpClient httpClient =
+ new OkHttpClient.Builder()
+ .callTimeout(options.getTimeoutMillis(), TimeUnit.MILLISECONDS)
+ .connectionPool(
+ new ConnectionPool(
+ options.getMaxConnections(), options.getKeepAliveSeconds(), TimeUnit.SECONDS))
+ .build();
+ TokenProvider tokenProvider = options.getTokenProvider();
+ if (tokenProvider == null) {
+ tokenProvider =
+ new AutoRefreshTokenProvider(
+ clientId, clientSecret, new NetworkingClient(httpClient, apiUrl));
+ }
+ tokenAwareNetworkingClient = new TokenAwareNetworkingClient(httpClient, apiUrl, tokenProvider);
}
/**
diff --git a/src/main/java/com/incognia/api/clients/AutoRefreshTokenProvider.java b/src/main/java/com/incognia/api/clients/AutoRefreshTokenProvider.java
new file mode 100644
index 00000000..7bcd94e7
--- /dev/null
+++ b/src/main/java/com/incognia/api/clients/AutoRefreshTokenProvider.java
@@ -0,0 +1,46 @@
+package com.incognia.api.clients;
+
+import com.incognia.common.Token;
+import com.incognia.common.exceptions.IncogniaException;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.concurrent.locks.ReentrantLock;
+
+public class AutoRefreshTokenProvider implements TokenProvider {
+ private static final int TOKEN_REFRESH_BEFORE_SECONDS = 10;
+
+ private final ReentrantLock lock = new ReentrantLock();
+ private final TokenRequester tokenRequester;
+ private volatile Token token;
+
+ public AutoRefreshTokenProvider(
+ String clientId, String clientSecret, NetworkingClient networkingClient) {
+ this.tokenRequester = new TokenRequester(clientId, clientSecret, networkingClient);
+ }
+
+ @Override
+ public Token getToken() throws IncogniaException {
+ refreshTokenIfNeeded();
+ return token;
+ }
+
+ private boolean needsRefresh(Token token) {
+ return token == null
+ || Instant.now().until(token.getExpiresAt(), ChronoUnit.SECONDS)
+ <= TOKEN_REFRESH_BEFORE_SECONDS;
+ }
+
+ private void refreshTokenIfNeeded() throws IncogniaException {
+ Token currentToken = token;
+ if (needsRefresh(currentToken)) {
+ lock.lock();
+ try {
+ if (needsRefresh(token)) {
+ token = tokenRequester.requestToken();
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/incognia/api/clients/ManualRefreshTokenProvider.java b/src/main/java/com/incognia/api/clients/ManualRefreshTokenProvider.java
new file mode 100644
index 00000000..428d2eba
--- /dev/null
+++ b/src/main/java/com/incognia/api/clients/ManualRefreshTokenProvider.java
@@ -0,0 +1,72 @@
+package com.incognia.api.clients;
+
+import com.incognia.common.Token;
+import com.incognia.common.exceptions.IncogniaException;
+import com.incognia.common.exceptions.TokenExpiredException;
+import com.incognia.common.exceptions.TokenNotFoundException;
+import com.incognia.common.utils.Asserts;
+import com.incognia.common.utils.CustomOptions;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+import okhttp3.ConnectionPool;
+import okhttp3.OkHttpClient;
+
+public class ManualRefreshTokenProvider implements TokenProvider {
+ private static final String API_URL = "https://api.incognia.com";
+
+ private final ReentrantLock lock = new ReentrantLock();
+ private final TokenRequester tokenRequester;
+ private volatile Token token;
+
+ public ManualRefreshTokenProvider(String clientId, String clientSecret) {
+ this(clientId, clientSecret, CustomOptions.builder().build());
+ }
+
+ public ManualRefreshTokenProvider(String clientId, String clientSecret, CustomOptions options) {
+ this(clientId, clientSecret, createNetworkingClient(options));
+ }
+
+ ManualRefreshTokenProvider(
+ String clientId, String clientSecret, NetworkingClient networkingClient) {
+ Asserts.assertNotEmpty(clientId, "client id");
+ Asserts.assertNotEmpty(clientSecret, "client secret");
+ Asserts.assertNotNull(networkingClient, "networking client");
+ this.tokenRequester = new TokenRequester(clientId, clientSecret, networkingClient);
+ }
+
+ @Override
+ public Token getToken() throws IncogniaException {
+ Token currentToken = token;
+ if (currentToken == null) {
+ throw new TokenNotFoundException();
+ }
+
+ if (currentToken.isExpired()) {
+ throw new TokenExpiredException();
+ }
+
+ return currentToken;
+ }
+
+ public Token refresh() throws IncogniaException {
+ lock.lock();
+ try {
+ token = tokenRequester.requestToken();
+ return token;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private static NetworkingClient createNetworkingClient(CustomOptions options) {
+ Asserts.assertNotNull(options, "custom options");
+ OkHttpClient httpClient =
+ new OkHttpClient.Builder()
+ .callTimeout(options.getTimeoutMillis(), TimeUnit.MILLISECONDS)
+ .connectionPool(
+ new ConnectionPool(
+ options.getMaxConnections(), options.getKeepAliveSeconds(), TimeUnit.SECONDS))
+ .build();
+ return new NetworkingClient(httpClient, API_URL);
+ }
+}
diff --git a/src/main/java/com/incognia/api/clients/TokenAwareNetworkingClient.java b/src/main/java/com/incognia/api/clients/TokenAwareNetworkingClient.java
index 9db737c0..35c80545 100644
--- a/src/main/java/com/incognia/api/clients/TokenAwareNetworkingClient.java
+++ b/src/main/java/com/incognia/api/clients/TokenAwareNetworkingClient.java
@@ -1,5 +1,6 @@
package com.incognia.api.clients;
+import com.incognia.common.Token;
import com.incognia.common.exceptions.IncogniaException;
import java.util.HashMap;
import java.util.Map;
@@ -26,44 +27,67 @@ public class TokenAwareNetworkingClient {
public TokenAwareNetworkingClient(
OkHttpClient httpClient, String baseUrl, String clientId, String clientSecret) {
- this.networkingClient = new NetworkingClient(httpClient, baseUrl);
- this.tokenProvider = new TokenProvider(clientId, clientSecret, networkingClient);
+ this(
+ httpClient,
+ baseUrl,
+ new AutoRefreshTokenProvider(
+ clientId, clientSecret, new NetworkingClient(httpClient, baseUrl)));
}
- private Map buildHeaders() throws IncogniaException {
- Map headers = new HashMap<>();
- headers.put(USER_AGENT_HEADER, USER_AGENT_HEADER_CONTENT);
- headers.put(AUTHORIZATION_HEADER, tokenProvider.buildAuthorizationHeader());
- Long latency = lastLatency.get();
- if (latency != null) {
- headers.put(LATENCY_HEADER, Long.toString(latency));
- }
- return headers;
+ public TokenAwareNetworkingClient(
+ OkHttpClient httpClient, String baseUrl, TokenProvider tokenProvider) {
+ this.networkingClient = new NetworkingClient(httpClient, baseUrl);
+ this.tokenProvider = tokenProvider;
}
public U doPost(
String path, T body, Class responseType, Map queryParameters)
throws IncogniaException {
- tokenProvider.getToken();
+ Token token = tokenProvider.getToken();
long start = System.nanoTime();
- U result = networkingClient.doPost(path, body, responseType, buildHeaders(), queryParameters);
+ U result =
+ networkingClient.doPost(path, body, responseType, buildHeaders(token), queryParameters);
lastLatency.set(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start));
return result;
}
public U doPost(String path, T body, Class responseType) throws IncogniaException {
- tokenProvider.getToken();
+ Token token = tokenProvider.getToken();
long start = System.nanoTime();
- U result = networkingClient.doPost(path, body, responseType, buildHeaders());
+ U result = networkingClient.doPost(path, body, responseType, buildHeaders(token));
lastLatency.set(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start));
return result;
}
public void doPost(String path, T body, Map queryParameters)
throws IncogniaException {
- tokenProvider.getToken();
+ Token token = tokenProvider.getToken();
long start = System.nanoTime();
- networkingClient.doPost(path, body, buildHeaders(), queryParameters);
+ networkingClient.doPost(path, body, buildHeaders(token), queryParameters);
lastLatency.set(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start));
}
+
+ private Map buildHeaders(Token token) throws IncogniaException {
+ validateToken(token);
+ Map headers = new HashMap<>();
+ headers.put(USER_AGENT_HEADER, USER_AGENT_HEADER_CONTENT);
+ headers.put(AUTHORIZATION_HEADER, token.getTokenType() + " " + token.getAccessToken());
+ Long latency = lastLatency.get();
+ if (latency != null) {
+ headers.put(LATENCY_HEADER, Long.toString(latency));
+ }
+ return headers;
+ }
+
+ private void validateToken(Token token) throws IncogniaException {
+ if (token == null) {
+ throw new IncogniaException("token provider returned a null token");
+ }
+ if (token.getTokenType() == null || token.getTokenType().isEmpty()) {
+ throw new IncogniaException("token provider returned a token without token type");
+ }
+ if (token.getAccessToken() == null || token.getAccessToken().isEmpty()) {
+ throw new IncogniaException("token provider returned a token without access token");
+ }
+ }
}
diff --git a/src/main/java/com/incognia/api/clients/TokenProvider.java b/src/main/java/com/incognia/api/clients/TokenProvider.java
index 74c5d0e5..d4455b55 100644
--- a/src/main/java/com/incognia/api/clients/TokenProvider.java
+++ b/src/main/java/com/incognia/api/clients/TokenProvider.java
@@ -1,78 +1,8 @@
package com.incognia.api.clients;
+import com.incognia.common.Token;
import com.incognia.common.exceptions.IncogniaException;
-import java.nio.charset.StandardCharsets;
-import java.time.Instant;
-import java.time.temporal.ChronoUnit;
-import java.util.Base64;
-import java.util.Collections;
-import java.util.Map;
-import java.util.concurrent.locks.ReentrantLock;
-class TokenProvider {
- private static final int TOKEN_REFRESH_BEFORE_SECONDS = 10;
- private static final String TOKEN_REQUEST_BODY = "grant_type=client_credentials";
- private static final String TOKEN_PATH = "api/v2/token";
-
- // This implementation assumes that only one instance of this class
- // is created per IncogniaAPI instance.
- // Therefore, for each (clientId, clientSecret) pair, there is exactly
- // one instance of this class.
- private final ReentrantLock lock = new ReentrantLock();
- private volatile TokenResponse token;
-
- private final String clientId;
- private final String clientSecret;
- private final NetworkingClient networkingClient;
-
- public TokenProvider(String clientId, String clientSecret, NetworkingClient networkingClient) {
- this.clientId = clientId;
- this.clientSecret = clientSecret;
- this.networkingClient = networkingClient;
- }
-
- public TokenResponse getToken() throws IncogniaException {
- refreshTokenIfNeeded();
- return token;
- }
-
- public String buildAuthorizationHeader() throws IncogniaException {
- if (token == null) {
- refreshTokenIfNeeded();
- }
-
- return token.getTokenType() + " " + token.getAccessToken();
- }
-
- private boolean needsRefresh() {
- return token == null
- || Instant.now().until(token.getExpiresAt(), ChronoUnit.SECONDS)
- <= TOKEN_REFRESH_BEFORE_SECONDS;
- }
-
- private void refreshTokenIfNeeded() throws IncogniaException {
- if (needsRefresh()) {
- lock.lock();
- try {
- if (needsRefresh()) {
- token = getNewToken();
- token.computeExpiresAt();
- }
- } finally {
- lock.unlock();
- }
- }
- }
-
- private TokenResponse getNewToken() throws IncogniaException {
- String clientIdSecret = clientId + ":" + clientSecret;
- Map headers =
- Collections.singletonMap(
- "Authorization",
- "Basic "
- + Base64.getUrlEncoder()
- .encodeToString(clientIdSecret.getBytes(StandardCharsets.UTF_8)));
- return networkingClient.doPostFormUrlEncoded(
- TOKEN_PATH, TOKEN_REQUEST_BODY, TokenResponse.class, headers);
- }
+public interface TokenProvider {
+ Token getToken() throws IncogniaException;
}
diff --git a/src/main/java/com/incognia/api/clients/TokenRequester.java b/src/main/java/com/incognia/api/clients/TokenRequester.java
new file mode 100644
index 00000000..2d13cd46
--- /dev/null
+++ b/src/main/java/com/incognia/api/clients/TokenRequester.java
@@ -0,0 +1,41 @@
+package com.incognia.api.clients;
+
+import com.incognia.common.Token;
+import com.incognia.common.exceptions.IncogniaException;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.Map;
+
+final class TokenRequester {
+ private static final String TOKEN_REQUEST_BODY = "grant_type=client_credentials";
+ private static final String TOKEN_PATH = "api/v2/token";
+
+ private final String clientId;
+ private final String clientSecret;
+ private final NetworkingClient networkingClient;
+
+ TokenRequester(String clientId, String clientSecret, NetworkingClient networkingClient) {
+ this.clientId = clientId;
+ this.clientSecret = clientSecret;
+ this.networkingClient = networkingClient;
+ }
+
+ Token requestToken() throws IncogniaException {
+ String clientIdSecret = clientId + ":" + clientSecret;
+ Map headers =
+ Collections.singletonMap(
+ "Authorization",
+ "Basic "
+ + Base64.getEncoder()
+ .encodeToString(clientIdSecret.getBytes(StandardCharsets.UTF_8)));
+ TokenResponse tokenResponse =
+ networkingClient.doPostFormUrlEncoded(
+ TOKEN_PATH, TOKEN_REQUEST_BODY, TokenResponse.class, headers);
+ return new Token(
+ tokenResponse.getAccessToken(),
+ tokenResponse.getTokenType(),
+ Instant.now().plusSeconds(tokenResponse.getExpiresIn()));
+ }
+}
diff --git a/src/main/java/com/incognia/api/clients/TokenResponse.java b/src/main/java/com/incognia/api/clients/TokenResponse.java
index ebd89d0d..c53c9161 100644
--- a/src/main/java/com/incognia/api/clients/TokenResponse.java
+++ b/src/main/java/com/incognia/api/clients/TokenResponse.java
@@ -1,6 +1,5 @@
package com.incognia.api.clients;
-import java.time.Instant;
import lombok.AllArgsConstructor;
import lombok.Data;
@@ -10,9 +9,4 @@ public class TokenResponse {
private String accessToken;
private long expiresIn;
private String tokenType;
- private Instant expiresAt;
-
- public void computeExpiresAt() {
- this.expiresAt = Instant.now().plusSeconds(this.expiresIn);
- }
}
diff --git a/src/main/java/com/incognia/common/Token.java b/src/main/java/com/incognia/common/Token.java
new file mode 100644
index 00000000..66205e00
--- /dev/null
+++ b/src/main/java/com/incognia/common/Token.java
@@ -0,0 +1,15 @@
+package com.incognia.common;
+
+import java.time.Instant;
+import lombok.Value;
+
+@Value
+public class Token {
+ String accessToken;
+ String tokenType;
+ Instant expiresAt;
+
+ public boolean isExpired() {
+ return !expiresAt.isAfter(Instant.now());
+ }
+}
diff --git a/src/main/java/com/incognia/common/exceptions/TokenExpiredException.java b/src/main/java/com/incognia/common/exceptions/TokenExpiredException.java
new file mode 100644
index 00000000..31d980d6
--- /dev/null
+++ b/src/main/java/com/incognia/common/exceptions/TokenExpiredException.java
@@ -0,0 +1,7 @@
+package com.incognia.common.exceptions;
+
+public class TokenExpiredException extends IncogniaException {
+ public TokenExpiredException() {
+ super("token is expired");
+ }
+}
diff --git a/src/main/java/com/incognia/common/exceptions/TokenNotFoundException.java b/src/main/java/com/incognia/common/exceptions/TokenNotFoundException.java
new file mode 100644
index 00000000..9447b48e
--- /dev/null
+++ b/src/main/java/com/incognia/common/exceptions/TokenNotFoundException.java
@@ -0,0 +1,7 @@
+package com.incognia.common.exceptions;
+
+public class TokenNotFoundException extends IncogniaException {
+ public TokenNotFoundException() {
+ super("token not found in memory");
+ }
+}
diff --git a/src/main/java/com/incognia/common/utils/CustomOptions.java b/src/main/java/com/incognia/common/utils/CustomOptions.java
index ab073905..b0d94b9a 100644
--- a/src/main/java/com/incognia/common/utils/CustomOptions.java
+++ b/src/main/java/com/incognia/common/utils/CustomOptions.java
@@ -1,12 +1,14 @@
package com.incognia.common.utils;
+import com.incognia.api.clients.TokenProvider;
import lombok.Builder;
import lombok.Value;
@Value
-@Builder
+@Builder(toBuilder = true)
public class CustomOptions {
@Builder.Default long timeoutMillis = 10000L;
@Builder.Default int maxConnections = 5;
@Builder.Default long keepAliveSeconds = 300;
+ TokenProvider tokenProvider;
}
diff --git a/src/test/java/com/incognia/api/IncogniaAPITest.java b/src/test/java/com/incognia/api/IncogniaAPITest.java
index 34f4b120..33155a3b 100644
--- a/src/test/java/com/incognia/api/IncogniaAPITest.java
+++ b/src/test/java/com/incognia/api/IncogniaAPITest.java
@@ -11,6 +11,7 @@
import static org.mockito.Mockito.verify;
import com.incognia.api.clients.TokenAwareDispatcher;
+import com.incognia.api.clients.TokenProvider;
import com.incognia.common.Address;
import com.incognia.common.Coordinates;
import com.incognia.common.FinancialAccount;
@@ -21,6 +22,7 @@
import com.incognia.common.ReasonCode;
import com.incognia.common.ReasonSource;
import com.incognia.common.StructuredAddress;
+import com.incognia.common.Token;
import com.incognia.common.exceptions.IncogniaException;
import com.incognia.common.utils.ClientCredentials;
import com.incognia.common.utils.CustomOptions;
@@ -28,6 +30,7 @@
import com.incognia.feedback.FeedbackIdentifiers;
import com.incognia.feedback.PostFeedbackRequestBody;
import com.incognia.fixtures.AddressFixture;
+import com.incognia.fixtures.TokenCreationFixture;
import com.incognia.onboarding.RegisterSignupRequest;
import com.incognia.onboarding.RegisterWebSignupRequest;
import com.incognia.onboarding.SignupAssessment;
@@ -45,7 +48,6 @@
import com.incognia.transaction.payment.PaymentValue;
import com.incognia.transaction.payment.PixKey;
import com.incognia.transaction.payment.RegisterPaymentRequest;
-import java.io.IOException;
import java.lang.reflect.Field;
import java.time.Instant;
import java.util.ArrayList;
@@ -87,7 +89,9 @@ class IncogniaAPITest {
private IncogniaAPI clientWithLowTimeout;
@BeforeEach
- void setUp() {
+ void setUp() throws Exception {
+ resetIncogniaApiInstances();
+ TokenAwareDispatcher.setToken(TokenCreationFixture.createToken());
mockServer = new MockWebServer();
client =
new IncogniaAPI(
@@ -104,9 +108,9 @@ void setUp() {
}
@AfterEach
- void tearDown() throws NoSuchFieldException, IOException, IllegalAccessException {
- resetIncogniaApiInstances();
+ void tearDown() throws Exception {
mockServer.shutdown();
+ resetIncogniaApiInstances();
}
@Test
@@ -126,6 +130,13 @@ void testInit_shouldReturnAnInstance() {
assertThat(instance2).isNotSameAs(instance1);
}
+ @Test
+ void testInit_whenCustomOptionsIsNull_shouldThrowIllegalArgumentException() {
+ assertThatThrownBy(() -> IncogniaAPI.init(CLIENT_ID, CLIENT_SECRET, null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("'custom options' cannot be null");
+ }
+
@Test
void testInstanceWithoutCredentials_shouldReturnAnInstance() {
IncogniaAPI instance1 =
@@ -242,6 +253,48 @@ void testInit_whenNoCustomOptions_shouldCreateOkHttpWithDefaultParameters() {
}
}
+ @Test
+ void testConstructor_whenCustomTokenProviderIsProvided_shouldUseIt() throws Exception {
+ TokenProvider customTokenProvider = mock(TokenProvider.class);
+ TokenAwareDispatcher.setToken("custom-token");
+ doReturn(new Token("custom-token", "Bearer", Instant.now().plusSeconds(100)))
+ .when(customTokenProvider)
+ .getToken();
+ dispatcher.setExpectedRequestToken("request-token-web-signup");
+ mockServer.setDispatcher(dispatcher);
+
+ IncogniaAPI api =
+ new IncogniaAPI(
+ CLIENT_ID,
+ CLIENT_SECRET,
+ CustomOptions.builder().tokenProvider(customTokenProvider).build(),
+ mockServer.url("").toString());
+
+ api.registerWebSignup(
+ RegisterWebSignupRequest.builder().requestToken("request-token-web-signup").build());
+
+ verify(customTokenProvider).getToken();
+ assertThat(dispatcher.getTokenRequestCount()).isZero();
+ }
+
+ @Test
+ void testConstructor_whenCustomTokenProviderIsNotProvided_shouldUseDefaultTokenProvider()
+ throws Exception {
+ dispatcher.setExpectedRequestToken("request-token-web-signup");
+ mockServer.setDispatcher(dispatcher);
+ IncogniaAPI api =
+ new IncogniaAPI(
+ CLIENT_ID,
+ CLIENT_SECRET,
+ CustomOptions.builder().build(),
+ mockServer.url("").toString());
+
+ api.registerWebSignup(
+ RegisterWebSignupRequest.builder().requestToken("request-token-web-signup").build());
+
+ assertThat(dispatcher.getTokenRequestCount()).isEqualTo(1);
+ }
+
@Test
@DisplayName("should return the expected signup response")
@SneakyThrows
diff --git a/src/test/java/com/incognia/api/clients/AutoRefreshTokenProviderTest.java b/src/test/java/com/incognia/api/clients/AutoRefreshTokenProviderTest.java
new file mode 100644
index 00000000..afc9a790
--- /dev/null
+++ b/src/test/java/com/incognia/api/clients/AutoRefreshTokenProviderTest.java
@@ -0,0 +1,137 @@
+package com.incognia.api.clients;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.incognia.common.Token;
+import com.incognia.common.exceptions.IncogniaException;
+import java.lang.reflect.Field;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import okhttp3.OkHttpClient;
+import okhttp3.mockwebserver.MockWebServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class AutoRefreshTokenProviderTest {
+ private final String CLIENT_ID = "client-id";
+ private final String ANOTHER_CLIENT_ID = "another-client-id";
+ private final String CLIENT_SECRET = "client-secret";
+ private MockWebServer mockServer;
+ private AutoRefreshTokenProvider tokenProvider;
+ private AutoRefreshTokenProvider anotherTokenProvider;
+
+ @BeforeEach
+ void setUp() {
+ mockServer = new MockWebServer();
+ tokenProvider =
+ new AutoRefreshTokenProvider(
+ CLIENT_ID,
+ CLIENT_SECRET,
+ new NetworkingClient(new OkHttpClient(), mockServer.url("").toString()));
+ anotherTokenProvider =
+ new AutoRefreshTokenProvider(
+ ANOTHER_CLIENT_ID,
+ CLIENT_SECRET,
+ new NetworkingClient(new OkHttpClient(), mockServer.url("").toString()));
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ mockServer.shutdown();
+ }
+
+ @Test
+ void testGetToken_whenTokenIsNull_shouldReturnTheSameTokenForTheSameInstance()
+ throws IncogniaException {
+ TokenAwareDispatcher dispatcher = new TokenAwareDispatcher(CLIENT_ID, CLIENT_SECRET);
+ mockServer.setDispatcher(dispatcher);
+
+ Token firstToken = tokenProvider.getToken();
+ Token secondToken = tokenProvider.getToken();
+
+ assertThat(firstToken).isSameAs(secondToken);
+ assertThat(dispatcher.getTokenRequestCount()).isEqualTo(1);
+ }
+
+ @Test
+ void testGetToken_whenCalledConcurrently_shouldRequestTheTokenOnlyOnce() throws Exception {
+ TokenAwareDispatcher dispatcher = new TokenAwareDispatcher(CLIENT_ID, CLIENT_SECRET);
+ mockServer.setDispatcher(dispatcher);
+ int threadCount = 8;
+ CountDownLatch ready = new CountDownLatch(threadCount);
+ CountDownLatch start = new CountDownLatch(1);
+ ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+
+ try {
+ List> futures = new ArrayList<>();
+ for (int i = 0; i < threadCount; i++) {
+ futures.add(
+ executor.submit(
+ () -> {
+ ready.countDown();
+ assertThat(start.await(5, TimeUnit.SECONDS)).isTrue();
+ return tokenProvider.getToken();
+ }));
+ }
+
+ assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
+ start.countDown();
+
+ Token firstToken = futures.get(0).get(5, TimeUnit.SECONDS);
+ for (Future future : futures) {
+ assertThat(future.get(5, TimeUnit.SECONDS)).isSameAs(firstToken);
+ }
+ } finally {
+ executor.shutdownNow();
+ }
+
+ assertThat(dispatcher.getTokenRequestCount()).isEqualTo(1);
+ }
+
+ @Test
+ void testGetToken_whenDifferentProvidersUseDifferentCredentials_shouldNotShareTokens()
+ throws IncogniaException {
+ TokenAwareDispatcher dispatcher = new TokenAwareDispatcher(CLIENT_ID, CLIENT_SECRET);
+ TokenAwareDispatcher anotherDispatcher =
+ new TokenAwareDispatcher(ANOTHER_CLIENT_ID, CLIENT_SECRET);
+ mockServer.setDispatcher(dispatcher);
+
+ Token token = tokenProvider.getToken();
+
+ mockServer.setDispatcher(anotherDispatcher);
+ Token anotherToken = anotherTokenProvider.getToken();
+
+ assertThat(anotherToken).isNotSameAs(token);
+ assertThat(dispatcher.getTokenRequestCount()).isEqualTo(1);
+ assertThat(anotherDispatcher.getTokenRequestCount()).isEqualTo(1);
+ }
+
+ @Test
+ void testGetToken_whenTokenIsExpired_shouldRefreshToken() throws Exception {
+ TokenAwareDispatcher dispatcher = new TokenAwareDispatcher(CLIENT_ID, CLIENT_SECRET);
+ mockServer.setDispatcher(dispatcher);
+
+ Token token = tokenProvider.getToken();
+ expireToken(tokenProvider, token);
+
+ Token refreshedToken = tokenProvider.getToken();
+
+ assertThat(refreshedToken).isNotSameAs(token);
+ assertThat(dispatcher.getTokenRequestCount()).isEqualTo(2);
+ }
+
+ private static void expireToken(AutoRefreshTokenProvider tokenProvider, Token token)
+ throws Exception {
+ Field tokenField = AutoRefreshTokenProvider.class.getDeclaredField("token");
+ tokenField.setAccessible(true);
+ tokenField.set(
+ tokenProvider, new Token(token.getAccessToken(), token.getTokenType(), Instant.EPOCH));
+ }
+}
diff --git a/src/test/java/com/incognia/api/clients/ManualRefreshTokenProviderTest.java b/src/test/java/com/incognia/api/clients/ManualRefreshTokenProviderTest.java
new file mode 100644
index 00000000..f8730d10
--- /dev/null
+++ b/src/test/java/com/incognia/api/clients/ManualRefreshTokenProviderTest.java
@@ -0,0 +1,122 @@
+package com.incognia.api.clients;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.verify;
+
+import com.incognia.common.Token;
+import com.incognia.common.exceptions.IncogniaException;
+import com.incognia.common.exceptions.TokenExpiredException;
+import com.incognia.common.exceptions.TokenNotFoundException;
+import com.incognia.common.utils.CustomOptions;
+import java.lang.reflect.Field;
+import java.time.Instant;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import okhttp3.ConnectionPool;
+import okhttp3.OkHttpClient;
+import okhttp3.mockwebserver.MockWebServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+
+class ManualRefreshTokenProviderTest {
+ private final String CLIENT_ID = "client-id";
+ private final String CLIENT_SECRET = "client-secret";
+ private ManualRefreshTokenProvider tokenProvider;
+ private MockWebServer mockServer;
+
+ @BeforeEach
+ void setUp() {
+ mockServer = new MockWebServer();
+ tokenProvider =
+ new ManualRefreshTokenProvider(
+ CLIENT_ID,
+ CLIENT_SECRET,
+ new NetworkingClient(new OkHttpClient(), mockServer.url("").toString()));
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ mockServer.shutdown();
+ }
+
+ @Test
+ void testGetToken_whenTokenWasNotRefreshed_shouldThrowTokenNotFoundException() {
+ assertThatThrownBy(() -> tokenProvider.getToken()).isInstanceOf(TokenNotFoundException.class);
+ }
+
+ @Test
+ void testGetToken_whenTokenIsExpired_shouldThrowTokenExpiredException() throws Exception {
+ TokenAwareDispatcher dispatcher = new TokenAwareDispatcher(CLIENT_ID, CLIENT_SECRET);
+ mockServer.setDispatcher(dispatcher);
+ tokenProvider.refresh();
+ expireToken(tokenProvider);
+
+ assertThatThrownBy(() -> tokenProvider.getToken()).isInstanceOf(TokenExpiredException.class);
+ }
+
+ @Test
+ void testRefresh_shouldStoreAndReturnAToken() throws IncogniaException {
+ TokenAwareDispatcher dispatcher = new TokenAwareDispatcher(CLIENT_ID, CLIENT_SECRET);
+ mockServer.setDispatcher(dispatcher);
+
+ Token refreshedToken = tokenProvider.refresh();
+
+ assertThat(tokenProvider.getToken()).isSameAs(refreshedToken);
+ assertThat(refreshedToken.getExpiresAt()).isAfter(Instant.now());
+ assertThat(dispatcher.getTokenRequestCount()).isEqualTo(1);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void testConstructor_whenCustomOptionsProvided_shouldCreateOkHttpWithRightParameters() {
+ AtomicReference> poolArgs = new AtomicReference<>();
+
+ try (MockedConstruction builderConstruction =
+ mockConstruction(
+ OkHttpClient.Builder.class,
+ (mock, context) -> {
+ doReturn(mock).when(mock).callTimeout(anyLong(), any());
+ doReturn(mock).when(mock).connectionPool(any());
+ doReturn(mock(OkHttpClient.class)).when(mock).build();
+ });
+ MockedConstruction ignored =
+ mockConstruction(
+ ConnectionPool.class,
+ (mock, context) -> poolArgs.set((List