From 5f871afd6df8088d99477c773a227a460e5780c0 Mon Sep 17 00:00:00 2001 From: Kanat Date: Wed, 29 Oct 2025 17:45:25 -0400 Subject: [PATCH 01/23] experiment with client-side request --- build.gradle | 1 + .../java/models/framework/StreamRequest.java | 15 ++- .../services/framework/DefaultClient.java | 41 +++++-- .../java/services/framework/UserClient.java | 110 ++++++++++++++++++ .../io/getstream/chat/java/CustomTest.java | 24 ++++ .../getstream/chat/java/UserClientTest.java | 78 +++++++++++++ 6 files changed, 258 insertions(+), 11 deletions(-) create mode 100644 src/main/java/io/getstream/chat/java/services/framework/UserClient.java create mode 100644 src/test/java/io/getstream/chat/java/CustomTest.java create mode 100644 src/test/java/io/getstream/chat/java/UserClientTest.java diff --git a/build.gradle b/build.gradle index e3531fbbe..91178bad0 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,7 @@ dependencies { // define any required OkHttp artifacts without version implementation("com.squareup.okhttp3:okhttp") + implementation("com.squareup.okhttp3:logging-interceptor") implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-jackson:2.9.0' diff --git a/src/main/java/io/getstream/chat/java/models/framework/StreamRequest.java b/src/main/java/io/getstream/chat/java/models/framework/StreamRequest.java index da14fe94b..c54bce8d0 100644 --- a/src/main/java/io/getstream/chat/java/models/framework/StreamRequest.java +++ b/src/main/java/io/getstream/chat/java/models/framework/StreamRequest.java @@ -4,6 +4,8 @@ import io.getstream.chat.java.services.framework.Client; import io.getstream.chat.java.services.framework.StreamServiceHandler; import java.util.function.Consumer; + +import io.getstream.chat.java.services.framework.UserClient; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import retrofit2.Call; @@ -13,6 +15,8 @@ public abstract class StreamRequest { private Client client; + private String userToken; + /** * Executes the request * @@ -53,8 +57,17 @@ public StreamRequest withClient(Client client) { return this; } + public StreamRequest withUserToken(final String token) { + this.userToken = token; + return this; + } + @NotNull protected Client getClient() { - return (client == null) ? Client.getInstance() : client; + Client finalClient = (client == null) ? Client.getInstance() : client; + if (!"".equals(userToken)) { + return new UserClient(finalClient, userToken); + } + return finalClient; } } diff --git a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java index 14302b5c8..688be46b4 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java @@ -8,17 +8,19 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; import java.security.Key; import java.time.Duration; import java.util.*; import java.util.concurrent.TimeUnit; import javax.crypto.spec.SecretKeySpec; -import okhttp3.ConnectionPool; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; + +import okhttp3.*; +import okhttp3.logging.HttpLoggingInterceptor; import org.jetbrains.annotations.NotNull; +import retrofit2.CallAdapter; import retrofit2.Retrofit; import retrofit2.converter.jackson.JacksonConverterFactory; @@ -85,23 +87,36 @@ private Retrofit buildRetrofitClient() { httpClient.interceptors().clear(); HttpLoggingInterceptor loggingInterceptor = - new HttpLoggingInterceptor().setLevel(getLogLevel(extendedProperties)); + new HttpLoggingInterceptor(s -> System.out.printf("OkHttp: %s%n", s)).setLevel(getLogLevel(extendedProperties)); httpClient.addInterceptor(loggingInterceptor); httpClient.addInterceptor( chain -> { Request original = chain.request(); + + // Check for user token tag + UserClient.UserToken userToken = original.tag(UserClient.UserToken.class); + HttpUrl url = original.url().newBuilder().addQueryParameter("api_key", apiKey).build(); - Request request = + Request.Builder builder = original .newBuilder() .url(url) .header("Content-Type", "application/json") .header("X-Stream-Client", "stream-java-client-" + sdkVersion) - .header("Stream-Auth-Type", "jwt") - .header("Authorization", jwtToken(apiSecret)) - .build(); - return chain.proceed(request); + .header("Stream-Auth-Type", "jwt"); + + if (userToken != null) { + System.out.println("!.!.! Client-Side Auth"); + // User token present - use user auth + builder.header("Authorization", userToken.token); + } else { + System.out.println("!.!.! Server-Side Auth"); + // Server-side auth + builder.header("Authorization", jwtToken(apiSecret)); + } + + return chain.proceed(builder.build()); }); final ObjectMapper mapper = new ObjectMapper(); // Use field-based serialization but respect @JsonProperty and @JsonAnyGetter annotations @@ -118,6 +133,12 @@ private Retrofit buildRetrofitClient() { new Retrofit.Builder() .baseUrl(getStreamChatBaseUrl(extendedProperties)) .addConverterFactory(new QueryConverterFactory()) + // .callFactory(new Call.Factory() { + // @Override + // public @NotNull Call newCall(@NotNull Request request) { + // return null; + // } + // }) .addConverterFactory(JacksonConverterFactory.create(mapper)); builder.client(httpClient.build()); diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserClient.java b/src/main/java/io/getstream/chat/java/services/framework/UserClient.java new file mode 100644 index 000000000..e417a0a00 --- /dev/null +++ b/src/main/java/io/getstream/chat/java/services/framework/UserClient.java @@ -0,0 +1,110 @@ +package io.getstream.chat.java.services.framework; + +import org.jetbrains.annotations.NotNull; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; +import okhttp3.Request; + +import java.io.IOException; +import java.lang.reflect.Proxy; +import java.time.Duration; + +public final class UserClient implements Client { + + private final Client delegate; + private final String userToken; + + public UserClient(Client delegate, String userToken) { + this.delegate = delegate; + this.userToken = userToken; + } + + @Override + @SuppressWarnings("unchecked") + public @NotNull TService create(Class svcClass) { + TService service = delegate.create(svcClass); + + return (TService) Proxy.newProxyInstance( + svcClass.getClassLoader(), + new Class[]{svcClass}, + (proxy, method, args) -> { + Object result = method.invoke(service, args); + + if (result instanceof Call) { + return taggedCall((Call) result); + } + return result; + } + ); + } + + private Call taggedCall(Call original) { + return new Call() { + @Override + public Request request() { + return original.request().newBuilder() + .tag(UserToken.class, new UserToken(userToken)) + .build(); + } + + @Override + public Response execute() throws IOException { + return original.execute(); + } + + @Override + public void enqueue(Callback callback) { + original.enqueue(callback); + } + + @Override + public boolean isExecuted() { + return original.isExecuted(); + } + + @Override + public void cancel() { + original.cancel(); + } + + @Override + public boolean isCanceled() { + return original.isCanceled(); + } + + @Override + public Call clone() { + return taggedCall(original.clone()); + } + + @Override + public okio.Timeout timeout() { + return original.timeout(); + } + }; + } + + @Override + public @NotNull String getApiKey() { + return delegate.getApiKey(); + } + + @Override + public @NotNull String getApiSecret() { + return delegate.getApiSecret(); + } + + @Override + public void setTimeout(@NotNull Duration timeoutDuration) { + delegate.setTimeout(timeoutDuration); + } + + public static class UserToken { + public final String token; + + public UserToken(String token) { + this.token = token; + } + } +} diff --git a/src/test/java/io/getstream/chat/java/CustomTest.java b/src/test/java/io/getstream/chat/java/CustomTest.java new file mode 100644 index 000000000..d0fc90ffd --- /dev/null +++ b/src/test/java/io/getstream/chat/java/CustomTest.java @@ -0,0 +1,24 @@ +package io.getstream.chat.java; + +import io.getstream.chat.java.models.User; +import org.junit.jupiter.api.Test; + +public class CustomTest { + + @Test + void customTest() throws Exception { + var userId = "han_solo"; + var userToken = User.createToken("han_solo", null, null); + var response = User.list().userId(userId).filterCondition("id", userId).request(); + System.out.println(response); + } + + + @Test + void userReqTest() throws Exception { + var userId = "han_solo"; + var userToken = User.createToken("han_solo", null, null); + var response = User.list().filterCondition("id", userId).withUserToken(userToken).request(); + System.out.println("\n> " + response + "\n"); + } +} diff --git a/src/test/java/io/getstream/chat/java/UserClientTest.java b/src/test/java/io/getstream/chat/java/UserClientTest.java new file mode 100644 index 000000000..517da26d8 --- /dev/null +++ b/src/test/java/io/getstream/chat/java/UserClientTest.java @@ -0,0 +1,78 @@ +package io.getstream.chat.java; + +import io.getstream.chat.java.models.User; +import io.getstream.chat.java.services.framework.DefaultClient; +import io.getstream.chat.java.services.framework.UserClient; +import org.junit.jupiter.api.Test; + +/** + * Demonstrates how to use UserClient for client-side requests with user tokens. + * + *

Instead of server-side auth (API key + secret), UserClient allows you to make requests + * authenticated with a user token (JWT). + */ +public class UserClientTest { + + @Test + public void demonstrateUserClientUsage() throws Exception { + // Server-side setup - generate a user token for a specific user + String userToken = + User.createToken( + System.getProperty("io.getstream.chat.apiSecret"), + "test-user-id", + null, // no expiration + null // default issued at + ); + + // Create a client-side client for this user + DefaultClient serverClient = DefaultClient.getInstance(); + UserClient userClient = new UserClient(serverClient, userToken); + + // Now you can make requests on behalf of this user + // The token will be automatically included in the Authorization header + + // Example: Query channels visible to this user + // Channel.list().withClient(userClient).request(); + + // Example: Send a message as this user + // Message.send("messaging", "general") + // .withClient(userClient) + // .message(MessageRequestObject.builder() + // .text("Hello from client-side!") + // .userId("test-user-id") + // .build()) + // .request(); + + System.out.println( + "UserClient created successfully with token: " + userToken.substring(0, 20) + "..."); + } + + @Test + public void demonstrateMultipleUserClients() throws Exception { + // You can create multiple UserClients for different users + // Each shares the same underlying connection pool and thread pool + + String apiSecret = System.getProperty("io.getstream.chat.apiSecret"); + DefaultClient serverClient = DefaultClient.getInstance(); + + UserClient user1Client = + new UserClient(serverClient, User.createToken(apiSecret, "user-1", null, null)); + + UserClient user2Client = + new UserClient(serverClient, User.createToken(apiSecret, "user-2", null, null)); + + // Both clients share the same connection pool (efficient!) + // But each request is authenticated with its respective user token + + // You can make parallel requests with different user contexts: + // CompletableFuture future1 = CompletableFuture.supplyAsync(() -> + // Message.send("messaging", "channel1").withClient(user1Client).request() + // ); + // + // CompletableFuture future2 = CompletableFuture.supplyAsync(() -> + // Message.send("messaging", "channel2").withClient(user2Client).request() + // ); + + System.out.println("Multiple UserClients can coexist safely"); + } +} From 7bb5b0a362bf705a738dd6f5ed07b2c5c0b3b7ab Mon Sep 17 00:00:00 2001 From: Kanat Date: Wed, 29 Oct 2025 17:52:59 -0400 Subject: [PATCH 02/23] fix StreamRequest --- .../io/getstream/chat/java/models/framework/StreamRequest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/getstream/chat/java/models/framework/StreamRequest.java b/src/main/java/io/getstream/chat/java/models/framework/StreamRequest.java index c54bce8d0..0e2ce84d3 100644 --- a/src/main/java/io/getstream/chat/java/models/framework/StreamRequest.java +++ b/src/main/java/io/getstream/chat/java/models/framework/StreamRequest.java @@ -65,7 +65,7 @@ public StreamRequest withUserToken(final String token) { @NotNull protected Client getClient() { Client finalClient = (client == null) ? Client.getInstance() : client; - if (!"".equals(userToken)) { + if (userToken != null && !userToken.isEmpty()) { return new UserClient(finalClient, userToken); } return finalClient; From df09c1c4935389017a29115b0a89a3d40f5d7e56 Mon Sep 17 00:00:00 2001 From: Kanat Date: Wed, 29 Oct 2025 19:47:11 -0400 Subject: [PATCH 03/23] support UserToken --- .../chat/java/services/framework/Client.java | 4 + .../services/framework/DefaultClient.java | 25 +++++- .../java/services/framework/UserClient.java | 80 +------------------ .../java/services/framework/UserToken.java | 17 ++++ .../io/getstream/chat/java/CustomTest.java | 16 ++++ 5 files changed, 61 insertions(+), 81 deletions(-) create mode 100644 src/main/java/io/getstream/chat/java/services/framework/UserToken.java diff --git a/src/main/java/io/getstream/chat/java/services/framework/Client.java b/src/main/java/io/getstream/chat/java/services/framework/Client.java index fc0b31b4f..075893ea2 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/Client.java +++ b/src/main/java/io/getstream/chat/java/services/framework/Client.java @@ -7,6 +7,10 @@ public interface Client { @NotNull TService create(Class svcClass); + default @NotNull TService create(Class svcClass, UserToken token) { + return create(svcClass); + } + @NotNull String getApiKey(); diff --git a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java index 688be46b4..7ad683be2 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java @@ -95,7 +95,7 @@ private Retrofit buildRetrofitClient() { Request original = chain.request(); // Check for user token tag - UserClient.UserToken userToken = original.tag(UserClient.UserToken.class); + UserToken userToken = original.tag(UserToken.class); HttpUrl url = original.url().newBuilder().addQueryParameter("api_key", apiKey).build(); Request.Builder builder = @@ -107,11 +107,9 @@ private Retrofit buildRetrofitClient() { .header("Stream-Auth-Type", "jwt"); if (userToken != null) { - System.out.println("!.!.! Client-Side Auth"); // User token present - use user auth - builder.header("Authorization", userToken.token); + builder.header("Authorization", userToken.value()); } else { - System.out.println("!.!.! Server-Side Auth"); // Server-side auth builder.header("Authorization", jwtToken(apiSecret)); } @@ -151,6 +149,25 @@ public TService create(Class svcClass) { return retrofit.create(svcClass); } + @Override + public @NotNull TService create(Class svcClass, UserToken token) { + // Create a tagged retrofit instance with a Call.Factory that tags all requests + OkHttpClient originalClient = (OkHttpClient) retrofit.callFactory(); + + okhttp3.Call.Factory taggingFactory = request -> { + Request taggedRequest = request.newBuilder() + .tag(UserToken.class, token) + .build(); + return originalClient.newCall(taggedRequest); + }; + + Retrofit taggedRetrofit = retrofit.newBuilder() + .callFactory(taggingFactory) + .build(); + + return taggedRetrofit.create(svcClass); + } + @NotNull public String getApiSecret() { return apiSecret; diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserClient.java b/src/main/java/io/getstream/chat/java/services/framework/UserClient.java index e417a0a00..fbb7a55d5 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserClient.java @@ -1,88 +1,22 @@ package io.getstream.chat.java.services.framework; import org.jetbrains.annotations.NotNull; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; -import okhttp3.Request; -import java.io.IOException; -import java.lang.reflect.Proxy; import java.time.Duration; public final class UserClient implements Client { private final Client delegate; - private final String userToken; + private final UserToken userToken; public UserClient(Client delegate, String userToken) { this.delegate = delegate; - this.userToken = userToken; + this.userToken = new UserToken(userToken); } @Override - @SuppressWarnings("unchecked") public @NotNull TService create(Class svcClass) { - TService service = delegate.create(svcClass); - - return (TService) Proxy.newProxyInstance( - svcClass.getClassLoader(), - new Class[]{svcClass}, - (proxy, method, args) -> { - Object result = method.invoke(service, args); - - if (result instanceof Call) { - return taggedCall((Call) result); - } - return result; - } - ); - } - - private Call taggedCall(Call original) { - return new Call() { - @Override - public Request request() { - return original.request().newBuilder() - .tag(UserToken.class, new UserToken(userToken)) - .build(); - } - - @Override - public Response execute() throws IOException { - return original.execute(); - } - - @Override - public void enqueue(Callback callback) { - original.enqueue(callback); - } - - @Override - public boolean isExecuted() { - return original.isExecuted(); - } - - @Override - public void cancel() { - original.cancel(); - } - - @Override - public boolean isCanceled() { - return original.isCanceled(); - } - - @Override - public Call clone() { - return taggedCall(original.clone()); - } - - @Override - public okio.Timeout timeout() { - return original.timeout(); - } - }; + return delegate.create(svcClass, userToken); } @Override @@ -99,12 +33,4 @@ public okio.Timeout timeout() { public void setTimeout(@NotNull Duration timeoutDuration) { delegate.setTimeout(timeoutDuration); } - - public static class UserToken { - public final String token; - - public UserToken(String token) { - this.token = token; - } - } } diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserToken.java b/src/main/java/io/getstream/chat/java/services/framework/UserToken.java new file mode 100644 index 000000000..a144b3480 --- /dev/null +++ b/src/main/java/io/getstream/chat/java/services/framework/UserToken.java @@ -0,0 +1,17 @@ +package io.getstream.chat.java.services.framework; + +public final class UserToken { + private final String value; + + public UserToken(String value) { + this.value = value; + } + + public String value() { + return value; + } + + public boolean isBlank() { + return value == null || value.isBlank(); + } +} diff --git a/src/test/java/io/getstream/chat/java/CustomTest.java b/src/test/java/io/getstream/chat/java/CustomTest.java index d0fc90ffd..88ad89b44 100644 --- a/src/test/java/io/getstream/chat/java/CustomTest.java +++ b/src/test/java/io/getstream/chat/java/CustomTest.java @@ -21,4 +21,20 @@ void userReqTest() throws Exception { var response = User.list().filterCondition("id", userId).withUserToken(userToken).request(); System.out.println("\n> " + response + "\n"); } + + @Test + void directClientTest() throws Exception { + var userId = "han_solo"; + var userToken = User.createToken("han_solo", null, null); + + // Test creating a UserClient directly - should use Client-Side auth + var defaultClient = io.getstream.chat.java.services.framework.Client.getInstance(); + var userClient = new io.getstream.chat.java.services.framework.UserClient(defaultClient, userToken); + + var response = User.list() + .filterCondition("id", userId) + .withClient(userClient) + .request(); + System.out.println("\n> Direct UserClient: " + response + "\n"); + } } From 4f98150baa70dd9f6ecba077ace275462dd95245 Mon Sep 17 00:00:00 2001 From: Kanat Date: Wed, 29 Oct 2025 20:34:37 -0400 Subject: [PATCH 04/23] use reflection to set request --- .../services/framework/DefaultClient.java | 121 ++++++++++++++---- 1 file changed, 96 insertions(+), 25 deletions(-) diff --git a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java index 7ad683be2..a7763725f 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java @@ -8,8 +8,6 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import java.io.IOException; -import java.lang.annotation.Annotation; -import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; import java.security.Key; import java.time.Duration; @@ -20,7 +18,6 @@ import okhttp3.*; import okhttp3.logging.HttpLoggingInterceptor; import org.jetbrains.annotations.NotNull; -import retrofit2.CallAdapter; import retrofit2.Retrofit; import retrofit2.converter.jackson.JacksonConverterFactory; @@ -33,6 +30,7 @@ public class DefaultClient implements Client { private static final String API_DEFAULT_URL = "https://chat.stream-io-api.com"; private static volatile DefaultClient defaultInstance; @NotNull private Retrofit retrofit; + @NotNull private OkHttpClient okHttpClient; @NotNull private final String apiSecret; @NotNull private final String apiKey; @NotNull private final Properties extendedProperties; @@ -107,9 +105,11 @@ private Retrofit buildRetrofitClient() { .header("Stream-Auth-Type", "jwt"); if (userToken != null) { + System.out.println("!.!.! Client-Side"); // User token present - use user auth builder.header("Authorization", userToken.value()); } else { + System.out.println("!.!.! Server-Side"); // Server-side auth builder.header("Authorization", jwtToken(apiSecret)); } @@ -127,18 +127,20 @@ private Retrofit buildRetrofitClient() { new StdDateFormat().withColonInTimeZone(true).withTimeZone(TimeZone.getTimeZone("UTC"))); mapper.enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE); + this.okHttpClient = httpClient.build(); Retrofit.Builder builder = new Retrofit.Builder() .baseUrl(getStreamChatBaseUrl(extendedProperties)) + .client(okHttpClient) .addConverterFactory(new QueryConverterFactory()) - // .callFactory(new Call.Factory() { - // @Override - // public @NotNull Call newCall(@NotNull Request request) { - // return null; - // } - // }) - .addConverterFactory(JacksonConverterFactory.create(mapper)); - builder.client(httpClient.build()); + .addConverterFactory(JacksonConverterFactory.create(mapper)) + .callFactory(new Call.Factory() { + @Override + public @NotNull Call newCall(@NotNull Request request) { + return okHttpClient.newCall(request); + } + }); +// builder.client(httpClient.build()); return builder.build(); } @@ -151,21 +153,37 @@ public TService create(Class svcClass) { @Override public @NotNull TService create(Class svcClass, UserToken token) { - // Create a tagged retrofit instance with a Call.Factory that tags all requests - OkHttpClient originalClient = (OkHttpClient) retrofit.callFactory(); + TService service = retrofit.create(svcClass); - okhttp3.Call.Factory taggingFactory = request -> { - Request taggedRequest = request.newBuilder() - .tag(UserToken.class, token) - .build(); - return originalClient.newCall(taggedRequest); - }; - - Retrofit taggedRetrofit = retrofit.newBuilder() - .callFactory(taggingFactory) - .build(); - - return taggedRetrofit.create(svcClass); + return (TService) java.lang.reflect.Proxy.newProxyInstance( + svcClass.getClassLoader(), + new Class[] { svcClass }, + (proxy, method, args) -> { + Object result = method.invoke(service, args); + + // If the result is a Call, wrap it to add the tag + if (result instanceof retrofit2.Call) { + retrofit2.Call originalCall = (retrofit2.Call) result; + retrofit2.Call clonedCall = originalCall.clone(); + + try { + // Retrofit's OkHttpCall has a rawCall field + var newRequest = originalCall.request().newBuilder().tag(UserToken.class, token).build(); + okhttp3.Call newOkHttpCall = okHttpClient.newCall(newRequest); + + java.lang.reflect.Field rawCallField = clonedCall.getClass().getDeclaredField("rawCall"); + rawCallField.setAccessible(true); + rawCallField.set(clonedCall, newOkHttpCall); + + return clonedCall; + } catch (Exception e) { + throw new RuntimeException("Failed to modify call", e); + } + } + + return result; + } + ); } @NotNull @@ -270,4 +288,57 @@ private static boolean hasFailOnUnknownProperties(@NotNull Properties properties var hasEnabled = properties.getOrDefault(propName, "false"); return Boolean.parseBoolean(hasEnabled.toString()); } + + private static class UserCall implements retrofit2.Call { + private final retrofit2.Call delegate; + private final UserToken token; + + UserCall(retrofit2.Call delegate, UserToken token) { + this.delegate = delegate; + this.token = token; + } + + @Override + public retrofit2.Response execute() throws IOException { + return delegate.execute(); + } + + @Override + public void enqueue(retrofit2.Callback callback) { + delegate.enqueue(callback); + } + + @Override + public boolean isExecuted() { + return delegate.isExecuted(); + } + + @Override + public void cancel() { + delegate.cancel(); + } + + @Override + public boolean isCanceled() { + return delegate.isCanceled(); + } + + @Override + public retrofit2.Call clone() { + return new UserCall<>(delegate.clone(), token); + } + + @Override + public Request request() { + Request original = delegate.request(); + return original.newBuilder() + .tag(UserToken.class, token) + .build(); + } + + @Override + public okio.Timeout timeout() { + return delegate.timeout(); + } + } } From 10e39f704a8d8d8350f00699122d65c48d5fa08a Mon Sep 17 00:00:00 2001 From: Kanat Date: Wed, 29 Oct 2025 20:44:14 -0400 Subject: [PATCH 05/23] extract to UserTokenCallProxy --- .../services/framework/DefaultClient.java | 26 +------ .../framework/UserTokenCallProxy.java | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+), 25 deletions(-) create mode 100644 src/main/java/io/getstream/chat/java/services/framework/UserTokenCallProxy.java diff --git a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java index a7763725f..a5b2d2c2f 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java @@ -158,31 +158,7 @@ public TService create(Class svcClass) { return (TService) java.lang.reflect.Proxy.newProxyInstance( svcClass.getClassLoader(), new Class[] { svcClass }, - (proxy, method, args) -> { - Object result = method.invoke(service, args); - - // If the result is a Call, wrap it to add the tag - if (result instanceof retrofit2.Call) { - retrofit2.Call originalCall = (retrofit2.Call) result; - retrofit2.Call clonedCall = originalCall.clone(); - - try { - // Retrofit's OkHttpCall has a rawCall field - var newRequest = originalCall.request().newBuilder().tag(UserToken.class, token).build(); - okhttp3.Call newOkHttpCall = okHttpClient.newCall(newRequest); - - java.lang.reflect.Field rawCallField = clonedCall.getClass().getDeclaredField("rawCall"); - rawCallField.setAccessible(true); - rawCallField.set(clonedCall, newOkHttpCall); - - return clonedCall; - } catch (Exception e) { - throw new RuntimeException("Failed to modify call", e); - } - } - - return result; - } + new UserTokenCallProxy(okHttpClient, service, token) ); } diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallProxy.java b/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallProxy.java new file mode 100644 index 000000000..c480683e2 --- /dev/null +++ b/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallProxy.java @@ -0,0 +1,78 @@ +package io.getstream.chat.java.services.framework; + +import okhttp3.Call; +import okhttp3.Request; +import org.jetbrains.annotations.NotNull; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; + +/** + * Dynamic proxy that intercepts Retrofit service calls and injects UserToken + * into the request by modifying the internal rawCall field via reflection. + * + * This approach allows per-call authentication without creating multiple OkHttpClient + * instances, making it suitable for multi-tenant systems with thousands of users. + */ +class UserTokenCallProxy implements InvocationHandler { + private static volatile Field rawCallField; + + private final Call.Factory callFactory; + private final Object delegate; + private final UserToken token; + + UserTokenCallProxy(@NotNull Call.Factory callFactory, @NotNull Object delegate, @NotNull UserToken token) { + this.callFactory = callFactory; + this.delegate = delegate; + this.token = token; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + Object result = method.invoke(delegate, args); + + // If the result is a Retrofit Call, inject the user token + if (result instanceof retrofit2.Call) { + return injectTokenIntoCall((retrofit2.Call) result); + } + + return result; + } + + private retrofit2.Call injectTokenIntoCall(retrofit2.Call originalCall) { + retrofit2.Call clonedCall = originalCall.clone(); + + try { + // Cache field lookup for performance (double-checked locking) + if (rawCallField == null) { + synchronized (UserTokenCallProxy.class) { + if (rawCallField == null) { + rawCallField = clonedCall.getClass().getDeclaredField("rawCall"); + rawCallField.setAccessible(true); + } + } + } + + // Create new request with token tag + Request newRequest = originalCall.request().newBuilder() + .tag(UserToken.class, token) + .build(); + + // Create new OkHttp call with modified request + okhttp3.Call newOkHttpCall = callFactory.newCall(newRequest); + + // Inject the new call into the cloned Retrofit call + rawCallField.set(clonedCall, newOkHttpCall); + + return clonedCall; + } catch (NoSuchFieldException e) { + // If Retrofit's internal structure changes, provide clear error message + throw new RuntimeException( + "Retrofit internal structure changed. Field 'rawCall' not found in " + + clonedCall.getClass().getName() + ". Update client implementation.", e); + } catch (IllegalAccessException e) { + throw new RuntimeException("Failed to inject token into call", e); + } + } +} + From fca583ef48717b29d62569c423ab49efde137dd0 Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 13:15:15 -0400 Subject: [PATCH 06/23] add UserTokenCallAdapterFactory --- .../chat/java/services/framework/Client.java | 2 +- .../services/framework/DefaultClient.java | 35 ++++-- .../java/services/framework/UserClient.java | 4 +- .../UserTokenCallAdapterFactory.java | 117 ++++++++++++++++++ .../io/getstream/chat/java/CustomTest.java | 101 ++++++++++++--- .../getstream/chat/java/UserClientTest.java | 78 ------------ 6 files changed, 227 insertions(+), 110 deletions(-) create mode 100644 src/main/java/io/getstream/chat/java/services/framework/UserTokenCallAdapterFactory.java delete mode 100644 src/test/java/io/getstream/chat/java/UserClientTest.java diff --git a/src/main/java/io/getstream/chat/java/services/framework/Client.java b/src/main/java/io/getstream/chat/java/services/framework/Client.java index 075893ea2..f73b3ab33 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/Client.java +++ b/src/main/java/io/getstream/chat/java/services/framework/Client.java @@ -7,7 +7,7 @@ public interface Client { @NotNull TService create(Class svcClass); - default @NotNull TService create(Class svcClass, UserToken token) { + default @NotNull TService create(Class svcClass, String userToken) { return create(svcClass); } diff --git a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java index a5b2d2c2f..54efc38fc 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java @@ -8,6 +8,7 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import java.io.IOException; +import java.lang.reflect.Proxy; import java.nio.charset.StandardCharsets; import java.security.Key; import java.time.Duration; @@ -29,8 +30,8 @@ public class DefaultClient implements Client { private static final String API_DEFAULT_URL = "https://chat.stream-io-api.com"; private static volatile DefaultClient defaultInstance; - @NotNull private Retrofit retrofit; @NotNull private OkHttpClient okHttpClient; + @NotNull private Retrofit retrofit; @NotNull private final String apiSecret; @NotNull private final String apiKey; @NotNull private final Properties extendedProperties; @@ -152,14 +153,30 @@ public TService create(Class svcClass) { } @Override - public @NotNull TService create(Class svcClass, UserToken token) { - TService service = retrofit.create(svcClass); - - return (TService) java.lang.reflect.Proxy.newProxyInstance( - svcClass.getClassLoader(), - new Class[] { svcClass }, - new UserTokenCallProxy(okHttpClient, service, token) - ); + public @NotNull TService create(Class svcClass, String userToken) { + TService service = retrofit.create(svcClass); + return (TService) Proxy.newProxyInstance( + svcClass.getClassLoader(), + new Class[] { svcClass }, + new UserTokenCallProxy(okHttpClient, service, new UserToken(userToken)) + ); + } + + public @NotNull TService create2(Class svcClass, String userToken) { + // Create a tagged retrofit instance with a Call.Factory that tags all requests + + okhttp3.Call.Factory taggingFactory = request -> { + Request taggedRequest = request.newBuilder() + .tag(UserToken.class, new UserToken(userToken)) + .build(); + return okHttpClient.newCall(taggedRequest); + }; + + Retrofit taggedRetrofit = retrofit.newBuilder() + .callFactory(taggingFactory) + .build(); + + return taggedRetrofit.create(svcClass); } @NotNull diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserClient.java b/src/main/java/io/getstream/chat/java/services/framework/UserClient.java index fbb7a55d5..ee6b468d6 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserClient.java @@ -7,11 +7,11 @@ public final class UserClient implements Client { private final Client delegate; - private final UserToken userToken; + private final String userToken; public UserClient(Client delegate, String userToken) { this.delegate = delegate; - this.userToken = new UserToken(userToken); + this.userToken = userToken; } @Override diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallAdapterFactory.java b/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallAdapterFactory.java new file mode 100644 index 000000000..5b030e37a --- /dev/null +++ b/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallAdapterFactory.java @@ -0,0 +1,117 @@ +package io.getstream.chat.java.services.framework; + +import okhttp3.Request; +import org.jetbrains.annotations.NotNull; +import retrofit2.Call; +import retrofit2.CallAdapter; +import retrofit2.Retrofit; + +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; + +/** + * CallAdapter.Factory that wraps Retrofit calls to inject UserToken into requests. + * This is Retrofit's official API for customizing call behavior. + * + * Compared to reflection-based approach: + * + Uses public Retrofit API (future-proof) + * + Type-safe + * - Still requires a Call wrapper (can't avoid it in Retrofit's design) + */ +class UserTokenCallAdapterFactory extends CallAdapter.Factory { + private final UserToken token; + private final okhttp3.Call.Factory callFactory; + + UserTokenCallAdapterFactory(@NotNull UserToken token, @NotNull okhttp3.Call.Factory callFactory) { + this.token = token; + this.callFactory = callFactory; + } + + @Override + public CallAdapter get(Type returnType, Annotation[] annotations, Retrofit retrofit) { + // Only handle Call return types + if (getRawType(returnType) != Call.class) { + return null; + } + + if (!(returnType instanceof ParameterizedType)) { + throw new IllegalStateException( + "Call return type must be parameterized as Call or Call"); + } + + final Type responseType = getParameterUpperBound(0, (ParameterizedType) returnType); + + return new CallAdapter>() { + @Override + public Type responseType() { + return responseType; + } + + @Override + public Call adapt(Call call) { + return new UserTokenCall<>(call, token, callFactory); + } + }; + } + + /** + * Wrapper that injects UserToken tag into the request before execution. + */ + private static class UserTokenCall implements Call { + private final Call delegate; + private final UserToken token; + private final okhttp3.Call.Factory callFactory; + + UserTokenCall(Call delegate, UserToken token, okhttp3.Call.Factory callFactory) { + this.delegate = delegate; + this.token = token; + this.callFactory = callFactory; + } + + @Override + public retrofit2.Response execute() throws IOException { + return delegate.execute(); + } + + @Override + public void enqueue(retrofit2.Callback callback) { + delegate.enqueue(callback); + } + + @Override + public boolean isExecuted() { + return delegate.isExecuted(); + } + + @Override + public void cancel() { + delegate.cancel(); + } + + @Override + public boolean isCanceled() { + return delegate.isCanceled(); + } + + @Override + public Call clone() { + return new UserTokenCall<>(delegate.clone(), token, callFactory); + } + + @Override + public Request request() { + // This is where the magic happens - inject the token tag + return delegate.request().newBuilder() + .tag(UserToken.class, token) + .build(); + } + + @Override + public okio.Timeout timeout() { + return delegate.timeout(); + } + } +} + diff --git a/src/test/java/io/getstream/chat/java/CustomTest.java b/src/test/java/io/getstream/chat/java/CustomTest.java index 88ad89b44..f89c34158 100644 --- a/src/test/java/io/getstream/chat/java/CustomTest.java +++ b/src/test/java/io/getstream/chat/java/CustomTest.java @@ -1,7 +1,10 @@ package io.getstream.chat.java; import io.getstream.chat.java.models.User; +import io.getstream.chat.java.services.UserService; +import io.getstream.chat.java.services.framework.DefaultClient; import org.junit.jupiter.api.Test; +import java.lang.management.ManagementFactory; public class CustomTest { @@ -14,27 +17,85 @@ void customTest() throws Exception { } - @Test - void userReqTest() throws Exception { - var userId = "han_solo"; - var userToken = User.createToken("han_solo", null, null); - var response = User.list().filterCondition("id", userId).withUserToken(userToken).request(); - System.out.println("\n> " + response + "\n"); + @Test + void userReqTest() throws Exception { + var userId = "han_solo"; + var userToken = User.createToken("han_solo", null, null); + var response = User.list().filterCondition("id", userId).withUserToken(userToken).request(); + System.out.println("\n> " + response + "\n"); + } + + @Test + void measureClientCreate() throws Exception { + var userId = "han_solo"; + var userToken = User.createToken(userId, null, null); + + // Test creating a UserClient directly - should use Client-Side auth + var defaultClient = new DefaultClient(); + + var iterations = 10_000_000; + + // Warm up JVM to avoid cold start effects + for (int i = 0; i < 10_000; i++) { + defaultClient.create(UserService.class, userToken); + defaultClient.create2(UserService.class, userToken); } - @Test - void directClientTest() throws Exception { - var userId = "han_solo"; - var userToken = User.createToken("han_solo", null, null); - - // Test creating a UserClient directly - should use Client-Side auth - var defaultClient = io.getstream.chat.java.services.framework.Client.getInstance(); - var userClient = new io.getstream.chat.java.services.framework.UserClient(defaultClient, userToken); - - var response = User.list() - .filterCondition("id", userId) - .withClient(userClient) - .request(); - System.out.println("\n> Direct UserClient: " + response + "\n"); + // Get ThreadMXBean for accurate memory allocation tracking + com.sun.management.ThreadMXBean threadBean = + (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean(); + + // Measure first test + long allocatedBefore1 = threadBean.getCurrentThreadAllocatedBytes(); + long startTime = System.nanoTime(); + for (int i = 0; i < iterations; i++) { + defaultClient.create(UserService.class, userToken); } + long endTime = System.nanoTime(); + long allocatedAfter1 = threadBean.getCurrentThreadAllocatedBytes(); + long elapsedTime1 = endTime - startTime; + long allocated1 = allocatedAfter1 - allocatedBefore1; + + System.out.println("========================================================="); + + System.out.println("> First loop elapsed time: " + (elapsedTime1 / 1_000_000) + " ms"); + System.out.println("> First loop memory allocated: " + (allocated1 / 1024 / 1024) + " MB"); + System.out.println("> First loop avg time per call: " + (elapsedTime1 / (double) iterations) + " ns"); + System.out.println("> First loop avg memory per call: " + (allocated1 / (double) iterations) + " bytes"); + + // Measure second test + long allocatedBefore2 = threadBean.getCurrentThreadAllocatedBytes(); + startTime = System.nanoTime(); + for (int i = 0; i < iterations; i++) { + defaultClient.create2(UserService.class, userToken); + } + endTime = System.nanoTime(); + long allocatedAfter2 = threadBean.getCurrentThreadAllocatedBytes(); + long elapsedTime2 = endTime - startTime; + long allocated2 = allocatedAfter2 - allocatedBefore2; + + System.out.println("> Second loop elapsed time: " + (elapsedTime2 / 1_000_000) + " ms"); + System.out.println("> Second loop memory allocated: " + (allocated2 / 1024 / 1024) + " MB"); + System.out.println("> Second loop avg time per call: " + (elapsedTime2 / (double) iterations) + " ns"); + System.out.println("> Second loop avg memory per call: " + (allocated2 / (double) iterations) + " bytes"); + + // Performance comparison + if (elapsedTime1 < elapsedTime2) { + double timesFaster = (double) elapsedTime2 / elapsedTime1; + System.out.println("> create is " + String.format("%.2fx", timesFaster) + " faster than create2"); + } else { + double timesFaster = (double) elapsedTime1 / elapsedTime2; + System.out.println("> create2 is " + String.format("%.2fx", timesFaster) + " faster than create"); + } + + if (allocated1 < allocated2) { + double timesLess = (double) allocated2 / allocated1; + System.out.println("> create allocates " + String.format("%.2fx", timesLess) + " less memory than create2"); + } else { + double timesLess = (double) allocated1 / allocated2; + System.out.println("> create2 allocates " + String.format("%.2fx", timesLess) + " less memory than create"); + } + + System.out.println("======================================================================"); + } } diff --git a/src/test/java/io/getstream/chat/java/UserClientTest.java b/src/test/java/io/getstream/chat/java/UserClientTest.java deleted file mode 100644 index 517da26d8..000000000 --- a/src/test/java/io/getstream/chat/java/UserClientTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package io.getstream.chat.java; - -import io.getstream.chat.java.models.User; -import io.getstream.chat.java.services.framework.DefaultClient; -import io.getstream.chat.java.services.framework.UserClient; -import org.junit.jupiter.api.Test; - -/** - * Demonstrates how to use UserClient for client-side requests with user tokens. - * - *

Instead of server-side auth (API key + secret), UserClient allows you to make requests - * authenticated with a user token (JWT). - */ -public class UserClientTest { - - @Test - public void demonstrateUserClientUsage() throws Exception { - // Server-side setup - generate a user token for a specific user - String userToken = - User.createToken( - System.getProperty("io.getstream.chat.apiSecret"), - "test-user-id", - null, // no expiration - null // default issued at - ); - - // Create a client-side client for this user - DefaultClient serverClient = DefaultClient.getInstance(); - UserClient userClient = new UserClient(serverClient, userToken); - - // Now you can make requests on behalf of this user - // The token will be automatically included in the Authorization header - - // Example: Query channels visible to this user - // Channel.list().withClient(userClient).request(); - - // Example: Send a message as this user - // Message.send("messaging", "general") - // .withClient(userClient) - // .message(MessageRequestObject.builder() - // .text("Hello from client-side!") - // .userId("test-user-id") - // .build()) - // .request(); - - System.out.println( - "UserClient created successfully with token: " + userToken.substring(0, 20) + "..."); - } - - @Test - public void demonstrateMultipleUserClients() throws Exception { - // You can create multiple UserClients for different users - // Each shares the same underlying connection pool and thread pool - - String apiSecret = System.getProperty("io.getstream.chat.apiSecret"); - DefaultClient serverClient = DefaultClient.getInstance(); - - UserClient user1Client = - new UserClient(serverClient, User.createToken(apiSecret, "user-1", null, null)); - - UserClient user2Client = - new UserClient(serverClient, User.createToken(apiSecret, "user-2", null, null)); - - // Both clients share the same connection pool (efficient!) - // But each request is authenticated with its respective user token - - // You can make parallel requests with different user contexts: - // CompletableFuture future1 = CompletableFuture.supplyAsync(() -> - // Message.send("messaging", "channel1").withClient(user1Client).request() - // ); - // - // CompletableFuture future2 = CompletableFuture.supplyAsync(() -> - // Message.send("messaging", "channel2").withClient(user2Client).request() - // ); - - System.out.println("Multiple UserClients can coexist safely"); - } -} From 9bcf3147b5777af6c58ee36865929c4ff3fe4abb Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 13:41:14 -0400 Subject: [PATCH 07/23] create UserServiceFactory --- .../services/framework/DefaultClient.java | 63 +--------- .../java/services/framework/UserCall.java | 59 +++++++++ .../framework/UserServiceFactory.java | 24 ++++ .../UserTokenCallAdapterFactory.java | 117 ------------------ .../io/getstream/chat/java/CustomTest.java | 35 +++--- 5 files changed, 104 insertions(+), 194 deletions(-) create mode 100644 src/main/java/io/getstream/chat/java/services/framework/UserCall.java create mode 100644 src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java delete mode 100644 src/main/java/io/getstream/chat/java/services/framework/UserTokenCallAdapterFactory.java diff --git a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java index 54efc38fc..f1f92ef85 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java @@ -8,7 +8,6 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import java.io.IOException; -import java.lang.reflect.Proxy; import java.nio.charset.StandardCharsets; import java.security.Key; import java.time.Duration; @@ -32,6 +31,7 @@ public class DefaultClient implements Client { private static volatile DefaultClient defaultInstance; @NotNull private OkHttpClient okHttpClient; @NotNull private Retrofit retrofit; + @NotNull private UserServiceFactory serviceFactory; @NotNull private final String apiSecret; @NotNull private final String apiKey; @NotNull private final Properties extendedProperties; @@ -76,6 +76,7 @@ public DefaultClient(Properties properties) { this.apiSecret = apiSecret.toString(); this.apiKey = apiKey.toString(); this.retrofit = buildRetrofitClient(); + this.serviceFactory = new UserServiceFactory(retrofit); } private Retrofit buildRetrofitClient() { @@ -154,12 +155,7 @@ public TService create(Class svcClass) { @Override public @NotNull TService create(Class svcClass, String userToken) { - TService service = retrofit.create(svcClass); - return (TService) Proxy.newProxyInstance( - svcClass.getClassLoader(), - new Class[] { svcClass }, - new UserTokenCallProxy(okHttpClient, service, new UserToken(userToken)) - ); + return serviceFactory.create(svcClass, new UserToken(userToken)); } public @NotNull TService create2(Class svcClass, String userToken) { @@ -281,57 +277,4 @@ private static boolean hasFailOnUnknownProperties(@NotNull Properties properties var hasEnabled = properties.getOrDefault(propName, "false"); return Boolean.parseBoolean(hasEnabled.toString()); } - - private static class UserCall implements retrofit2.Call { - private final retrofit2.Call delegate; - private final UserToken token; - - UserCall(retrofit2.Call delegate, UserToken token) { - this.delegate = delegate; - this.token = token; - } - - @Override - public retrofit2.Response execute() throws IOException { - return delegate.execute(); - } - - @Override - public void enqueue(retrofit2.Callback callback) { - delegate.enqueue(callback); - } - - @Override - public boolean isExecuted() { - return delegate.isExecuted(); - } - - @Override - public void cancel() { - delegate.cancel(); - } - - @Override - public boolean isCanceled() { - return delegate.isCanceled(); - } - - @Override - public retrofit2.Call clone() { - return new UserCall<>(delegate.clone(), token); - } - - @Override - public Request request() { - Request original = delegate.request(); - return original.newBuilder() - .tag(UserToken.class, token) - .build(); - } - - @Override - public okio.Timeout timeout() { - return delegate.timeout(); - } - } } diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserCall.java b/src/main/java/io/getstream/chat/java/services/framework/UserCall.java new file mode 100644 index 000000000..a1767ce5f --- /dev/null +++ b/src/main/java/io/getstream/chat/java/services/framework/UserCall.java @@ -0,0 +1,59 @@ +package io.getstream.chat.java.services.framework; + +import okhttp3.Request; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; + +public class UserCall implements retrofit2.Call { + private final retrofit2.Call delegate; + private final UserToken token; + + UserCall(retrofit2.Call delegate, UserToken token) { + this.delegate = delegate; + this.token = token; + } + + @Override + public @NotNull retrofit2.Response execute() throws IOException { + return delegate.execute(); + } + + @Override + public void enqueue(@NotNull retrofit2.Callback callback) { + delegate.enqueue(callback); + } + + @Override + public boolean isExecuted() { + return delegate.isExecuted(); + } + + @Override + public void cancel() { + delegate.cancel(); + } + + @Override + public boolean isCanceled() { + return delegate.isCanceled(); + } + + @Override + public @NotNull retrofit2.Call clone() { + return new UserCall<>(delegate.clone(), token); + } + + @Override + public @NotNull Request request() { + Request original = delegate.request(); + return original.newBuilder() + .tag(UserToken.class, token) + .build(); + } + + @Override + public @NotNull okio.Timeout timeout() { + return delegate.timeout(); + } +} \ No newline at end of file diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java new file mode 100644 index 000000000..20a202733 --- /dev/null +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java @@ -0,0 +1,24 @@ +package io.getstream.chat.java.services.framework; + +import retrofit2.Retrofit; + +import static java.lang.reflect.Proxy.newProxyInstance; + +class UserServiceFactory { + + private final Retrofit retrofit; + + public UserServiceFactory(Retrofit retrofit) { + this.retrofit = retrofit; + } + + @SuppressWarnings("unchecked") + public final TService create(Class svcClass, UserToken userToken) { + return (TService) newProxyInstance( + svcClass.getClassLoader(), + new Class[] { svcClass }, + new UserTokenCallProxy(retrofit.callFactory(), retrofit.create(svcClass), userToken) + ); + } + +} diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallAdapterFactory.java b/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallAdapterFactory.java deleted file mode 100644 index 5b030e37a..000000000 --- a/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallAdapterFactory.java +++ /dev/null @@ -1,117 +0,0 @@ -package io.getstream.chat.java.services.framework; - -import okhttp3.Request; -import org.jetbrains.annotations.NotNull; -import retrofit2.Call; -import retrofit2.CallAdapter; -import retrofit2.Retrofit; - -import java.io.IOException; -import java.lang.annotation.Annotation; -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.Type; - -/** - * CallAdapter.Factory that wraps Retrofit calls to inject UserToken into requests. - * This is Retrofit's official API for customizing call behavior. - * - * Compared to reflection-based approach: - * + Uses public Retrofit API (future-proof) - * + Type-safe - * - Still requires a Call wrapper (can't avoid it in Retrofit's design) - */ -class UserTokenCallAdapterFactory extends CallAdapter.Factory { - private final UserToken token; - private final okhttp3.Call.Factory callFactory; - - UserTokenCallAdapterFactory(@NotNull UserToken token, @NotNull okhttp3.Call.Factory callFactory) { - this.token = token; - this.callFactory = callFactory; - } - - @Override - public CallAdapter get(Type returnType, Annotation[] annotations, Retrofit retrofit) { - // Only handle Call return types - if (getRawType(returnType) != Call.class) { - return null; - } - - if (!(returnType instanceof ParameterizedType)) { - throw new IllegalStateException( - "Call return type must be parameterized as Call or Call"); - } - - final Type responseType = getParameterUpperBound(0, (ParameterizedType) returnType); - - return new CallAdapter>() { - @Override - public Type responseType() { - return responseType; - } - - @Override - public Call adapt(Call call) { - return new UserTokenCall<>(call, token, callFactory); - } - }; - } - - /** - * Wrapper that injects UserToken tag into the request before execution. - */ - private static class UserTokenCall implements Call { - private final Call delegate; - private final UserToken token; - private final okhttp3.Call.Factory callFactory; - - UserTokenCall(Call delegate, UserToken token, okhttp3.Call.Factory callFactory) { - this.delegate = delegate; - this.token = token; - this.callFactory = callFactory; - } - - @Override - public retrofit2.Response execute() throws IOException { - return delegate.execute(); - } - - @Override - public void enqueue(retrofit2.Callback callback) { - delegate.enqueue(callback); - } - - @Override - public boolean isExecuted() { - return delegate.isExecuted(); - } - - @Override - public void cancel() { - delegate.cancel(); - } - - @Override - public boolean isCanceled() { - return delegate.isCanceled(); - } - - @Override - public Call clone() { - return new UserTokenCall<>(delegate.clone(), token, callFactory); - } - - @Override - public Request request() { - // This is where the magic happens - inject the token tag - return delegate.request().newBuilder() - .tag(UserToken.class, token) - .build(); - } - - @Override - public okio.Timeout timeout() { - return delegate.timeout(); - } - } -} - diff --git a/src/test/java/io/getstream/chat/java/CustomTest.java b/src/test/java/io/getstream/chat/java/CustomTest.java index f89c34158..144e2e604 100644 --- a/src/test/java/io/getstream/chat/java/CustomTest.java +++ b/src/test/java/io/getstream/chat/java/CustomTest.java @@ -5,13 +5,14 @@ import io.getstream.chat.java.services.framework.DefaultClient; import org.junit.jupiter.api.Test; import java.lang.management.ManagementFactory; +import java.util.concurrent.TimeUnit; public class CustomTest { @Test void customTest() throws Exception { - var userId = "han_solo"; - var userToken = User.createToken("han_solo", null, null); + var userId = "admin"; + var userToken = User.createToken(userId, null, null); var response = User.list().userId(userId).filterCondition("id", userId).request(); System.out.println(response); } @@ -19,21 +20,21 @@ void customTest() throws Exception { @Test void userReqTest() throws Exception { - var userId = "han_solo"; - var userToken = User.createToken("han_solo", null, null); + var userId = "admin"; + var userToken = User.createToken(userId, null, null); var response = User.list().filterCondition("id", userId).withUserToken(userToken).request(); - System.out.println("\n> " + response + "\n"); + System.out.println("\n!.!.! " + response + "\n"); } @Test void measureClientCreate() throws Exception { - var userId = "han_solo"; + var userId = "admin"; var userToken = User.createToken(userId, null, null); // Test creating a UserClient directly - should use Client-Side auth var defaultClient = new DefaultClient(); - var iterations = 10_000_000; + var iterations = 100_000_000; // Warm up JVM to avoid cold start effects for (int i = 0; i < 10_000; i++) { @@ -53,14 +54,14 @@ void measureClientCreate() throws Exception { } long endTime = System.nanoTime(); long allocatedAfter1 = threadBean.getCurrentThreadAllocatedBytes(); - long elapsedTime1 = endTime - startTime; + long elapsedTimeInNs1 = endTime - startTime; long allocated1 = allocatedAfter1 - allocatedBefore1; System.out.println("========================================================="); - System.out.println("> First loop elapsed time: " + (elapsedTime1 / 1_000_000) + " ms"); + System.out.println("> First loop elapsed time: " + TimeUnit.NANOSECONDS.toMillis(elapsedTimeInNs1) + " ms"); System.out.println("> First loop memory allocated: " + (allocated1 / 1024 / 1024) + " MB"); - System.out.println("> First loop avg time per call: " + (elapsedTime1 / (double) iterations) + " ns"); + System.out.println("> First loop avg time per call: " + (elapsedTimeInNs1 / (double) iterations) + " ns"); System.out.println("> First loop avg memory per call: " + (allocated1 / (double) iterations) + " bytes"); // Measure second test @@ -71,20 +72,20 @@ void measureClientCreate() throws Exception { } endTime = System.nanoTime(); long allocatedAfter2 = threadBean.getCurrentThreadAllocatedBytes(); - long elapsedTime2 = endTime - startTime; + long elapsedTimeInNs2 = endTime - startTime; long allocated2 = allocatedAfter2 - allocatedBefore2; - System.out.println("> Second loop elapsed time: " + (elapsedTime2 / 1_000_000) + " ms"); + System.out.println("> Second loop elapsed time: " + TimeUnit.NANOSECONDS.toMillis(elapsedTimeInNs2) + " ms"); System.out.println("> Second loop memory allocated: " + (allocated2 / 1024 / 1024) + " MB"); - System.out.println("> Second loop avg time per call: " + (elapsedTime2 / (double) iterations) + " ns"); + System.out.println("> Second loop avg time per call: " + (elapsedTimeInNs2 / (double) iterations) + " ns"); System.out.println("> Second loop avg memory per call: " + (allocated2 / (double) iterations) + " bytes"); // Performance comparison - if (elapsedTime1 < elapsedTime2) { - double timesFaster = (double) elapsedTime2 / elapsedTime1; + if (elapsedTimeInNs1 < elapsedTimeInNs2) { + double timesFaster = (double) elapsedTimeInNs2 / elapsedTimeInNs1; System.out.println("> create is " + String.format("%.2fx", timesFaster) + " faster than create2"); } else { - double timesFaster = (double) elapsedTime1 / elapsedTime2; + double timesFaster = (double) elapsedTimeInNs1 / elapsedTimeInNs2; System.out.println("> create2 is " + String.format("%.2fx", timesFaster) + " faster than create"); } @@ -96,6 +97,6 @@ void measureClientCreate() throws Exception { System.out.println("> create2 allocates " + String.format("%.2fx", timesLess) + " less memory than create"); } - System.out.println("======================================================================"); + System.out.println("========================================================="); } } From bf955f0a2488ae784316cfeb56a00622f62cd68f Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 13:43:42 -0400 Subject: [PATCH 08/23] make UserTokenCallProxy generic --- .../chat/java/services/framework/UserServiceFactory.java | 2 +- .../chat/java/services/framework/UserTokenCallProxy.java | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java index 20a202733..2af13a679 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java @@ -17,7 +17,7 @@ public final TService create(Class svcClass, UserToken user return (TService) newProxyInstance( svcClass.getClassLoader(), new Class[] { svcClass }, - new UserTokenCallProxy(retrofit.callFactory(), retrofit.create(svcClass), userToken) + new UserTokenCallProxy<>(retrofit.callFactory(), retrofit.create(svcClass), userToken) ); } diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallProxy.java b/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallProxy.java index c480683e2..87aab9dcc 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallProxy.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallProxy.java @@ -14,14 +14,14 @@ * This approach allows per-call authentication without creating multiple OkHttpClient * instances, making it suitable for multi-tenant systems with thousands of users. */ -class UserTokenCallProxy implements InvocationHandler { +class UserTokenCallProxy implements InvocationHandler { private static volatile Field rawCallField; private final Call.Factory callFactory; - private final Object delegate; + private final TService delegate; private final UserToken token; - UserTokenCallProxy(@NotNull Call.Factory callFactory, @NotNull Object delegate, @NotNull UserToken token) { + UserTokenCallProxy(@NotNull Call.Factory callFactory, @NotNull TService delegate, @NotNull UserToken token) { this.callFactory = callFactory; this.delegate = delegate; this.token = token; @@ -32,7 +32,7 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl Object result = method.invoke(delegate, args); // If the result is a Retrofit Call, inject the user token - if (result instanceof retrofit2.Call) { + if (result instanceof retrofit2.Call) { return injectTokenIntoCall((retrofit2.Call) result); } From 6f5fd4880dcb928cfbd4271a36dd21890ff56399 Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 14:06:01 -0400 Subject: [PATCH 09/23] refactor namings --- .../services/framework/DefaultClient.java | 17 +----- .../framework/UserServiceFactory.java | 21 +------ .../framework/UserServiceFactoryProxy.java | 60 ++++++++++++++++++ .../framework/UserServiceFactoryTagging.java | 61 +++++++++++++++++++ ...lProxy.java => UserTokenCallRewriter.java} | 6 +- .../io/getstream/chat/java/CustomTest.java | 2 +- 6 files changed, 129 insertions(+), 38 deletions(-) create mode 100644 src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryProxy.java create mode 100644 src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryTagging.java rename src/main/java/io/getstream/chat/java/services/framework/{UserTokenCallProxy.java => UserTokenCallRewriter.java} (91%) diff --git a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java index f1f92ef85..f32f55676 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java @@ -76,7 +76,7 @@ public DefaultClient(Properties properties) { this.apiSecret = apiSecret.toString(); this.apiKey = apiKey.toString(); this.retrofit = buildRetrofitClient(); - this.serviceFactory = new UserServiceFactory(retrofit); + this.serviceFactory = new UserServiceFactoryProxy(retrofit); } private Retrofit buildRetrofitClient() { @@ -159,20 +159,7 @@ public TService create(Class svcClass) { } public @NotNull TService create2(Class svcClass, String userToken) { - // Create a tagged retrofit instance with a Call.Factory that tags all requests - - okhttp3.Call.Factory taggingFactory = request -> { - Request taggedRequest = request.newBuilder() - .tag(UserToken.class, new UserToken(userToken)) - .build(); - return okHttpClient.newCall(taggedRequest); - }; - - Retrofit taggedRetrofit = retrofit.newBuilder() - .callFactory(taggingFactory) - .build(); - - return taggedRetrofit.create(svcClass); + return new UserServiceFactoryTagging(retrofit).create(svcClass, new UserToken(userToken)); } @NotNull diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java index 2af13a679..cb74be04a 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java @@ -1,24 +1,7 @@ package io.getstream.chat.java.services.framework; -import retrofit2.Retrofit; +public interface UserServiceFactory { -import static java.lang.reflect.Proxy.newProxyInstance; - -class UserServiceFactory { - - private final Retrofit retrofit; - - public UserServiceFactory(Retrofit retrofit) { - this.retrofit = retrofit; - } - - @SuppressWarnings("unchecked") - public final TService create(Class svcClass, UserToken userToken) { - return (TService) newProxyInstance( - svcClass.getClassLoader(), - new Class[] { svcClass }, - new UserTokenCallProxy<>(retrofit.callFactory(), retrofit.create(svcClass), userToken) - ); - } + TService create(Class svcClass, UserToken userToken); } diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryProxy.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryProxy.java new file mode 100644 index 000000000..20c578f5c --- /dev/null +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryProxy.java @@ -0,0 +1,60 @@ +package io.getstream.chat.java.services.framework; + +import retrofit2.Retrofit; + +import static java.lang.reflect.Proxy.newProxyInstance; + +/** + * User-aware service factory that uses dynamic proxies to inject user tokens. + *

+ * This implementation wraps Retrofit service interfaces with a dynamic proxy that intercepts + * method calls and delegates to {@link UserTokenCallRewriter} for token injection. + *

+ * Mechanism: Uses Java reflection {@link java.lang.reflect.Proxy} to wrap the service + * interface and inject user tokens at method invocation time. + *

+ * Trade-offs: + *

    + *
  • Pros: Reuses single Retrofit instance, flexible interception point
  • + *
  • Cons: Reflection overhead on every method call, slightly more complex debugging
  • + *
+ *

+ * Thread-safety: Immutable and thread-safe once constructed. The underlying proxy + * delegation is also thread-safe. + * + * @see UserTokenCallRewriter + */ +final class UserServiceFactoryProxy implements UserServiceFactory { + + private final Retrofit retrofit; + + /** + * Constructs a new proxy-based user service factory. + * + * @param retrofit the Retrofit instance to create services from + */ + public UserServiceFactoryProxy(Retrofit retrofit) { + this.retrofit = retrofit; + } + + /** + * Creates a user-aware service instance using a dynamic proxy. + *

+ * The returned service is a dynamic proxy that intercepts all method calls and delegates + * to the underlying Retrofit service while injecting the user token. + * + * @param svcClass the Retrofit service interface class + * @param userToken the user token to inject into all requests from this service + * @param the service type + * @return a proxied service instance that injects the user token + */ + @SuppressWarnings("unchecked") + public final TService create(Class svcClass, UserToken userToken) { + return (TService) newProxyInstance( + svcClass.getClassLoader(), + new Class[] { svcClass }, + new UserTokenCallRewriter<>(retrofit.callFactory(), retrofit.create(svcClass), userToken) + ); + } + +} diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryTagging.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryTagging.java new file mode 100644 index 000000000..4171b1fde --- /dev/null +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryTagging.java @@ -0,0 +1,61 @@ +package io.getstream.chat.java.services.framework; + +import okhttp3.Request; +import retrofit2.Retrofit; + +/** + * User-aware service factory that tags OkHttp requests with user tokens. + *

+ * This implementation wraps the OkHttp call factory to automatically attach a {@link UserToken} + * as a request tag. The token can then be retrieved by interceptors for authentication purposes. + *

+ * Mechanism: Creates a new Retrofit instance with a custom call factory that tags + * each request before delegating to the underlying call factory. + *

+ * Trade-offs: + *

    + *
  • Pros: Clean, type-safe, works with any Retrofit service, no reflection overhead
  • + *
  • Cons: Creates a new Retrofit instance per service call (minor memory overhead)
  • + *
+ *

+ * Thread-safety: Immutable and thread-safe once constructed. + */ +final class UserServiceFactoryTagging implements UserServiceFactory { + + private final Retrofit retrofit; + + /** + * Constructs a new tagging-based user service factory. + * + * @param retrofit the base Retrofit instance to derive user-specific instances from + */ + public UserServiceFactoryTagging(Retrofit retrofit) { + this.retrofit = retrofit; + } + + /** + * Creates a user-aware service instance that automatically tags requests with the user token. + *

+ * The returned service wraps each OkHttp request with a {@link UserToken} tag that can be + * retrieved by interceptors using {@code request.tag(UserToken.class)}. + * + * @param svcClass the Retrofit service interface class + * @param userToken the user token to attach to all requests from this service + * @param the service type + * @return a service instance that tags requests with the user token + */ + @SuppressWarnings("unchecked") + public final TService create(Class svcClass, UserToken userToken) { + Retrofit taggedRetrofit = retrofit.newBuilder() + .callFactory(request -> { + Request taggedRequest = request.newBuilder() + .tag(UserToken.class, userToken) + .build(); + return retrofit.callFactory().newCall(taggedRequest); + }) + .build(); + + return taggedRetrofit.create(svcClass); + } + +} diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallProxy.java b/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallRewriter.java similarity index 91% rename from src/main/java/io/getstream/chat/java/services/framework/UserTokenCallProxy.java rename to src/main/java/io/getstream/chat/java/services/framework/UserTokenCallRewriter.java index 87aab9dcc..af072ba10 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallProxy.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallRewriter.java @@ -14,14 +14,14 @@ * This approach allows per-call authentication without creating multiple OkHttpClient * instances, making it suitable for multi-tenant systems with thousands of users. */ -class UserTokenCallProxy implements InvocationHandler { +class UserTokenCallRewriter implements InvocationHandler { private static volatile Field rawCallField; private final Call.Factory callFactory; private final TService delegate; private final UserToken token; - UserTokenCallProxy(@NotNull Call.Factory callFactory, @NotNull TService delegate, @NotNull UserToken token) { + UserTokenCallRewriter(@NotNull Call.Factory callFactory, @NotNull TService delegate, @NotNull UserToken token) { this.callFactory = callFactory; this.delegate = delegate; this.token = token; @@ -45,7 +45,7 @@ private retrofit2.Call injectTokenIntoCall(retrofit2.Call originalCall) { try { // Cache field lookup for performance (double-checked locking) if (rawCallField == null) { - synchronized (UserTokenCallProxy.class) { + synchronized (UserTokenCallRewriter.class) { if (rawCallField == null) { rawCallField = clonedCall.getClass().getDeclaredField("rawCall"); rawCallField.setAccessible(true); diff --git a/src/test/java/io/getstream/chat/java/CustomTest.java b/src/test/java/io/getstream/chat/java/CustomTest.java index 144e2e604..11e1eb19d 100644 --- a/src/test/java/io/getstream/chat/java/CustomTest.java +++ b/src/test/java/io/getstream/chat/java/CustomTest.java @@ -34,7 +34,7 @@ void measureClientCreate() throws Exception { // Test creating a UserClient directly - should use Client-Side auth var defaultClient = new DefaultClient(); - var iterations = 100_000_000; + var iterations = 30_000_000; // Warm up JVM to avoid cold start effects for (int i = 0; i < 10_000; i++) { From 2d7ca9030797e2f1b23cf686a425daf7cc3f72f5 Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 14:15:43 -0400 Subject: [PATCH 10/23] add TokenInjectionException --- .../framework/UserTokenCallRewriter.java | 12 +++++++---- .../internal/TokenInjectionException.java | 18 +++++++++++++++++ .../io/getstream/chat/java/CustomTest.java | 20 +++++++++++++++---- 3 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 src/main/java/io/getstream/chat/java/services/framework/internal/TokenInjectionException.java diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallRewriter.java b/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallRewriter.java index af072ba10..e0fa45160 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallRewriter.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallRewriter.java @@ -1,5 +1,6 @@ package io.getstream.chat.java.services.framework; +import io.getstream.chat.java.services.framework.internal.TokenInjectionException; import okhttp3.Call; import okhttp3.Request; import org.jetbrains.annotations.NotNull; @@ -36,10 +37,13 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl return injectTokenIntoCall((retrofit2.Call) result); } - return result; + // All service methods must return Call for token injection + throw new TokenInjectionException( + "Method " + method.getName() + " on " + delegate.getClass().getName() + + " did not return retrofit2.Call. User token injection requires all service methods to return Call."); } - private retrofit2.Call injectTokenIntoCall(retrofit2.Call originalCall) { + private retrofit2.Call injectTokenIntoCall(retrofit2.Call originalCall) throws TokenInjectionException { retrofit2.Call clonedCall = originalCall.clone(); try { @@ -67,11 +71,11 @@ private retrofit2.Call injectTokenIntoCall(retrofit2.Call originalCall) { return clonedCall; } catch (NoSuchFieldException e) { // If Retrofit's internal structure changes, provide clear error message - throw new RuntimeException( + throw new TokenInjectionException( "Retrofit internal structure changed. Field 'rawCall' not found in " + clonedCall.getClass().getName() + ". Update client implementation.", e); } catch (IllegalAccessException e) { - throw new RuntimeException("Failed to inject token into call", e); + throw new TokenInjectionException("Failed to inject token into call", e); } } } diff --git a/src/main/java/io/getstream/chat/java/services/framework/internal/TokenInjectionException.java b/src/main/java/io/getstream/chat/java/services/framework/internal/TokenInjectionException.java new file mode 100644 index 000000000..55bbfe32e --- /dev/null +++ b/src/main/java/io/getstream/chat/java/services/framework/internal/TokenInjectionException.java @@ -0,0 +1,18 @@ +package io.getstream.chat.java.services.framework.internal; + +/** + * Thrown when user token injection into a request fails. + * This can happen if: + * - A service method doesn't return retrofit2.Call + * - Retrofit's internal structure changes and reflection fails + */ +public class TokenInjectionException extends Exception { + public TokenInjectionException(String message) { + super(message); + } + + public TokenInjectionException(String message, Throwable cause) { + super(message, cause); + } +} + diff --git a/src/test/java/io/getstream/chat/java/CustomTest.java b/src/test/java/io/getstream/chat/java/CustomTest.java index 11e1eb19d..d54f4f86a 100644 --- a/src/test/java/io/getstream/chat/java/CustomTest.java +++ b/src/test/java/io/getstream/chat/java/CustomTest.java @@ -1,11 +1,16 @@ package io.getstream.chat.java; +import io.getstream.chat.java.exceptions.StreamException; +import io.getstream.chat.java.models.Thread; import io.getstream.chat.java.models.User; import io.getstream.chat.java.services.UserService; import io.getstream.chat.java.services.framework.DefaultClient; import org.junit.jupiter.api.Test; import java.lang.management.ManagementFactory; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; public class CustomTest { @@ -20,10 +25,17 @@ void customTest() throws Exception { @Test void userReqTest() throws Exception { - var userId = "admin"; - var userToken = User.createToken(userId, null, null); - var response = User.list().filterCondition("id", userId).withUserToken(userToken).request(); - System.out.println("\n!.!.! " + response + "\n"); + var userIds = List.of( "admin", "MWRYXIHURH", "SRLOTCPYQS", "IOEXOFYTRH", "RIOPOIGMDQ", "XUXJMHTNOI", "QQASWMJEQI"); + + for (var userId : userIds) { + var userToken = User.createToken(userId, null, null); + User.list().filterCondition("id", userId).withUserToken(userToken).requestAsync( + userListResponse -> System.out.println("\n!.!.! " + userListResponse + "\n"), + e -> {} + ); + } + + java.lang.Thread.sleep(10000); } @Test From e35ff3ee27fc9e99ade38942a05ab69ca41f3a39 Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 14:31:53 -0400 Subject: [PATCH 11/23] add UserServiceFactorySelector & fix visibility --- .../services/framework/DefaultClient.java | 2 +- .../java/services/framework/UserCall.java | 2 +- .../framework/UserServiceFactory.java | 2 +- .../framework/UserServiceFactorySelector.java | 91 +++++++++++++++++++ .../framework/UserServiceFactoryTagging.java | 2 +- .../java/services/framework/UserToken.java | 10 +- .../internal/TokenInjectionException.java | 2 +- 7 files changed, 99 insertions(+), 12 deletions(-) create mode 100644 src/main/java/io/getstream/chat/java/services/framework/UserServiceFactorySelector.java diff --git a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java index f32f55676..6ad130bd6 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java @@ -76,7 +76,7 @@ public DefaultClient(Properties properties) { this.apiSecret = apiSecret.toString(); this.apiKey = apiKey.toString(); this.retrofit = buildRetrofitClient(); - this.serviceFactory = new UserServiceFactoryProxy(retrofit); + this.serviceFactory = new UserServiceFactorySelector(retrofit); } private Retrofit buildRetrofitClient() { diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserCall.java b/src/main/java/io/getstream/chat/java/services/framework/UserCall.java index a1767ce5f..8dae94ebc 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserCall.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserCall.java @@ -5,7 +5,7 @@ import java.io.IOException; -public class UserCall implements retrofit2.Call { +class UserCall implements retrofit2.Call { private final retrofit2.Call delegate; private final UserToken token; diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java index cb74be04a..5422d6e97 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java @@ -1,6 +1,6 @@ package io.getstream.chat.java.services.framework; -public interface UserServiceFactory { +interface UserServiceFactory { TService create(Class svcClass, UserToken userToken); diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactorySelector.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactorySelector.java new file mode 100644 index 000000000..2f552bc14 --- /dev/null +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactorySelector.java @@ -0,0 +1,91 @@ +package io.getstream.chat.java.services.framework; + +import retrofit2.Retrofit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Smart user-aware service factory with automatic fallback mechanism. + *

+ * This implementation attempts to use {@link UserServiceFactoryProxy} (more efficient) + * and automatically falls back to {@link UserServiceFactoryTagging} if reflection fails + * or the Retrofit API has changed. + *

+ * Fallback Strategy: + *

    + *
  1. First attempt: Use proxy-based approach (reuses Retrofit instance)
  2. + *
  3. On failure: Switch to tagging-based approach (more compatible)
  4. + *
  5. Once switched: All subsequent calls use the fallback implementation
  6. + *
+ *

+ * Thread-safety: Thread-safe. Uses atomic reference for fallback state tracking. + * Multiple threads may attempt fallback simultaneously, but only one will win. + */ +final class UserServiceFactorySelector implements UserServiceFactory { + + private final UserServiceFactory proxyFactory; + private final UserServiceFactory taggingFactory; + private final AtomicReference activeFactory; + + /** + * Constructs a new smart factory with fallback capability. + * + * @param retrofit the Retrofit instance to create services from + */ + public UserServiceFactorySelector(Retrofit retrofit) { + this.proxyFactory = new UserServiceFactoryProxy(retrofit); + this.taggingFactory = new UserServiceFactoryTagging(retrofit); + + // Verify proxy approach is viable before setting default + UserServiceFactory defaultFactory = proxyFactory; + try { + // Check if we can access the rawCall field that UserTokenCallRewriter needs + Class retrofitCallClass = Class.forName("retrofit2.OkHttpCall"); + retrofitCallClass.getDeclaredField("rawCall"); + // If we get here, proxy should work + } catch (Throwable e) { + // Proxy approach won't work, use tagging as default + defaultFactory = taggingFactory; + } + + this.activeFactory = new AtomicReference<>(defaultFactory); + } + + /** + * Creates a user-aware service instance with automatic fallback. + *

+ * Attempts to use the proxy implementation first. If it fails (due to reflection issues, + * API changes, or other errors), automatically switches to the tagging implementation + * and retries. + * + * @param svcClass the Retrofit service interface class + * @param userToken the user token to inject into all requests from this service + * @param the service type + * @return a service instance that injects the user token + * @throws RuntimeException if both implementations fail + */ + @Override + public TService create(Class svcClass, UserToken userToken) { + UserServiceFactory factory = activeFactory.get(); + + try { + return factory.create(svcClass, userToken); + } catch (Throwable e) { + // If we're already using the fallback, propagate the error + if (factory == taggingFactory) { + throw new RuntimeException("Failed to create service using fallback implementation", e); + } + + // Switch to fallback and retry + activeFactory.compareAndSet(proxyFactory, taggingFactory); + + // Retry with fallback + try { + return taggingFactory.create(svcClass, userToken); + } catch (Throwable fallbackException) { + throw new RuntimeException("Failed to create service with both implementations", fallbackException); + } + } + } + +} + diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryTagging.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryTagging.java index 4171b1fde..a71fe9631 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryTagging.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryTagging.java @@ -29,7 +29,7 @@ final class UserServiceFactoryTagging implements UserServiceFactory { * * @param retrofit the base Retrofit instance to derive user-specific instances from */ - public UserServiceFactoryTagging(Retrofit retrofit) { + UserServiceFactoryTagging(Retrofit retrofit) { this.retrofit = retrofit; } diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserToken.java b/src/main/java/io/getstream/chat/java/services/framework/UserToken.java index a144b3480..8e5915120 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserToken.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserToken.java @@ -1,17 +1,13 @@ package io.getstream.chat.java.services.framework; -public final class UserToken { +final class UserToken { private final String value; - public UserToken(String value) { + UserToken(String value) { this.value = value; } - public String value() { + String value() { return value; } - - public boolean isBlank() { - return value == null || value.isBlank(); - } } diff --git a/src/main/java/io/getstream/chat/java/services/framework/internal/TokenInjectionException.java b/src/main/java/io/getstream/chat/java/services/framework/internal/TokenInjectionException.java index 55bbfe32e..a6bac7c57 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/internal/TokenInjectionException.java +++ b/src/main/java/io/getstream/chat/java/services/framework/internal/TokenInjectionException.java @@ -6,7 +6,7 @@ * - A service method doesn't return retrofit2.Call * - Retrofit's internal structure changes and reflection fails */ -public class TokenInjectionException extends Exception { +public class TokenInjectionException extends ReflectiveOperationException { public TokenInjectionException(String message) { super(message); } From 80df7abfc5efdb17b5c364193dcac839fa65a3d0 Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 14:38:35 -0400 Subject: [PATCH 12/23] add docs --- .../java/services/framework/UserCall.java | 64 +++++++++++++++++++ .../java/services/framework/UserClient.java | 39 +++++++++++ .../framework/UserServiceFactory.java | 22 +++++++ .../framework/UserServiceFactoryProxy.java | 9 --- .../framework/UserServiceFactorySelector.java | 15 +---- .../framework/UserServiceFactoryTagging.java | 10 +-- .../java/services/framework/UserToken.java | 21 ++++++ .../framework/UserTokenCallRewriter.java | 53 +++++++++++++-- 8 files changed, 198 insertions(+), 35 deletions(-) diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserCall.java b/src/main/java/io/getstream/chat/java/services/framework/UserCall.java index 8dae94ebc..8491f0675 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserCall.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserCall.java @@ -5,45 +5,104 @@ import java.io.IOException; +/** + * Wrapper for Retrofit {@code Call} objects that injects user authentication tokens. + *

+ * This class delegates all {@code Call} operations to an underlying call while ensuring + * that the {@link UserToken} is attached to the request as a typed tag. The token can + * then be retrieved by OkHttp interceptors for adding authorization headers. + *

+ * + * @param the response body type + * @see UserToken + * @see UserTokenCallRewriter + */ class UserCall implements retrofit2.Call { private final retrofit2.Call delegate; private final UserToken token; + /** + * Constructs a new UserCall that wraps the provided call with token injection. + * + * @param delegate the underlying Retrofit call + * @param token the user token to inject + */ UserCall(retrofit2.Call delegate, UserToken token) { this.delegate = delegate; this.token = token; } + /** + * Executes the HTTP request synchronously. + * + * @return the response + * @throws IOException if the request fails + */ @Override public @NotNull retrofit2.Response execute() throws IOException { return delegate.execute(); } + /** + * Asynchronously sends the request and notifies the callback of its response. + * + * @param callback the callback to notify when the response arrives + */ @Override public void enqueue(@NotNull retrofit2.Callback callback) { delegate.enqueue(callback); } + /** + * Returns true if this call has been executed. + * + * @return true if executed, false otherwise + */ @Override public boolean isExecuted() { return delegate.isExecuted(); } + /** + * Cancels the request, if possible. + */ @Override public void cancel() { delegate.cancel(); } + /** + * Returns true if this call has been canceled. + * + * @return true if canceled, false otherwise + */ @Override public boolean isCanceled() { return delegate.isCanceled(); } + /** + * Creates a new, identical call that can be executed independently. + *

+ * The cloned call will also have the user token injected. + *

+ * + * @return a new call instance + */ @Override public @NotNull retrofit2.Call clone() { return new UserCall<>(delegate.clone(), token); } + /** + * Returns the original HTTP request with the user token attached as a typed tag. + *

+ * The token is stored using {@link Request#tag(Class, Object)} and can be retrieved + * by interceptors using {@code request.tag(UserToken.class)}. + *

+ * + * @return the request with the user token tag + */ @Override public @NotNull Request request() { Request original = delegate.request(); @@ -52,6 +111,11 @@ public boolean isCanceled() { .build(); } + /** + * Returns the timeout for this call. + * + * @return the timeout + */ @Override public @NotNull okio.Timeout timeout() { return delegate.timeout(); diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserClient.java b/src/main/java/io/getstream/chat/java/services/framework/UserClient.java index ee6b468d6..62e7e7ccf 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserClient.java @@ -4,31 +4,70 @@ import java.time.Duration; +/** + * Client implementation for user-scoped API operations. + *

+ * This client wraps a base {@link Client} and automatically injects a user-specific + * authentication token into all service calls. It's designed for scenarios where + * different users need to make authenticated API calls without creating separate + * client instances per user. + *

+ * + * @see Client + */ public final class UserClient implements Client { private final Client delegate; private final String userToken; + /** + * Constructs a new UserClient that wraps the provided client with user authentication. + * + * @param delegate the base client to delegate calls to + * @param userToken the user-specific authentication token to inject into requests + */ public UserClient(Client delegate, String userToken) { this.delegate = delegate; this.userToken = userToken; } + /** + * Creates a service proxy that automatically injects the user token into all requests. + * + * @param svcClass the service interface class + * @param the service type + * @return a proxy instance of the service with user token injection + */ @Override public @NotNull TService create(Class svcClass) { return delegate.create(svcClass, userToken); } + /** + * Returns the API key from the underlying client. + * + * @return the API key + */ @Override public @NotNull String getApiKey() { return delegate.getApiKey(); } + /** + * Returns the API secret from the underlying client. + * + * @return the API secret + */ @Override public @NotNull String getApiSecret() { return delegate.getApiSecret(); } + /** + * Sets the request timeout duration on the underlying client. + * + * @param timeoutDuration the timeout duration to set + */ @Override public void setTimeout(@NotNull Duration timeoutDuration) { delegate.setTimeout(timeoutDuration); diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java index 5422d6e97..bdd0615e0 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java @@ -1,7 +1,29 @@ package io.getstream.chat.java.services.framework; +/** + * Factory interface for creating service instances with user-specific authentication. + *

+ * Implementations of this interface are responsible for creating Retrofit service + * proxies that inject the provided {@link UserToken} into API requests. This enables + * per-user authentication without requiring separate HTTP client instances. + *

+ *

+ * Package-private to control instantiation within the framework. + *

+ * + * @see UserToken + * @see UserTokenCallRewriter + */ interface UserServiceFactory { + /** + * Creates a service instance that injects the specified user token into all requests. + * + * @param svcClass the service interface class to create + * @param userToken the user token to inject into requests + * @param the service interface type + * @return a proxy instance of the service with token injection capabilities + */ TService create(Class svcClass, UserToken userToken); } diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryProxy.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryProxy.java index 20c578f5c..0376a9805 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryProxy.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryProxy.java @@ -12,15 +12,6 @@ *

* Mechanism: Uses Java reflection {@link java.lang.reflect.Proxy} to wrap the service * interface and inject user tokens at method invocation time. - *

- * Trade-offs: - *

    - *
  • Pros: Reuses single Retrofit instance, flexible interception point
  • - *
  • Cons: Reflection overhead on every method call, slightly more complex debugging
  • - *
- *

- * Thread-safety: Immutable and thread-safe once constructed. The underlying proxy - * delegation is also thread-safe. * * @see UserTokenCallRewriter */ diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactorySelector.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactorySelector.java index 2f552bc14..50b65afa5 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactorySelector.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactorySelector.java @@ -5,20 +5,9 @@ /** * Smart user-aware service factory with automatic fallback mechanism. - *

+ * * This implementation attempts to use {@link UserServiceFactoryProxy} (more efficient) - * and automatically falls back to {@link UserServiceFactoryTagging} if reflection fails - * or the Retrofit API has changed. - *

- * Fallback Strategy: - *

    - *
  1. First attempt: Use proxy-based approach (reuses Retrofit instance)
  2. - *
  3. On failure: Switch to tagging-based approach (more compatible)
  4. - *
  5. Once switched: All subsequent calls use the fallback implementation
  6. - *
- *

- * Thread-safety: Thread-safe. Uses atomic reference for fallback state tracking. - * Multiple threads may attempt fallback simultaneously, but only one will win. + * and automatically falls back to {@link UserServiceFactoryTagging} if the proxy approach fails. */ final class UserServiceFactorySelector implements UserServiceFactory { diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryTagging.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryTagging.java index a71fe9631..c5baeafa3 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryTagging.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryTagging.java @@ -8,17 +8,11 @@ *

* This implementation wraps the OkHttp call factory to automatically attach a {@link UserToken} * as a request tag. The token can then be retrieved by interceptors for authentication purposes. + *

*

* Mechanism: Creates a new Retrofit instance with a custom call factory that tags * each request before delegating to the underlying call factory. - *

- * Trade-offs: - *

    - *
  • Pros: Clean, type-safe, works with any Retrofit service, no reflection overhead
  • - *
  • Cons: Creates a new Retrofit instance per service call (minor memory overhead)
  • - *
- *

- * Thread-safety: Immutable and thread-safe once constructed. + *

*/ final class UserServiceFactoryTagging implements UserServiceFactory { diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserToken.java b/src/main/java/io/getstream/chat/java/services/framework/UserToken.java index 8e5915120..0bd2a36ae 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserToken.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserToken.java @@ -1,12 +1,33 @@ package io.getstream.chat.java.services.framework; +/** + * Immutable wrapper for a user authentication token. + *

+ * This class encapsulates a user token string that is injected into HTTP requests + * for per-user authentication in multi-tenant scenarios. The token is stored as a + * request tag and retrieved by interceptors for adding authorization headers. + *

+ *

+ * Package-private to prevent direct instantiation outside the framework. + *

+ */ final class UserToken { private final String value; + /** + * Constructs a new UserToken with the specified value. + * + * @param value the token string value + */ UserToken(String value) { this.value = value; } + /** + * Returns the token string value. + * + * @return the token string + */ String value() { return value; } diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallRewriter.java b/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallRewriter.java index e0fa45160..3dd00a23c 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallRewriter.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallRewriter.java @@ -9,25 +9,57 @@ import java.lang.reflect.Method; /** - * Dynamic proxy that intercepts Retrofit service calls and injects UserToken - * into the request by modifying the internal rawCall field via reflection. - * - * This approach allows per-call authentication without creating multiple OkHttpClient - * instances, making it suitable for multi-tenant systems with thousands of users. + * Dynamic proxy that intercepts Retrofit service calls and injects {@link UserToken} + * into requests for per-user authentication. + *

+ * This class uses Java reflection to modify Retrofit's internal {@code Call} objects, + * injecting a {@link UserToken} as a request tag. The token is then retrieved by + * OkHttp interceptors to add authentication headers. + *

+ * + * @param the service interface type being proxied + * @see UserToken + * @see UserServiceFactory */ class UserTokenCallRewriter implements InvocationHandler { + /** + * Cached reference to Retrofit's internal rawCall field. + * Uses double-checked locking for thread-safe lazy initialization. + */ private static volatile Field rawCallField; private final Call.Factory callFactory; private final TService delegate; private final UserToken token; + /** + * Constructs a new call rewriter that injects the specified token. + * + * @param callFactory the OkHttp call factory for creating modified calls + * @param delegate the original service implementation to proxy + * @param token the user token to inject into requests + */ UserTokenCallRewriter(@NotNull Call.Factory callFactory, @NotNull TService delegate, @NotNull UserToken token) { this.callFactory = callFactory; this.delegate = delegate; this.token = token; } + /** + * Intercepts service method invocations to inject the user token. + *

+ * This method ensures that all service methods return {@code retrofit2.Call} + * objects. If a method returns a different type, a {@link TokenInjectionException} + * is thrown. + *

+ * + * @param proxy the proxy instance + * @param method the method being invoked + * @param args the method arguments + * @return the modified Call with token injection + * @throws Throwable if the underlying method throws an exception + * @throws TokenInjectionException if the method doesn't return retrofit2.Call + */ @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { Object result = method.invoke(delegate, args); @@ -43,6 +75,17 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl " did not return retrofit2.Call. User token injection requires all service methods to return Call."); } + /** + * Injects the user token into a Retrofit call by modifying its internal OkHttp call. + *

+ * The token is added as a request tag of type {@link UserToken}, which can be + * retrieved by OkHttp interceptors for authentication purposes. + *

+ * + * @param originalCall the original Retrofit call + * @return a cloned call with the user token injected + * @throws TokenInjectionException if reflection fails or Retrofit's structure has changed + */ private retrofit2.Call injectTokenIntoCall(retrofit2.Call originalCall) throws TokenInjectionException { retrofit2.Call clonedCall = originalCall.clone(); From dda6149623d27088f51462d28366796e6b83438e Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 14:46:34 -0400 Subject: [PATCH 13/23] add UserServiceFactoryCall --- .../services/framework/DefaultClient.java | 11 ++- .../framework/UserServiceFactoryCall.java | 67 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryCall.java diff --git a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java index 6ad130bd6..8835de70a 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java @@ -154,14 +154,21 @@ public TService create(Class svcClass) { } @Override - public @NotNull TService create(Class svcClass, String userToken) { + @NotNull + public TService create(Class svcClass, String userToken) { return serviceFactory.create(svcClass, new UserToken(userToken)); } - public @NotNull TService create2(Class svcClass, String userToken) { + @NotNull + public TService create2(Class svcClass, String userToken) { return new UserServiceFactoryTagging(retrofit).create(svcClass, new UserToken(userToken)); } + @NotNull + public TService create3(Class svcClass, String userToken) { + return new UserServiceFactoryCall(retrofit).create(svcClass, new UserToken(userToken)); + } + @NotNull public String getApiSecret() { return apiSecret; diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryCall.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryCall.java new file mode 100644 index 000000000..33ad39d74 --- /dev/null +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryCall.java @@ -0,0 +1,67 @@ +package io.getstream.chat.java.services.framework; + +import retrofit2.Retrofit; + +/** + * A user service factory implementation that wraps Retrofit service calls with user token context. + *

+ * This factory creates dynamic proxies around Retrofit service interfaces, intercepting method calls + * to wrap any {@link retrofit2.Call} results with {@link UserCall}. This enables automatic user token + * injection for client-side requests without modifying the service interface definitions. + *

+ *

+ * The wrapping process is transparent to callers - they interact with the service interface normally, + * but each Retrofit Call is automatically enhanced with the provided user token. + *

+ * + * @see UserServiceFactory + * @see UserCall + * @see UserToken + */ +final class UserServiceFactoryCall implements UserServiceFactory { + + private final Retrofit retrofit; + + /** + * Constructs a new UserServiceFactoryCall with the specified Retrofit instance. + * + * @param retrofit the Retrofit instance used to create the underlying service implementations + */ + public UserServiceFactoryCall(Retrofit retrofit) { + this.retrofit = retrofit; + } + + /** + * Creates a dynamic proxy for the specified service interface that wraps Retrofit Calls with user token context. + *

+ * This method generates a service implementation that intercepts all method calls. When a method returns + * a {@link retrofit2.Call}, it wraps the call in a {@link UserCall} that carries the provided user token. + * Non-Call return values are passed through unchanged. + *

+ * + * @param the service interface type + * @param svcClass the service interface class to create + * @param userToken the user token to inject into wrapped calls + * @return a dynamic proxy implementing the service interface with automatic UserCall wrapping + */ + @SuppressWarnings("unchecked") + public final TService create(Class svcClass, UserToken userToken) { + TService delegate = retrofit.create(svcClass); + + return (TService) java.lang.reflect.Proxy.newProxyInstance( + svcClass.getClassLoader(), + new Class[] { svcClass }, + (proxy, method, args) -> { + Object result = method.invoke(delegate, args); + + // If the result is a retrofit2.Call, wrap it with UserCall + if (result instanceof retrofit2.Call) { + return new UserCall<>((retrofit2.Call) result, userToken); + } + + return result; + } + ); + } + +} From cafc8cc8a8d36c910fa07d89b2177a0ae3f25f5e Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 15:18:56 -0400 Subject: [PATCH 14/23] improve UserCall --- .../java/services/framework/UserCall.java | 180 ++++++++++++++++-- .../framework/UserServiceFactoryCall.java | 15 +- .../io/getstream/chat/java/CustomTest.java | 2 +- 3 files changed, 179 insertions(+), 18 deletions(-) diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserCall.java b/src/main/java/io/getstream/chat/java/services/framework/UserCall.java index 8491f0675..b1047a1f1 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserCall.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserCall.java @@ -1,56 +1,204 @@ package io.getstream.chat.java.services.framework; import okhttp3.Request; +import okhttp3.ResponseBody; import org.jetbrains.annotations.NotNull; +import retrofit2.Retrofit; import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; /** * Wrapper for Retrofit {@code Call} objects that injects user authentication tokens. *

- * This class delegates all {@code Call} operations to an underlying call while ensuring - * that the {@link UserToken} is attached to the request as a typed tag. The token can - * then be retrieved by OkHttp interceptors for adding authorization headers. + * This class creates new OkHttp calls using the tagged request to ensure the {@link UserToken} + * is properly attached and available to interceptors for adding authorization headers. *

* * @param the response body type * @see UserToken - * @see UserTokenCallRewriter */ class UserCall implements retrofit2.Call { private final retrofit2.Call delegate; private final UserToken token; + private final Retrofit retrofit; + private final Type responseType; + private volatile boolean executed; + private volatile okhttp3.Call rawCall; /** * Constructs a new UserCall that wraps the provided call with token injection. * - * @param delegate the underlying Retrofit call + * @param delegate the underlying Retrofit call (used for request template) * @param token the user token to inject + * @param retrofit the Retrofit instance for creating calls and parsing responses + * @param responseType the actual response type for proper deserialization */ - UserCall(retrofit2.Call delegate, UserToken token) { + UserCall(retrofit2.Call delegate, UserToken token, Retrofit retrofit, Type responseType) { this.delegate = delegate; this.token = token; + this.retrofit = retrofit; + this.responseType = responseType; } /** - * Executes the HTTP request synchronously. + * Creates an OkHttp call with the tagged request. + */ + private okhttp3.Call createRawCall() { + return retrofit.callFactory().newCall(request()); + } + + /** + * Executes the HTTP request synchronously using a new call with the tagged request. * * @return the response * @throws IOException if the request fails */ @Override public @NotNull retrofit2.Response execute() throws IOException { - return delegate.execute(); + okhttp3.Call call; + synchronized (this) { + if (executed) throw new IllegalStateException("Already executed."); + executed = true; + rawCall = createRawCall(); + call = rawCall; + } + + okhttp3.Response rawResponse = call.execute(); + return parseResponse(rawResponse); } /** - * Asynchronously sends the request and notifies the callback of its response. + * Asynchronously sends the request using a new call with the tagged request. * * @param callback the callback to notify when the response arrives */ @Override public void enqueue(@NotNull retrofit2.Callback callback) { - delegate.enqueue(callback); + okhttp3.Call call; + synchronized (this) { + if (executed) throw new IllegalStateException("Already executed."); + executed = true; + rawCall = createRawCall(); + call = rawCall; + } + + call.enqueue(new okhttp3.Callback() { + @Override + public void onResponse(@NotNull okhttp3.Call call, @NotNull okhttp3.Response rawResponse) { + retrofit2.Response response; + try { + response = parseResponse(rawResponse); + } catch (Throwable t) { + callFailure(t); + return; + } + callSuccess(response); + } + + @Override + public void onFailure(@NotNull okhttp3.Call call, @NotNull IOException e) { + callFailure(e); + } + + private void callSuccess(retrofit2.Response response) { + try { + callback.onResponse(UserCall.this, response); + } catch (Throwable t) { + t.printStackTrace(); + } + } + + private void callFailure(Throwable t) { + try { + callback.onFailure(UserCall.this, t); + } catch (Throwable t2) { + t2.printStackTrace(); + } + } + }); + } + + /** + * Parses the raw OkHttp response into a Retrofit response using Retrofit's converters. + * Based on Retrofit's OkHttpCall.parseResponse() implementation. + */ + @SuppressWarnings("unchecked") + private retrofit2.Response parseResponse(okhttp3.Response rawResponse) throws IOException { + ResponseBody rawBody = rawResponse.body(); + + // Remove the body's source (the only stateful object) so we can pass the response along + rawResponse = rawResponse.newBuilder() + .body(new NoContentResponseBody(rawBody.contentType(), rawBody.contentLength())) + .build(); + + int code = rawResponse.code(); + + if (code < 200 || code >= 300) { + try { + // Buffer the entire body to avoid future I/O + ResponseBody bufferedBody = bufferResponseBody(rawBody); + return retrofit2.Response.error(bufferedBody, rawResponse); + } finally { + rawBody.close(); + } + } + + if (code == 204 || code == 205) { + rawBody.close(); + return retrofit2.Response.success(null, rawResponse); + } + + // Success response - parse body using Retrofit's converter + try { + retrofit2.Converter converter = + (retrofit2.Converter) retrofit.responseBodyConverter( + responseType, new Annotation[0]); + + T body = converter.convert(rawBody); + return retrofit2.Response.success(body, rawResponse); + } catch (RuntimeException e) { + rawBody.close(); + throw e; + } + } + + /** + * Buffers the response body to avoid future I/O operations. + */ + private static ResponseBody bufferResponseBody(ResponseBody body) throws IOException { + okio.Buffer buffer = new okio.Buffer(); + body.source().readAll(buffer); + return ResponseBody.create(buffer.readByteArray(), body.contentType()); + } + + /** + * A response body that returns empty content, used to prevent reading stateful sources. + */ + private static final class NoContentResponseBody extends ResponseBody { + private final okhttp3.MediaType contentType; + private final long contentLength; + + NoContentResponseBody(okhttp3.MediaType contentType, long contentLength) { + this.contentType = contentType; + this.contentLength = contentLength; + } + + @Override + public okhttp3.MediaType contentType() { + return contentType; + } + + @Override + public long contentLength() { + return contentLength; + } + + @Override + public okio.BufferedSource source() { + throw new IllegalStateException("Cannot read raw response body of a converted body."); + } } /** @@ -60,7 +208,7 @@ public void enqueue(@NotNull retrofit2.Callback callback) { */ @Override public boolean isExecuted() { - return delegate.isExecuted(); + return executed; } /** @@ -68,7 +216,9 @@ public boolean isExecuted() { */ @Override public void cancel() { - delegate.cancel(); + if (rawCall != null) { + rawCall.cancel(); + } } /** @@ -78,7 +228,7 @@ public void cancel() { */ @Override public boolean isCanceled() { - return delegate.isCanceled(); + return rawCall != null && rawCall.isCanceled(); } /** @@ -91,7 +241,7 @@ public boolean isCanceled() { */ @Override public @NotNull retrofit2.Call clone() { - return new UserCall<>(delegate.clone(), token); + return new UserCall<>(delegate.clone(), token, retrofit, responseType); } /** @@ -118,6 +268,6 @@ public boolean isCanceled() { */ @Override public @NotNull okio.Timeout timeout() { - return delegate.timeout(); + return rawCall != null ? rawCall.timeout() : okio.Timeout.NONE; } } \ No newline at end of file diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryCall.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryCall.java index 33ad39d74..272f8672b 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryCall.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryCall.java @@ -55,8 +55,19 @@ public final TService create(Class svcClass, UserToken user Object result = method.invoke(delegate, args); // If the result is a retrofit2.Call, wrap it with UserCall - if (result instanceof retrofit2.Call) { - return new UserCall<>((retrofit2.Call) result, userToken); + if (result instanceof retrofit2.Call) { + // Extract the response type from the method's return type + java.lang.reflect.Type returnType = method.getGenericReturnType(); + java.lang.reflect.Type responseType = Object.class; + + if (returnType instanceof java.lang.reflect.ParameterizedType) { + java.lang.reflect.ParameterizedType paramType = (java.lang.reflect.ParameterizedType) returnType; + if (paramType.getActualTypeArguments().length > 0) { + responseType = paramType.getActualTypeArguments()[0]; + } + } + + return new UserCall<>((retrofit2.Call) result, userToken, retrofit, responseType); } return result; diff --git a/src/test/java/io/getstream/chat/java/CustomTest.java b/src/test/java/io/getstream/chat/java/CustomTest.java index d54f4f86a..219a662e8 100644 --- a/src/test/java/io/getstream/chat/java/CustomTest.java +++ b/src/test/java/io/getstream/chat/java/CustomTest.java @@ -18,7 +18,7 @@ public class CustomTest { void customTest() throws Exception { var userId = "admin"; var userToken = User.createToken(userId, null, null); - var response = User.list().userId(userId).filterCondition("id", userId).request(); + var response = User.list().filterCondition("id", userId).withUserToken(userToken).request(); System.out.println(response); } From 2b2e768dd27d50a629e5a6df4b529484fde4b7a2 Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 15:30:01 -0400 Subject: [PATCH 15/23] improve UserServiceFactoryCall --- .../java/services/framework/UserCall.java | 16 ++-- .../framework/UserServiceFactoryCall.java | 84 +++++++++++++++---- 2 files changed, 74 insertions(+), 26 deletions(-) diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserCall.java b/src/main/java/io/getstream/chat/java/services/framework/UserCall.java index b1047a1f1..b9e2f29dd 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserCall.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserCall.java @@ -20,9 +20,9 @@ * @see UserToken */ class UserCall implements retrofit2.Call { - private final retrofit2.Call delegate; - private final UserToken token; private final Retrofit retrofit; + private final UserToken token; + private final retrofit2.Call delegate; private final Type responseType; private volatile boolean executed; private volatile okhttp3.Call rawCall; @@ -30,15 +30,15 @@ class UserCall implements retrofit2.Call { /** * Constructs a new UserCall that wraps the provided call with token injection. * - * @param delegate the underlying Retrofit call (used for request template) - * @param token the user token to inject * @param retrofit the Retrofit instance for creating calls and parsing responses + * @param token the user token to inject + * @param delegate the underlying Retrofit call (used for request template) * @param responseType the actual response type for proper deserialization */ - UserCall(retrofit2.Call delegate, UserToken token, Retrofit retrofit, Type responseType) { - this.delegate = delegate; - this.token = token; + UserCall(Retrofit retrofit, UserToken token, retrofit2.Call delegate, Type responseType) { this.retrofit = retrofit; + this.token = token; + this.delegate = delegate; this.responseType = responseType; } @@ -241,7 +241,7 @@ public boolean isCanceled() { */ @Override public @NotNull retrofit2.Call clone() { - return new UserCall<>(delegate.clone(), token, retrofit, responseType); + return new UserCall<>(retrofit, token, delegate.clone(), responseType); } /** diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryCall.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryCall.java index 272f8672b..c42138a88 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryCall.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryCall.java @@ -2,6 +2,11 @@ import retrofit2.Retrofit; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.concurrent.ConcurrentHashMap; + /** * A user service factory implementation that wraps Retrofit service calls with user token context. *

@@ -13,6 +18,14 @@ * The wrapping process is transparent to callers - they interact with the service interface normally, * but each Retrofit Call is automatically enhanced with the provided user token. *

+ *

+ * Requirements: Service methods must return {@code Call} with a type parameter (not raw Call). + * The service interface must be compiled with generic type information preserved (default behavior). + *

+ *

+ * Performance: Response type extraction is cached per-method to minimize reflection overhead + * on the hot path (~10ns overhead per call after caching vs ~100ns without). + *

* * @see UserServiceFactory * @see UserCall @@ -21,6 +34,15 @@ final class UserServiceFactoryCall implements UserServiceFactory { private final Retrofit retrofit; + + /** + * Cache of response types extracted from service method signatures. + * Key: Method from service interface + * Value: Response type T from Call return type + * + * Thread-safe and lazily populated on first method invocation. + */ + private final ConcurrentHashMap responseTypeCache = new ConcurrentHashMap<>(); /** * Constructs a new UserServiceFactoryCall with the specified Retrofit instance. @@ -34,15 +56,15 @@ public UserServiceFactoryCall(Retrofit retrofit) { /** * Creates a dynamic proxy for the specified service interface that wraps Retrofit Calls with user token context. *

- * This method generates a service implementation that intercepts all method calls. When a method returns - * a {@link retrofit2.Call}, it wraps the call in a {@link UserCall} that carries the provided user token. - * Non-Call return values are passed through unchanged. + * This method generates a service implementation that intercepts all method calls. ALL service methods + * MUST return {@link retrofit2.Call} - methods that don't return Call will fail with {@link IllegalStateException}. *

* * @param the service interface type * @param svcClass the service interface class to create * @param userToken the user token to inject into wrapped calls * @return a dynamic proxy implementing the service interface with automatic UserCall wrapping + * @throws IllegalStateException if a service method doesn't return Call or returns raw Call without type parameter */ @SuppressWarnings("unchecked") public final TService create(Class svcClass, UserToken userToken) { @@ -54,25 +76,51 @@ public final TService create(Class svcClass, UserToken user (proxy, method, args) -> { Object result = method.invoke(delegate, args); - // If the result is a retrofit2.Call, wrap it with UserCall - if (result instanceof retrofit2.Call) { - // Extract the response type from the method's return type - java.lang.reflect.Type returnType = method.getGenericReturnType(); - java.lang.reflect.Type responseType = Object.class; - - if (returnType instanceof java.lang.reflect.ParameterizedType) { - java.lang.reflect.ParameterizedType paramType = (java.lang.reflect.ParameterizedType) returnType; - if (paramType.getActualTypeArguments().length > 0) { - responseType = paramType.getActualTypeArguments()[0]; - } - } - - return new UserCall<>((retrofit2.Call) result, userToken, retrofit, responseType); + // ALL service methods MUST return retrofit2.Call for user token injection + if (!(result instanceof retrofit2.Call)) { + throw new IllegalStateException( + "Service method " + method.getDeclaringClass().getName() + "." + method.getName() + + " must return retrofit2.Call for user token injection. " + + "Actual return type: " + (result == null ? "null" : result.getClass().getName())); } - return result; + retrofit2.Call call = (retrofit2.Call) result; + Type responseType = responseTypeCache.computeIfAbsent(method, this::extractResponseType); + return new UserCall<>(retrofit, userToken, call, responseType); } ); } + /** + * Extracts the response type T from a method that returns Call. + *

+ * This method is called once per service method and cached for subsequent invocations. + *

+ * + * @param method the service method + * @return the response type T from Call + * @throws IllegalStateException if the method doesn't return Call with a type parameter + */ + private Type extractResponseType(Method method) { + Type returnType = method.getGenericReturnType(); + + if (!(returnType instanceof ParameterizedType)) { + throw new IllegalStateException( + "Service method " + method.getDeclaringClass().getName() + "." + method.getName() + + " must return Call with a type parameter, not raw Call. " + + "Ensure the service interface is compiled with generic type information."); + } + + ParameterizedType parameterizedType = (ParameterizedType) returnType; + Type[] typeArguments = parameterizedType.getActualTypeArguments(); + + if (typeArguments.length == 0) { + throw new IllegalStateException( + "Service method " + method.getDeclaringClass().getName() + "." + method.getName() + + " returns Call without type arguments. Expected Call."); + } + + return typeArguments[0]; + } + } From c51ddc854083794fad2b96270adbce4e017deb67 Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 15:35:29 -0400 Subject: [PATCH 16/23] emasure create3 --- .../services/framework/DefaultClient.java | 10 ++-- .../io/getstream/chat/java/CustomTest.java | 52 ++++++++++++++----- 2 files changed, 45 insertions(+), 17 deletions(-) diff --git a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java index 8835de70a..ec27b1c61 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java @@ -32,6 +32,8 @@ public class DefaultClient implements Client { @NotNull private OkHttpClient okHttpClient; @NotNull private Retrofit retrofit; @NotNull private UserServiceFactory serviceFactory; + @NotNull private UserServiceFactory serviceFactory2; + @NotNull private UserServiceFactory serviceFactory3; @NotNull private final String apiSecret; @NotNull private final String apiKey; @NotNull private final Properties extendedProperties; @@ -77,6 +79,8 @@ public DefaultClient(Properties properties) { this.apiKey = apiKey.toString(); this.retrofit = buildRetrofitClient(); this.serviceFactory = new UserServiceFactorySelector(retrofit); + this.serviceFactory2 = new UserServiceFactoryTagging(retrofit); + this.serviceFactory3 = new UserServiceFactoryCall(retrofit); } private Retrofit buildRetrofitClient() { @@ -153,7 +157,7 @@ public TService create(Class svcClass) { return retrofit.create(svcClass); } - @Override +// @Override @NotNull public TService create(Class svcClass, String userToken) { return serviceFactory.create(svcClass, new UserToken(userToken)); @@ -161,12 +165,12 @@ public TService create(Class svcClass, String userToken) { @NotNull public TService create2(Class svcClass, String userToken) { - return new UserServiceFactoryTagging(retrofit).create(svcClass, new UserToken(userToken)); + return serviceFactory2.create(svcClass, new UserToken(userToken)); } @NotNull public TService create3(Class svcClass, String userToken) { - return new UserServiceFactoryCall(retrofit).create(svcClass, new UserToken(userToken)); + return serviceFactory3.create(svcClass, new UserToken(userToken)); } @NotNull diff --git a/src/test/java/io/getstream/chat/java/CustomTest.java b/src/test/java/io/getstream/chat/java/CustomTest.java index 219a662e8..e141beb89 100644 --- a/src/test/java/io/getstream/chat/java/CustomTest.java +++ b/src/test/java/io/getstream/chat/java/CustomTest.java @@ -52,6 +52,7 @@ void measureClientCreate() throws Exception { for (int i = 0; i < 10_000; i++) { defaultClient.create(UserService.class, userToken); defaultClient.create2(UserService.class, userToken); + defaultClient.create3(UserService.class, userToken); } // Get ThreadMXBean for accurate memory allocation tracking @@ -92,22 +93,45 @@ void measureClientCreate() throws Exception { System.out.println("> Second loop avg time per call: " + (elapsedTimeInNs2 / (double) iterations) + " ns"); System.out.println("> Second loop avg memory per call: " + (allocated2 / (double) iterations) + " bytes"); - // Performance comparison - if (elapsedTimeInNs1 < elapsedTimeInNs2) { - double timesFaster = (double) elapsedTimeInNs2 / elapsedTimeInNs1; - System.out.println("> create is " + String.format("%.2fx", timesFaster) + " faster than create2"); - } else { - double timesFaster = (double) elapsedTimeInNs1 / elapsedTimeInNs2; - System.out.println("> create2 is " + String.format("%.2fx", timesFaster) + " faster than create"); + // Measure third test + long allocatedBefore3 = threadBean.getCurrentThreadAllocatedBytes(); + startTime = System.nanoTime(); + for (int i = 0; i < iterations; i++) { + defaultClient.create3(UserService.class, userToken); } + endTime = System.nanoTime(); + long allocatedAfter3 = threadBean.getCurrentThreadAllocatedBytes(); + long elapsedTimeInNs3 = endTime - startTime; + long allocated3 = allocatedAfter3 - allocatedBefore3; + + System.out.println("> Third loop elapsed time: " + TimeUnit.NANOSECONDS.toMillis(elapsedTimeInNs3) + " ms"); + System.out.println("> Third loop memory allocated: " + (allocated3 / 1024 / 1024) + " MB"); + System.out.println("> Third loop avg time per call: " + (elapsedTimeInNs3 / (double) iterations) + " ns"); + System.out.println("> Third loop avg memory per call: " + (allocated3 / (double) iterations) + " bytes"); + + // Performance comparison - Time + long fastestTime = Math.min(elapsedTimeInNs1, Math.min(elapsedTimeInNs2, elapsedTimeInNs3)); + String fastestMethod = ""; + if (fastestTime == elapsedTimeInNs1) fastestMethod = "create"; + else if (fastestTime == elapsedTimeInNs2) fastestMethod = "create2"; + else fastestMethod = "create3"; - if (allocated1 < allocated2) { - double timesLess = (double) allocated2 / allocated1; - System.out.println("> create allocates " + String.format("%.2fx", timesLess) + " less memory than create2"); - } else { - double timesLess = (double) allocated1 / allocated2; - System.out.println("> create2 allocates " + String.format("%.2fx", timesLess) + " less memory than create"); - } + System.out.println("> Time comparison (fastest: " + fastestMethod + "):"); + System.out.println(" - create: " + String.format("%.2fx", (double) elapsedTimeInNs1 / fastestTime)); + System.out.println(" - create2: " + String.format("%.2fx", (double) elapsedTimeInNs2 / fastestTime)); + System.out.println(" - create3: " + String.format("%.2fx", (double) elapsedTimeInNs3 / fastestTime)); + + // Performance comparison - Memory + long leastMemory = Math.min(allocated1, Math.min(allocated2, allocated3)); + String mostEfficientMethod = ""; + if (leastMemory == allocated1) mostEfficientMethod = "create"; + else if (leastMemory == allocated2) mostEfficientMethod = "create2"; + else mostEfficientMethod = "create3"; + + System.out.println("> Memory comparison (least: " + mostEfficientMethod + "):"); + System.out.println(" - create: " + String.format("%.2fx", (double) allocated1 / leastMemory)); + System.out.println(" - create2: " + String.format("%.2fx", (double) allocated2 / leastMemory)); + System.out.println(" - create3: " + String.format("%.2fx", (double) allocated3 / leastMemory)); System.out.println("========================================================="); } From 8d584a06bd794ca56b9eb35e715ccaa93ba4b5a6 Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 15:37:31 -0400 Subject: [PATCH 17/23] code clean up --- build.gradle | 1 - .../services/framework/DefaultClient.java | 20 ++----------------- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/build.gradle b/build.gradle index 91178bad0..e3531fbbe 100644 --- a/build.gradle +++ b/build.gradle @@ -32,7 +32,6 @@ dependencies { // define any required OkHttp artifacts without version implementation("com.squareup.okhttp3:okhttp") - implementation("com.squareup.okhttp3:logging-interceptor") implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-jackson:2.9.0' diff --git a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java index ec27b1c61..612a6fd9f 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java @@ -32,8 +32,6 @@ public class DefaultClient implements Client { @NotNull private OkHttpClient okHttpClient; @NotNull private Retrofit retrofit; @NotNull private UserServiceFactory serviceFactory; - @NotNull private UserServiceFactory serviceFactory2; - @NotNull private UserServiceFactory serviceFactory3; @NotNull private final String apiSecret; @NotNull private final String apiKey; @NotNull private final Properties extendedProperties; @@ -79,8 +77,6 @@ public DefaultClient(Properties properties) { this.apiKey = apiKey.toString(); this.retrofit = buildRetrofitClient(); this.serviceFactory = new UserServiceFactorySelector(retrofit); - this.serviceFactory2 = new UserServiceFactoryTagging(retrofit); - this.serviceFactory3 = new UserServiceFactoryCall(retrofit); } private Retrofit buildRetrofitClient() { @@ -91,7 +87,7 @@ private Retrofit buildRetrofitClient() { httpClient.interceptors().clear(); HttpLoggingInterceptor loggingInterceptor = - new HttpLoggingInterceptor(s -> System.out.printf("OkHttp: %s%n", s)).setLevel(getLogLevel(extendedProperties)); + new HttpLoggingInterceptor().setLevel(getLogLevel(extendedProperties)); httpClient.addInterceptor(loggingInterceptor); httpClient.addInterceptor( @@ -111,11 +107,9 @@ private Retrofit buildRetrofitClient() { .header("Stream-Auth-Type", "jwt"); if (userToken != null) { - System.out.println("!.!.! Client-Side"); // User token present - use user auth builder.header("Authorization", userToken.value()); } else { - System.out.println("!.!.! Server-Side"); // Server-side auth builder.header("Authorization", jwtToken(apiSecret)); } @@ -157,22 +151,12 @@ public TService create(Class svcClass) { return retrofit.create(svcClass); } -// @Override + @Override @NotNull public TService create(Class svcClass, String userToken) { return serviceFactory.create(svcClass, new UserToken(userToken)); } - @NotNull - public TService create2(Class svcClass, String userToken) { - return serviceFactory2.create(svcClass, new UserToken(userToken)); - } - - @NotNull - public TService create3(Class svcClass, String userToken) { - return serviceFactory3.create(svcClass, new UserToken(userToken)); - } - @NotNull public String getApiSecret() { return apiSecret; From 7aa6e07cbbacdb6b93bd0ecc4d79d174983761f5 Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 15:38:08 -0400 Subject: [PATCH 18/23] delete CustomTest --- .../io/getstream/chat/java/CustomTest.java | 138 ------------------ 1 file changed, 138 deletions(-) delete mode 100644 src/test/java/io/getstream/chat/java/CustomTest.java diff --git a/src/test/java/io/getstream/chat/java/CustomTest.java b/src/test/java/io/getstream/chat/java/CustomTest.java deleted file mode 100644 index e141beb89..000000000 --- a/src/test/java/io/getstream/chat/java/CustomTest.java +++ /dev/null @@ -1,138 +0,0 @@ -package io.getstream.chat.java; - -import io.getstream.chat.java.exceptions.StreamException; -import io.getstream.chat.java.models.Thread; -import io.getstream.chat.java.models.User; -import io.getstream.chat.java.services.UserService; -import io.getstream.chat.java.services.framework.DefaultClient; -import org.junit.jupiter.api.Test; -import java.lang.management.ManagementFactory; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; - -public class CustomTest { - - @Test - void customTest() throws Exception { - var userId = "admin"; - var userToken = User.createToken(userId, null, null); - var response = User.list().filterCondition("id", userId).withUserToken(userToken).request(); - System.out.println(response); - } - - - @Test - void userReqTest() throws Exception { - var userIds = List.of( "admin", "MWRYXIHURH", "SRLOTCPYQS", "IOEXOFYTRH", "RIOPOIGMDQ", "XUXJMHTNOI", "QQASWMJEQI"); - - for (var userId : userIds) { - var userToken = User.createToken(userId, null, null); - User.list().filterCondition("id", userId).withUserToken(userToken).requestAsync( - userListResponse -> System.out.println("\n!.!.! " + userListResponse + "\n"), - e -> {} - ); - } - - java.lang.Thread.sleep(10000); - } - - @Test - void measureClientCreate() throws Exception { - var userId = "admin"; - var userToken = User.createToken(userId, null, null); - - // Test creating a UserClient directly - should use Client-Side auth - var defaultClient = new DefaultClient(); - - var iterations = 30_000_000; - - // Warm up JVM to avoid cold start effects - for (int i = 0; i < 10_000; i++) { - defaultClient.create(UserService.class, userToken); - defaultClient.create2(UserService.class, userToken); - defaultClient.create3(UserService.class, userToken); - } - - // Get ThreadMXBean for accurate memory allocation tracking - com.sun.management.ThreadMXBean threadBean = - (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean(); - - // Measure first test - long allocatedBefore1 = threadBean.getCurrentThreadAllocatedBytes(); - long startTime = System.nanoTime(); - for (int i = 0; i < iterations; i++) { - defaultClient.create(UserService.class, userToken); - } - long endTime = System.nanoTime(); - long allocatedAfter1 = threadBean.getCurrentThreadAllocatedBytes(); - long elapsedTimeInNs1 = endTime - startTime; - long allocated1 = allocatedAfter1 - allocatedBefore1; - - System.out.println("========================================================="); - - System.out.println("> First loop elapsed time: " + TimeUnit.NANOSECONDS.toMillis(elapsedTimeInNs1) + " ms"); - System.out.println("> First loop memory allocated: " + (allocated1 / 1024 / 1024) + " MB"); - System.out.println("> First loop avg time per call: " + (elapsedTimeInNs1 / (double) iterations) + " ns"); - System.out.println("> First loop avg memory per call: " + (allocated1 / (double) iterations) + " bytes"); - - // Measure second test - long allocatedBefore2 = threadBean.getCurrentThreadAllocatedBytes(); - startTime = System.nanoTime(); - for (int i = 0; i < iterations; i++) { - defaultClient.create2(UserService.class, userToken); - } - endTime = System.nanoTime(); - long allocatedAfter2 = threadBean.getCurrentThreadAllocatedBytes(); - long elapsedTimeInNs2 = endTime - startTime; - long allocated2 = allocatedAfter2 - allocatedBefore2; - - System.out.println("> Second loop elapsed time: " + TimeUnit.NANOSECONDS.toMillis(elapsedTimeInNs2) + " ms"); - System.out.println("> Second loop memory allocated: " + (allocated2 / 1024 / 1024) + " MB"); - System.out.println("> Second loop avg time per call: " + (elapsedTimeInNs2 / (double) iterations) + " ns"); - System.out.println("> Second loop avg memory per call: " + (allocated2 / (double) iterations) + " bytes"); - - // Measure third test - long allocatedBefore3 = threadBean.getCurrentThreadAllocatedBytes(); - startTime = System.nanoTime(); - for (int i = 0; i < iterations; i++) { - defaultClient.create3(UserService.class, userToken); - } - endTime = System.nanoTime(); - long allocatedAfter3 = threadBean.getCurrentThreadAllocatedBytes(); - long elapsedTimeInNs3 = endTime - startTime; - long allocated3 = allocatedAfter3 - allocatedBefore3; - - System.out.println("> Third loop elapsed time: " + TimeUnit.NANOSECONDS.toMillis(elapsedTimeInNs3) + " ms"); - System.out.println("> Third loop memory allocated: " + (allocated3 / 1024 / 1024) + " MB"); - System.out.println("> Third loop avg time per call: " + (elapsedTimeInNs3 / (double) iterations) + " ns"); - System.out.println("> Third loop avg memory per call: " + (allocated3 / (double) iterations) + " bytes"); - - // Performance comparison - Time - long fastestTime = Math.min(elapsedTimeInNs1, Math.min(elapsedTimeInNs2, elapsedTimeInNs3)); - String fastestMethod = ""; - if (fastestTime == elapsedTimeInNs1) fastestMethod = "create"; - else if (fastestTime == elapsedTimeInNs2) fastestMethod = "create2"; - else fastestMethod = "create3"; - - System.out.println("> Time comparison (fastest: " + fastestMethod + "):"); - System.out.println(" - create: " + String.format("%.2fx", (double) elapsedTimeInNs1 / fastestTime)); - System.out.println(" - create2: " + String.format("%.2fx", (double) elapsedTimeInNs2 / fastestTime)); - System.out.println(" - create3: " + String.format("%.2fx", (double) elapsedTimeInNs3 / fastestTime)); - - // Performance comparison - Memory - long leastMemory = Math.min(allocated1, Math.min(allocated2, allocated3)); - String mostEfficientMethod = ""; - if (leastMemory == allocated1) mostEfficientMethod = "create"; - else if (leastMemory == allocated2) mostEfficientMethod = "create2"; - else mostEfficientMethod = "create3"; - - System.out.println("> Memory comparison (least: " + mostEfficientMethod + "):"); - System.out.println(" - create: " + String.format("%.2fx", (double) allocated1 / leastMemory)); - System.out.println(" - create2: " + String.format("%.2fx", (double) allocated2 / leastMemory)); - System.out.println(" - create3: " + String.format("%.2fx", (double) allocated3 / leastMemory)); - - System.out.println("========================================================="); - } -} From 793b1cfc5f8275a9701217cad955afdf73c14495 Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 15:41:53 -0400 Subject: [PATCH 19/23] fix formatting --- .../java/models/framework/StreamRequest.java | 3 +- .../services/framework/DefaultClient.java | 24 +-- .../java/services/framework/UserCall.java | 154 ++++++++---------- .../java/services/framework/UserClient.java | 113 +++++++------ .../framework/UserServiceFactory.java | 15 +- .../framework/UserServiceFactoryCall.java | 133 ++++++++------- .../framework/UserServiceFactoryProxy.java | 32 ++-- .../framework/UserServiceFactorySelector.java | 28 ++-- .../framework/UserServiceFactoryTagging.java | 37 ++--- .../java/services/framework/UserToken.java | 14 +- .../framework/UserTokenCallRewriter.java | 82 +++++----- .../internal/TokenInjectionException.java | 9 +- 12 files changed, 313 insertions(+), 331 deletions(-) diff --git a/src/main/java/io/getstream/chat/java/models/framework/StreamRequest.java b/src/main/java/io/getstream/chat/java/models/framework/StreamRequest.java index 0e2ce84d3..98ae199ac 100644 --- a/src/main/java/io/getstream/chat/java/models/framework/StreamRequest.java +++ b/src/main/java/io/getstream/chat/java/models/framework/StreamRequest.java @@ -3,9 +3,8 @@ import io.getstream.chat.java.exceptions.StreamException; import io.getstream.chat.java.services.framework.Client; import io.getstream.chat.java.services.framework.StreamServiceHandler; -import java.util.function.Consumer; - import io.getstream.chat.java.services.framework.UserClient; +import java.util.function.Consumer; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import retrofit2.Call; diff --git a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java index 612a6fd9f..01133e1ec 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java @@ -14,7 +14,6 @@ import java.util.*; import java.util.concurrent.TimeUnit; import javax.crypto.spec.SecretKeySpec; - import okhttp3.*; import okhttp3.logging.HttpLoggingInterceptor; import org.jetbrains.annotations.NotNull; @@ -93,10 +92,10 @@ private Retrofit buildRetrofitClient() { httpClient.addInterceptor( chain -> { Request original = chain.request(); - + // Check for user token tag UserToken userToken = original.tag(UserToken.class); - + HttpUrl url = original.url().newBuilder().addQueryParameter("api_key", apiKey).build(); Request.Builder builder = original @@ -105,7 +104,7 @@ private Retrofit buildRetrofitClient() { .header("Content-Type", "application/json") .header("X-Stream-Client", "stream-java-client-" + sdkVersion) .header("Stream-Auth-Type", "jwt"); - + if (userToken != null) { // User token present - use user auth builder.header("Authorization", userToken.value()); @@ -113,7 +112,7 @@ private Retrofit buildRetrofitClient() { // Server-side auth builder.header("Authorization", jwtToken(apiSecret)); } - + return chain.proceed(builder.build()); }); final ObjectMapper mapper = new ObjectMapper(); @@ -134,13 +133,14 @@ private Retrofit buildRetrofitClient() { .client(okHttpClient) .addConverterFactory(new QueryConverterFactory()) .addConverterFactory(JacksonConverterFactory.create(mapper)) - .callFactory(new Call.Factory() { - @Override - public @NotNull Call newCall(@NotNull Request request) { - return okHttpClient.newCall(request); - } - }); -// builder.client(httpClient.build()); + .callFactory( + new Call.Factory() { + @Override + public @NotNull Call newCall(@NotNull Request request) { + return okHttpClient.newCall(request); + } + }); + // builder.client(httpClient.build()); return builder.build(); } diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserCall.java b/src/main/java/io/getstream/chat/java/services/framework/UserCall.java index b9e2f29dd..bceef7e02 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserCall.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserCall.java @@ -1,20 +1,18 @@ package io.getstream.chat.java.services.framework; +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.Type; import okhttp3.Request; import okhttp3.ResponseBody; import org.jetbrains.annotations.NotNull; import retrofit2.Retrofit; -import java.io.IOException; -import java.lang.annotation.Annotation; -import java.lang.reflect.Type; - /** * Wrapper for Retrofit {@code Call} objects that injects user authentication tokens. - *

- * This class creates new OkHttp calls using the tagged request to ensure the {@link UserToken} + * + *

This class creates new OkHttp calls using the tagged request to ensure the {@link UserToken} * is properly attached and available to interceptors for adding authorization headers. - *

* * @param the response body type * @see UserToken @@ -42,9 +40,7 @@ class UserCall implements retrofit2.Call { this.responseType = responseType; } - /** - * Creates an OkHttp call with the tagged request. - */ + /** Creates an OkHttp call with the tagged request. */ private okhttp3.Call createRawCall() { return retrofit.callFactory().newCall(request()); } @@ -64,7 +60,7 @@ private okhttp3.Call createRawCall() { rawCall = createRawCall(); call = rawCall; } - + okhttp3.Response rawResponse = call.execute(); return parseResponse(rawResponse); } @@ -84,57 +80,61 @@ public void enqueue(@NotNull retrofit2.Callback callback) { call = rawCall; } - call.enqueue(new okhttp3.Callback() { - @Override - public void onResponse(@NotNull okhttp3.Call call, @NotNull okhttp3.Response rawResponse) { - retrofit2.Response response; - try { - response = parseResponse(rawResponse); - } catch (Throwable t) { - callFailure(t); - return; - } - callSuccess(response); - } + call.enqueue( + new okhttp3.Callback() { + @Override + public void onResponse( + @NotNull okhttp3.Call call, @NotNull okhttp3.Response rawResponse) { + retrofit2.Response response; + try { + response = parseResponse(rawResponse); + } catch (Throwable t) { + callFailure(t); + return; + } + callSuccess(response); + } - @Override - public void onFailure(@NotNull okhttp3.Call call, @NotNull IOException e) { - callFailure(e); - } + @Override + public void onFailure(@NotNull okhttp3.Call call, @NotNull IOException e) { + callFailure(e); + } - private void callSuccess(retrofit2.Response response) { - try { - callback.onResponse(UserCall.this, response); - } catch (Throwable t) { - t.printStackTrace(); - } - } + private void callSuccess(retrofit2.Response response) { + try { + callback.onResponse(UserCall.this, response); + } catch (Throwable t) { + t.printStackTrace(); + } + } - private void callFailure(Throwable t) { - try { - callback.onFailure(UserCall.this, t); - } catch (Throwable t2) { - t2.printStackTrace(); - } - } - }); + private void callFailure(Throwable t) { + try { + callback.onFailure(UserCall.this, t); + } catch (Throwable t2) { + t2.printStackTrace(); + } + } + }); } /** - * Parses the raw OkHttp response into a Retrofit response using Retrofit's converters. - * Based on Retrofit's OkHttpCall.parseResponse() implementation. + * Parses the raw OkHttp response into a Retrofit response using Retrofit's converters. Based on + * Retrofit's OkHttpCall.parseResponse() implementation. */ @SuppressWarnings("unchecked") private retrofit2.Response parseResponse(okhttp3.Response rawResponse) throws IOException { ResponseBody rawBody = rawResponse.body(); - + // Remove the body's source (the only stateful object) so we can pass the response along - rawResponse = rawResponse.newBuilder() - .body(new NoContentResponseBody(rawBody.contentType(), rawBody.contentLength())) - .build(); - + rawResponse = + rawResponse + .newBuilder() + .body(new NoContentResponseBody(rawBody.contentType(), rawBody.contentLength())) + .build(); + int code = rawResponse.code(); - + if (code < 200 || code >= 300) { try { // Buffer the entire body to avoid future I/O @@ -144,18 +144,18 @@ private retrofit2.Response parseResponse(okhttp3.Response rawResponse) throws rawBody.close(); } } - + if (code == 204 || code == 205) { rawBody.close(); return retrofit2.Response.success(null, rawResponse); } - + // Success response - parse body using Retrofit's converter try { - retrofit2.Converter converter = - (retrofit2.Converter) retrofit.responseBodyConverter( - responseType, new Annotation[0]); - + retrofit2.Converter converter = + (retrofit2.Converter) + retrofit.responseBodyConverter(responseType, new Annotation[0]); + T body = converter.convert(rawBody); return retrofit2.Response.success(body, rawResponse); } catch (RuntimeException e) { @@ -163,38 +163,34 @@ private retrofit2.Response parseResponse(okhttp3.Response rawResponse) throws throw e; } } - - /** - * Buffers the response body to avoid future I/O operations. - */ + + /** Buffers the response body to avoid future I/O operations. */ private static ResponseBody bufferResponseBody(ResponseBody body) throws IOException { okio.Buffer buffer = new okio.Buffer(); body.source().readAll(buffer); return ResponseBody.create(buffer.readByteArray(), body.contentType()); } - - /** - * A response body that returns empty content, used to prevent reading stateful sources. - */ + + /** A response body that returns empty content, used to prevent reading stateful sources. */ private static final class NoContentResponseBody extends ResponseBody { private final okhttp3.MediaType contentType; private final long contentLength; - + NoContentResponseBody(okhttp3.MediaType contentType, long contentLength) { this.contentType = contentType; this.contentLength = contentLength; } - + @Override public okhttp3.MediaType contentType() { return contentType; } - + @Override public long contentLength() { return contentLength; } - + @Override public okio.BufferedSource source() { throw new IllegalStateException("Cannot read raw response body of a converted body."); @@ -211,9 +207,7 @@ public boolean isExecuted() { return executed; } - /** - * Cancels the request, if possible. - */ + /** Cancels the request, if possible. */ @Override public void cancel() { if (rawCall != null) { @@ -233,9 +227,8 @@ public boolean isCanceled() { /** * Creates a new, identical call that can be executed independently. - *

- * The cloned call will also have the user token injected. - *

+ * + *

The cloned call will also have the user token injected. * * @return a new call instance */ @@ -246,19 +239,16 @@ public boolean isCanceled() { /** * Returns the original HTTP request with the user token attached as a typed tag. - *

- * The token is stored using {@link Request#tag(Class, Object)} and can be retrieved - * by interceptors using {@code request.tag(UserToken.class)}. - *

+ * + *

The token is stored using {@link Request#tag(Class, Object)} and can be retrieved by + * interceptors using {@code request.tag(UserToken.class)}. * * @return the request with the user token tag */ @Override public @NotNull Request request() { Request original = delegate.request(); - return original.newBuilder() - .tag(UserToken.class, token) - .build(); + return original.newBuilder().tag(UserToken.class, token).build(); } /** @@ -270,4 +260,4 @@ public boolean isCanceled() { public @NotNull okio.Timeout timeout() { return rawCall != null ? rawCall.timeout() : okio.Timeout.NONE; } -} \ No newline at end of file +} diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserClient.java b/src/main/java/io/getstream/chat/java/services/framework/UserClient.java index 62e7e7ccf..de26cf8c1 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserClient.java @@ -1,75 +1,72 @@ package io.getstream.chat.java.services.framework; -import org.jetbrains.annotations.NotNull; - import java.time.Duration; +import org.jetbrains.annotations.NotNull; /** * Client implementation for user-scoped API operations. - *

- * This client wraps a base {@link Client} and automatically injects a user-specific - * authentication token into all service calls. It's designed for scenarios where - * different users need to make authenticated API calls without creating separate - * client instances per user. - *

+ * + *

This client wraps a base {@link Client} and automatically injects a user-specific + * authentication token into all service calls. It's designed for scenarios where different users + * need to make authenticated API calls without creating separate client instances per user. * * @see Client */ public final class UserClient implements Client { - private final Client delegate; - private final String userToken; + private final Client delegate; + private final String userToken; - /** - * Constructs a new UserClient that wraps the provided client with user authentication. - * - * @param delegate the base client to delegate calls to - * @param userToken the user-specific authentication token to inject into requests - */ - public UserClient(Client delegate, String userToken) { - this.delegate = delegate; - this.userToken = userToken; - } + /** + * Constructs a new UserClient that wraps the provided client with user authentication. + * + * @param delegate the base client to delegate calls to + * @param userToken the user-specific authentication token to inject into requests + */ + public UserClient(Client delegate, String userToken) { + this.delegate = delegate; + this.userToken = userToken; + } - /** - * Creates a service proxy that automatically injects the user token into all requests. - * - * @param svcClass the service interface class - * @param the service type - * @return a proxy instance of the service with user token injection - */ - @Override - public @NotNull TService create(Class svcClass) { - return delegate.create(svcClass, userToken); - } + /** + * Creates a service proxy that automatically injects the user token into all requests. + * + * @param svcClass the service interface class + * @param the service type + * @return a proxy instance of the service with user token injection + */ + @Override + public @NotNull TService create(Class svcClass) { + return delegate.create(svcClass, userToken); + } - /** - * Returns the API key from the underlying client. - * - * @return the API key - */ - @Override - public @NotNull String getApiKey() { - return delegate.getApiKey(); - } + /** + * Returns the API key from the underlying client. + * + * @return the API key + */ + @Override + public @NotNull String getApiKey() { + return delegate.getApiKey(); + } - /** - * Returns the API secret from the underlying client. - * - * @return the API secret - */ - @Override - public @NotNull String getApiSecret() { - return delegate.getApiSecret(); - } + /** + * Returns the API secret from the underlying client. + * + * @return the API secret + */ + @Override + public @NotNull String getApiSecret() { + return delegate.getApiSecret(); + } - /** - * Sets the request timeout duration on the underlying client. - * - * @param timeoutDuration the timeout duration to set - */ - @Override - public void setTimeout(@NotNull Duration timeoutDuration) { - delegate.setTimeout(timeoutDuration); - } + /** + * Sets the request timeout duration on the underlying client. + * + * @param timeoutDuration the timeout duration to set + */ + @Override + public void setTimeout(@NotNull Duration timeoutDuration) { + delegate.setTimeout(timeoutDuration); + } } diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java index bdd0615e0..63bd7737b 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java @@ -2,14 +2,12 @@ /** * Factory interface for creating service instances with user-specific authentication. - *

- * Implementations of this interface are responsible for creating Retrofit service - * proxies that inject the provided {@link UserToken} into API requests. This enables - * per-user authentication without requiring separate HTTP client instances. - *

- *

- * Package-private to control instantiation within the framework. - *

+ * + *

Implementations of this interface are responsible for creating Retrofit service proxies that + * inject the provided {@link UserToken} into API requests. This enables per-user authentication + * without requiring separate HTTP client instances. + * + *

Package-private to control instantiation within the framework. * * @see UserToken * @see UserTokenCallRewriter @@ -25,5 +23,4 @@ interface UserServiceFactory { * @return a proxy instance of the service with token injection capabilities */ TService create(Class svcClass, UserToken userToken); - } diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryCall.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryCall.java index c42138a88..3bc5bb017 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryCall.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryCall.java @@ -1,31 +1,28 @@ package io.getstream.chat.java.services.framework; -import retrofit2.Retrofit; - import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.concurrent.ConcurrentHashMap; +import retrofit2.Retrofit; /** * A user service factory implementation that wraps Retrofit service calls with user token context. - *

- * This factory creates dynamic proxies around Retrofit service interfaces, intercepting method calls - * to wrap any {@link retrofit2.Call} results with {@link UserCall}. This enables automatic user token - * injection for client-side requests without modifying the service interface definitions. - *

- *

- * The wrapping process is transparent to callers - they interact with the service interface normally, - * but each Retrofit Call is automatically enhanced with the provided user token. - *

- *

- * Requirements: Service methods must return {@code Call} with a type parameter (not raw Call). - * The service interface must be compiled with generic type information preserved (default behavior). - *

- *

- * Performance: Response type extraction is cached per-method to minimize reflection overhead - * on the hot path (~10ns overhead per call after caching vs ~100ns without). - *

+ * + *

This factory creates dynamic proxies around Retrofit service interfaces, intercepting method + * calls to wrap any {@link retrofit2.Call} results with {@link UserCall}. This enables automatic + * user token injection for client-side requests without modifying the service interface + * definitions. + * + *

The wrapping process is transparent to callers - they interact with the service interface + * normally, but each Retrofit Call is automatically enhanced with the provided user token. + * + *

Requirements: Service methods must return {@code Call} with a type parameter (not + * raw Call). The service interface must be compiled with generic type information preserved + * (default behavior). + * + *

Performance: Response type extraction is cached per-method to minimize reflection + * overhead on the hot path (~10ns overhead per call after caching vs ~100ns without). * * @see UserServiceFactory * @see UserCall @@ -34,13 +31,12 @@ final class UserServiceFactoryCall implements UserServiceFactory { private final Retrofit retrofit; - + /** - * Cache of response types extracted from service method signatures. - * Key: Method from service interface - * Value: Response type T from Call return type - * - * Thread-safe and lazily populated on first method invocation. + * Cache of response types extracted from service method signatures. Key: Method from service + * interface Value: Response type T from Call return type + * + *

Thread-safe and lazily populated on first method invocation. */ private final ConcurrentHashMap responseTypeCache = new ConcurrentHashMap<>(); @@ -54,48 +50,54 @@ public UserServiceFactoryCall(Retrofit retrofit) { } /** - * Creates a dynamic proxy for the specified service interface that wraps Retrofit Calls with user token context. - *

- * This method generates a service implementation that intercepts all method calls. ALL service methods - * MUST return {@link retrofit2.Call} - methods that don't return Call will fail with {@link IllegalStateException}. - *

+ * Creates a dynamic proxy for the specified service interface that wraps Retrofit Calls with user + * token context. + * + *

This method generates a service implementation that intercepts all method calls. ALL service + * methods MUST return {@link retrofit2.Call} - methods that don't return Call will fail with + * {@link IllegalStateException}. * * @param the service interface type - * @param svcClass the service interface class to create - * @param userToken the user token to inject into wrapped calls + * @param svcClass the service interface class to create + * @param userToken the user token to inject into wrapped calls * @return a dynamic proxy implementing the service interface with automatic UserCall wrapping - * @throws IllegalStateException if a service method doesn't return Call or returns raw Call without type parameter + * @throws IllegalStateException if a service method doesn't return Call or returns raw Call + * without type parameter */ @SuppressWarnings("unchecked") public final TService create(Class svcClass, UserToken userToken) { TService delegate = retrofit.create(svcClass); - return (TService) java.lang.reflect.Proxy.newProxyInstance( - svcClass.getClassLoader(), - new Class[] { svcClass }, - (proxy, method, args) -> { - Object result = method.invoke(delegate, args); + return (TService) + java.lang.reflect.Proxy.newProxyInstance( + svcClass.getClassLoader(), + new Class[] {svcClass}, + (proxy, method, args) -> { + Object result = method.invoke(delegate, args); - // ALL service methods MUST return retrofit2.Call for user token injection - if (!(result instanceof retrofit2.Call)) { - throw new IllegalStateException( - "Service method " + method.getDeclaringClass().getName() + "." + method.getName() + - " must return retrofit2.Call for user token injection. " + - "Actual return type: " + (result == null ? "null" : result.getClass().getName())); - } + // ALL service methods MUST return retrofit2.Call for user token injection + if (!(result instanceof retrofit2.Call)) { + throw new IllegalStateException( + "Service method " + + method.getDeclaringClass().getName() + + "." + + method.getName() + + " must return retrofit2.Call for user token injection. " + + "Actual return type: " + + (result == null ? "null" : result.getClass().getName())); + } - retrofit2.Call call = (retrofit2.Call) result; - Type responseType = responseTypeCache.computeIfAbsent(method, this::extractResponseType); - return new UserCall<>(retrofit, userToken, call, responseType); - } - ); + retrofit2.Call call = (retrofit2.Call) result; + Type responseType = + responseTypeCache.computeIfAbsent(method, this::extractResponseType); + return new UserCall<>(retrofit, userToken, call, responseType); + }); } /** * Extracts the response type T from a method that returns Call. - *

- * This method is called once per service method and cached for subsequent invocations. - *

+ * + *

This method is called once per service method and cached for subsequent invocations. * * @param method the service method * @return the response type T from Call @@ -103,24 +105,29 @@ public final TService create(Class svcClass, UserToken user */ private Type extractResponseType(Method method) { Type returnType = method.getGenericReturnType(); - + if (!(returnType instanceof ParameterizedType)) { throw new IllegalStateException( - "Service method " + method.getDeclaringClass().getName() + "." + method.getName() + - " must return Call with a type parameter, not raw Call. " + - "Ensure the service interface is compiled with generic type information."); + "Service method " + + method.getDeclaringClass().getName() + + "." + + method.getName() + + " must return Call with a type parameter, not raw Call. " + + "Ensure the service interface is compiled with generic type information."); } - + ParameterizedType parameterizedType = (ParameterizedType) returnType; Type[] typeArguments = parameterizedType.getActualTypeArguments(); - + if (typeArguments.length == 0) { throw new IllegalStateException( - "Service method " + method.getDeclaringClass().getName() + "." + method.getName() + - " returns Call without type arguments. Expected Call."); + "Service method " + + method.getDeclaringClass().getName() + + "." + + method.getName() + + " returns Call without type arguments. Expected Call."); } - + return typeArguments[0]; } - } diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryProxy.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryProxy.java index 0376a9805..1966eeff7 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryProxy.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryProxy.java @@ -1,16 +1,16 @@ package io.getstream.chat.java.services.framework; -import retrofit2.Retrofit; - import static java.lang.reflect.Proxy.newProxyInstance; +import retrofit2.Retrofit; + /** * User-aware service factory that uses dynamic proxies to inject user tokens. - *

- * This implementation wraps Retrofit service interfaces with a dynamic proxy that intercepts + * + *

This implementation wraps Retrofit service interfaces with a dynamic proxy that intercepts * method calls and delegates to {@link UserTokenCallRewriter} for token injection. - *

- * Mechanism: Uses Java reflection {@link java.lang.reflect.Proxy} to wrap the service + * + *

Mechanism: Uses Java reflection {@link java.lang.reflect.Proxy} to wrap the service * interface and inject user tokens at method invocation time. * * @see UserTokenCallRewriter @@ -30,22 +30,22 @@ public UserServiceFactoryProxy(Retrofit retrofit) { /** * Creates a user-aware service instance using a dynamic proxy. - *

- * The returned service is a dynamic proxy that intercepts all method calls and delegates - * to the underlying Retrofit service while injecting the user token. * - * @param svcClass the Retrofit service interface class + *

The returned service is a dynamic proxy that intercepts all method calls and delegates to + * the underlying Retrofit service while injecting the user token. + * + * @param svcClass the Retrofit service interface class * @param userToken the user token to inject into all requests from this service * @param the service type * @return a proxied service instance that injects the user token */ @SuppressWarnings("unchecked") public final TService create(Class svcClass, UserToken userToken) { - return (TService) newProxyInstance( - svcClass.getClassLoader(), - new Class[] { svcClass }, - new UserTokenCallRewriter<>(retrofit.callFactory(), retrofit.create(svcClass), userToken) - ); + return (TService) + newProxyInstance( + svcClass.getClassLoader(), + new Class[] {svcClass}, + new UserTokenCallRewriter<>( + retrofit.callFactory(), retrofit.create(svcClass), userToken)); } - } diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactorySelector.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactorySelector.java index 50b65afa5..db7b8b0cb 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactorySelector.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactorySelector.java @@ -1,13 +1,13 @@ package io.getstream.chat.java.services.framework; -import retrofit2.Retrofit; import java.util.concurrent.atomic.AtomicReference; +import retrofit2.Retrofit; /** * Smart user-aware service factory with automatic fallback mechanism. - * - * This implementation attempts to use {@link UserServiceFactoryProxy} (more efficient) - * and automatically falls back to {@link UserServiceFactoryTagging} if the proxy approach fails. + * + *

This implementation attempts to use {@link UserServiceFactoryProxy} (more efficient) and + * automatically falls back to {@link UserServiceFactoryTagging} if the proxy approach fails. */ final class UserServiceFactorySelector implements UserServiceFactory { @@ -23,7 +23,7 @@ final class UserServiceFactorySelector implements UserServiceFactory { public UserServiceFactorySelector(Retrofit retrofit) { this.proxyFactory = new UserServiceFactoryProxy(retrofit); this.taggingFactory = new UserServiceFactoryTagging(retrofit); - + // Verify proxy approach is viable before setting default UserServiceFactory defaultFactory = proxyFactory; try { @@ -35,18 +35,17 @@ public UserServiceFactorySelector(Retrofit retrofit) { // Proxy approach won't work, use tagging as default defaultFactory = taggingFactory; } - + this.activeFactory = new AtomicReference<>(defaultFactory); } /** * Creates a user-aware service instance with automatic fallback. - *

- * Attempts to use the proxy implementation first. If it fails (due to reflection issues, - * API changes, or other errors), automatically switches to the tagging implementation - * and retries. * - * @param svcClass the Retrofit service interface class + *

Attempts to use the proxy implementation first. If it fails (due to reflection issues, API + * changes, or other errors), automatically switches to the tagging implementation and retries. + * + * @param svcClass the Retrofit service interface class * @param userToken the user token to inject into all requests from this service * @param the service type * @return a service instance that injects the user token @@ -55,7 +54,7 @@ public UserServiceFactorySelector(Retrofit retrofit) { @Override public TService create(Class svcClass, UserToken userToken) { UserServiceFactory factory = activeFactory.get(); - + try { return factory.create(svcClass, userToken); } catch (Throwable e) { @@ -71,10 +70,9 @@ public TService create(Class svcClass, UserToken userToken) try { return taggingFactory.create(svcClass, userToken); } catch (Throwable fallbackException) { - throw new RuntimeException("Failed to create service with both implementations", fallbackException); + throw new RuntimeException( + "Failed to create service with both implementations", fallbackException); } } } - } - diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryTagging.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryTagging.java index c5baeafa3..21ea026f4 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryTagging.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactoryTagging.java @@ -5,14 +5,12 @@ /** * User-aware service factory that tags OkHttp requests with user tokens. - *

- * This implementation wraps the OkHttp call factory to automatically attach a {@link UserToken} + * + *

This implementation wraps the OkHttp call factory to automatically attach a {@link UserToken} * as a request tag. The token can then be retrieved by interceptors for authentication purposes. - *

- *

- * Mechanism: Creates a new Retrofit instance with a custom call factory that tags - * each request before delegating to the underlying call factory. - *

+ * + *

Mechanism: Creates a new Retrofit instance with a custom call factory that tags each + * request before delegating to the underlying call factory. */ final class UserServiceFactoryTagging implements UserServiceFactory { @@ -29,27 +27,28 @@ final class UserServiceFactoryTagging implements UserServiceFactory { /** * Creates a user-aware service instance that automatically tags requests with the user token. - *

- * The returned service wraps each OkHttp request with a {@link UserToken} tag that can be + * + *

The returned service wraps each OkHttp request with a {@link UserToken} tag that can be * retrieved by interceptors using {@code request.tag(UserToken.class)}. * - * @param svcClass the Retrofit service interface class + * @param svcClass the Retrofit service interface class * @param userToken the user token to attach to all requests from this service * @param the service type * @return a service instance that tags requests with the user token */ @SuppressWarnings("unchecked") public final TService create(Class svcClass, UserToken userToken) { - Retrofit taggedRetrofit = retrofit.newBuilder() - .callFactory(request -> { - Request taggedRequest = request.newBuilder() - .tag(UserToken.class, userToken) - .build(); - return retrofit.callFactory().newCall(taggedRequest); - }) - .build(); + Retrofit taggedRetrofit = + retrofit + .newBuilder() + .callFactory( + request -> { + Request taggedRequest = + request.newBuilder().tag(UserToken.class, userToken).build(); + return retrofit.callFactory().newCall(taggedRequest); + }) + .build(); return taggedRetrofit.create(svcClass); } - } diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserToken.java b/src/main/java/io/getstream/chat/java/services/framework/UserToken.java index 0bd2a36ae..dcb4a1ab3 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserToken.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserToken.java @@ -2,14 +2,12 @@ /** * Immutable wrapper for a user authentication token. - *

- * This class encapsulates a user token string that is injected into HTTP requests - * for per-user authentication in multi-tenant scenarios. The token is stored as a - * request tag and retrieved by interceptors for adding authorization headers. - *

- *

- * Package-private to prevent direct instantiation outside the framework. - *

+ * + *

This class encapsulates a user token string that is injected into HTTP requests for per-user + * authentication in multi-tenant scenarios. The token is stored as a request tag and retrieved by + * interceptors for adding authorization headers. + * + *

Package-private to prevent direct instantiation outside the framework. */ final class UserToken { private final String value; diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallRewriter.java b/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallRewriter.java index 3dd00a23c..17417fbc1 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallRewriter.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserTokenCallRewriter.java @@ -1,21 +1,20 @@ package io.getstream.chat.java.services.framework; import io.getstream.chat.java.services.framework.internal.TokenInjectionException; -import okhttp3.Call; -import okhttp3.Request; -import org.jetbrains.annotations.NotNull; import java.lang.reflect.Field; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; +import okhttp3.Call; +import okhttp3.Request; +import org.jetbrains.annotations.NotNull; /** - * Dynamic proxy that intercepts Retrofit service calls and injects {@link UserToken} - * into requests for per-user authentication. - *

- * This class uses Java reflection to modify Retrofit's internal {@code Call} objects, - * injecting a {@link UserToken} as a request tag. The token is then retrieved by - * OkHttp interceptors to add authentication headers. - *

+ * Dynamic proxy that intercepts Retrofit service calls and injects {@link UserToken} into requests + * for per-user authentication. + * + *

This class uses Java reflection to modify Retrofit's internal {@code Call} objects, injecting + * a {@link UserToken} as a request tag. The token is then retrieved by OkHttp interceptors to add + * authentication headers. * * @param the service interface type being proxied * @see UserToken @@ -23,11 +22,11 @@ */ class UserTokenCallRewriter implements InvocationHandler { /** - * Cached reference to Retrofit's internal rawCall field. - * Uses double-checked locking for thread-safe lazy initialization. + * Cached reference to Retrofit's internal rawCall field. Uses double-checked locking for + * thread-safe lazy initialization. */ private static volatile Field rawCallField; - + private final Call.Factory callFactory; private final TService delegate; private final UserToken token; @@ -39,19 +38,18 @@ class UserTokenCallRewriter implements InvocationHandler { * @param delegate the original service implementation to proxy * @param token the user token to inject into requests */ - UserTokenCallRewriter(@NotNull Call.Factory callFactory, @NotNull TService delegate, @NotNull UserToken token) { + UserTokenCallRewriter( + @NotNull Call.Factory callFactory, @NotNull TService delegate, @NotNull UserToken token) { this.callFactory = callFactory; this.delegate = delegate; this.token = token; } - + /** * Intercepts service method invocations to inject the user token. - *

- * This method ensures that all service methods return {@code retrofit2.Call} - * objects. If a method returns a different type, a {@link TokenInjectionException} - * is thrown. - *

+ * + *

This method ensures that all service methods return {@code retrofit2.Call} objects. If a + * method returns a different type, a {@link TokenInjectionException} is thrown. * * @param proxy the proxy instance * @param method the method being invoked @@ -63,32 +61,35 @@ class UserTokenCallRewriter implements InvocationHandler { @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { Object result = method.invoke(delegate, args); - + // If the result is a Retrofit Call, inject the user token if (result instanceof retrofit2.Call) { return injectTokenIntoCall((retrofit2.Call) result); } - + // All service methods must return Call for token injection throw new TokenInjectionException( - "Method " + method.getName() + " on " + delegate.getClass().getName() + - " did not return retrofit2.Call. User token injection requires all service methods to return Call."); + "Method " + + method.getName() + + " on " + + delegate.getClass().getName() + + " did not return retrofit2.Call. User token injection requires all service methods to return Call."); } - + /** * Injects the user token into a Retrofit call by modifying its internal OkHttp call. - *

- * The token is added as a request tag of type {@link UserToken}, which can be - * retrieved by OkHttp interceptors for authentication purposes. - *

+ * + *

The token is added as a request tag of type {@link UserToken}, which can be retrieved by + * OkHttp interceptors for authentication purposes. * * @param originalCall the original Retrofit call * @return a cloned call with the user token injected * @throws TokenInjectionException if reflection fails or Retrofit's structure has changed */ - private retrofit2.Call injectTokenIntoCall(retrofit2.Call originalCall) throws TokenInjectionException { + private retrofit2.Call injectTokenIntoCall(retrofit2.Call originalCall) + throws TokenInjectionException { retrofit2.Call clonedCall = originalCall.clone(); - + try { // Cache field lookup for performance (double-checked locking) if (rawCallField == null) { @@ -99,27 +100,26 @@ private retrofit2.Call injectTokenIntoCall(retrofit2.Call originalCall) th } } } - + // Create new request with token tag - Request newRequest = originalCall.request().newBuilder() - .tag(UserToken.class, token) - .build(); - + Request newRequest = originalCall.request().newBuilder().tag(UserToken.class, token).build(); + // Create new OkHttp call with modified request okhttp3.Call newOkHttpCall = callFactory.newCall(newRequest); - + // Inject the new call into the cloned Retrofit call rawCallField.set(clonedCall, newOkHttpCall); - + return clonedCall; } catch (NoSuchFieldException e) { // If Retrofit's internal structure changes, provide clear error message throw new TokenInjectionException( - "Retrofit internal structure changed. Field 'rawCall' not found in " + - clonedCall.getClass().getName() + ". Update client implementation.", e); + "Retrofit internal structure changed. Field 'rawCall' not found in " + + clonedCall.getClass().getName() + + ". Update client implementation.", + e); } catch (IllegalAccessException e) { throw new TokenInjectionException("Failed to inject token into call", e); } } } - diff --git a/src/main/java/io/getstream/chat/java/services/framework/internal/TokenInjectionException.java b/src/main/java/io/getstream/chat/java/services/framework/internal/TokenInjectionException.java index a6bac7c57..bd9b81a37 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/internal/TokenInjectionException.java +++ b/src/main/java/io/getstream/chat/java/services/framework/internal/TokenInjectionException.java @@ -1,18 +1,15 @@ package io.getstream.chat.java.services.framework.internal; /** - * Thrown when user token injection into a request fails. - * This can happen if: - * - A service method doesn't return retrofit2.Call - * - Retrofit's internal structure changes and reflection fails + * Thrown when user token injection into a request fails. This can happen if: - A service method + * doesn't return retrofit2.Call - Retrofit's internal structure changes and reflection fails */ public class TokenInjectionException extends ReflectiveOperationException { public TokenInjectionException(String message) { super(message); } - + public TokenInjectionException(String message, Throwable cause) { super(message, cause); } } - From eb6fd1b78333ff8791a69c51924b43f2c46e5b2a Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 15:44:44 -0400 Subject: [PATCH 20/23] fix imports --- .../chat/java/services/framework/DefaultClient.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java index 01133e1ec..f70c44511 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java @@ -11,10 +11,18 @@ import java.nio.charset.StandardCharsets; import java.security.Key; import java.time.Duration; -import java.util.*; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.Properties; +import java.util.TimeZone; import java.util.concurrent.TimeUnit; import javax.crypto.spec.SecretKeySpec; -import okhttp3.*; +import okhttp3.Call; +import okhttp3.ConnectionPool; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; import okhttp3.logging.HttpLoggingInterceptor; import org.jetbrains.annotations.NotNull; import retrofit2.Retrofit; From fc2d3127d22eede29269661a1af09f35d88b818a Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 16:15:45 -0400 Subject: [PATCH 21/23] code clean up --- .../services/framework/DefaultClient.java | 36 +++++++++---------- .../framework/UserServiceFactory.java | 2 +- .../java/services/framework/UserToken.java | 2 +- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java index f70c44511..01d1d1191 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java @@ -17,8 +17,8 @@ import java.util.Properties; import java.util.TimeZone; import java.util.concurrent.TimeUnit; +import java.util.function.Function; import javax.crypto.spec.SecretKeySpec; -import okhttp3.Call; import okhttp3.ConnectionPool; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; @@ -36,9 +36,9 @@ public class DefaultClient implements Client { private static final String API_DEFAULT_URL = "https://chat.stream-io-api.com"; private static volatile DefaultClient defaultInstance; - @NotNull private OkHttpClient okHttpClient; - @NotNull private Retrofit retrofit; - @NotNull private UserServiceFactory serviceFactory; + @NotNull private final OkHttpClient okHttpClient; + @NotNull private final Retrofit retrofit; + @NotNull private final UserServiceFactory serviceFactory; @NotNull private final String apiSecret; @NotNull private final String apiKey; @NotNull private final Properties extendedProperties; @@ -64,6 +64,11 @@ public DefaultClient() { } public DefaultClient(Properties properties) { + this(properties, UserServiceFactorySelector::new); + } + + public DefaultClient( + Properties properties, Function serviceFactoryBuilder) { extendedProperties = extendProperties(properties); var apiKey = extendedProperties.get(API_KEY_PROP_NAME); var apiSecret = extendedProperties.get(API_SECRET_PROP_NAME); @@ -82,11 +87,12 @@ public DefaultClient(Properties properties) { this.apiSecret = apiSecret.toString(); this.apiKey = apiKey.toString(); - this.retrofit = buildRetrofitClient(); - this.serviceFactory = new UserServiceFactorySelector(retrofit); + this.okHttpClient = buildOkHttpClient(); + this.retrofit = buildRetrofitClient(okHttpClient); + this.serviceFactory = serviceFactoryBuilder.apply(retrofit); } - private Retrofit buildRetrofitClient() { + private OkHttpClient buildOkHttpClient() { OkHttpClient.Builder httpClient = new OkHttpClient.Builder() .connectionPool(new ConnectionPool(5, 59, TimeUnit.SECONDS)) @@ -123,6 +129,10 @@ private Retrofit buildRetrofitClient() { return chain.proceed(builder.build()); }); + return httpClient.build(); + } + + private Retrofit buildRetrofitClient(OkHttpClient okHttpClient) { final ObjectMapper mapper = new ObjectMapper(); // Use field-based serialization but respect @JsonProperty and @JsonAnyGetter annotations mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); @@ -134,22 +144,12 @@ private Retrofit buildRetrofitClient() { new StdDateFormat().withColonInTimeZone(true).withTimeZone(TimeZone.getTimeZone("UTC"))); mapper.enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE); - this.okHttpClient = httpClient.build(); Retrofit.Builder builder = new Retrofit.Builder() .baseUrl(getStreamChatBaseUrl(extendedProperties)) .client(okHttpClient) .addConverterFactory(new QueryConverterFactory()) - .addConverterFactory(JacksonConverterFactory.create(mapper)) - .callFactory( - new Call.Factory() { - @Override - public @NotNull Call newCall(@NotNull Request request) { - return okHttpClient.newCall(request); - } - }); - // builder.client(httpClient.build()); - + .addConverterFactory(JacksonConverterFactory.create(mapper)); return builder.build(); } diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java index 63bd7737b..1fbc6a8cd 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserServiceFactory.java @@ -12,7 +12,7 @@ * @see UserToken * @see UserTokenCallRewriter */ -interface UserServiceFactory { +public interface UserServiceFactory { /** * Creates a service instance that injects the specified user token into all requests. diff --git a/src/main/java/io/getstream/chat/java/services/framework/UserToken.java b/src/main/java/io/getstream/chat/java/services/framework/UserToken.java index dcb4a1ab3..34d470415 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/UserToken.java +++ b/src/main/java/io/getstream/chat/java/services/framework/UserToken.java @@ -9,7 +9,7 @@ * *

Package-private to prevent direct instantiation outside the framework. */ -final class UserToken { +public final class UserToken { private final String value; /** From 270fdf7e119c1cc7bb4df76bd9cdb325fd5f6b12 Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 16:20:32 -0400 Subject: [PATCH 22/23] fix compilation --- .../java/services/framework/DefaultClient.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java index 01d1d1191..89d9ac84c 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java @@ -36,12 +36,13 @@ public class DefaultClient implements Client { private static final String API_DEFAULT_URL = "https://chat.stream-io-api.com"; private static volatile DefaultClient defaultInstance; - @NotNull private final OkHttpClient okHttpClient; - @NotNull private final Retrofit retrofit; - @NotNull private final UserServiceFactory serviceFactory; @NotNull private final String apiSecret; @NotNull private final String apiKey; @NotNull private final Properties extendedProperties; + @NotNull private final Function serviceFactoryBuilder; + + @NotNull Retrofit retrofit; + @NotNull UserServiceFactory serviceFactory; public static DefaultClient getInstance() { if (defaultInstance == null) { @@ -87,8 +88,9 @@ public DefaultClient( this.apiSecret = apiSecret.toString(); this.apiKey = apiKey.toString(); - this.okHttpClient = buildOkHttpClient(); - this.retrofit = buildRetrofitClient(okHttpClient); + this.serviceFactoryBuilder = serviceFactoryBuilder; + + this.retrofit = buildRetrofitClient(buildOkHttpClient()); this.serviceFactory = serviceFactoryBuilder.apply(retrofit); } @@ -178,7 +180,8 @@ public String getApiKey() { public void setTimeout(@NotNull Duration timeoutDuration) { extendedProperties.setProperty( API_TIMEOUT_PROP_NAME, Long.toString(timeoutDuration.toMillis())); - this.retrofit = buildRetrofitClient(); + this.retrofit = buildRetrofitClient(buildOkHttpClient()); + this.serviceFactory = serviceFactoryBuilder.apply(retrofit); } private static @NotNull String jwtToken(String apiSecret) { From 324743ec9ecb6b8c82749b54956c04308b9c82fd Mon Sep 17 00:00:00 2001 From: Kanat Date: Thu, 30 Oct 2025 16:26:15 -0400 Subject: [PATCH 23/23] compile fix --- .../getstream/chat/java/services/framework/DefaultClient.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java index 89d9ac84c..a5e82ea4d 100644 --- a/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java +++ b/src/main/java/io/getstream/chat/java/services/framework/DefaultClient.java @@ -23,7 +23,6 @@ import okhttp3.HttpUrl; import okhttp3.OkHttpClient; import okhttp3.Request; -import okhttp3.logging.HttpLoggingInterceptor; import org.jetbrains.annotations.NotNull; import retrofit2.Retrofit; import retrofit2.converter.jackson.JacksonConverterFactory; @@ -69,7 +68,8 @@ public DefaultClient(Properties properties) { } public DefaultClient( - Properties properties, Function serviceFactoryBuilder) { + @NotNull Properties properties, + @NotNull Function serviceFactoryBuilder) { extendedProperties = extendProperties(properties); var apiKey = extendedProperties.get(API_KEY_PROP_NAME); var apiSecret = extendedProperties.get(API_SECRET_PROP_NAME);