From 46fc5219c89fd37364519dc7973fa37f3541896b Mon Sep 17 00:00:00 2001 From: Lahiru Maramba Date: Thu, 5 Nov 2026 15:34:30 +0000 Subject: [PATCH] fix: default to GsonFactory and lazy-load default JsonFactory. Switch the default JsonFactory in ApiClientUtils from JacksonFactory to GsonFactory and initialize it lazily via a static holder class. Previously, ApiClientUtils referenced JacksonFactory directly even though firebase-admin only received google-http-client-jackson2 transitively from google-cloud-storage. Excluding Cloud Storage or Jackson2 caused a ClassNotFoundException when the JVM verified ApiClientUtils, even when callers explicitly passed a custom JsonFactory to FirebaseOptions. The upstream Gson regressions from 2021 (strict parsing, IOException on malformed JSON, and float serialization) are resolved in google-http-client-gson 2.x, which firebase-admin already depends on. Fixes #1231 --- .../firebase/internal/ApiClientUtils.java | 25 ++- .../google/firebase/FirebaseOptionsTest.java | 2 + .../firebase/internal/ApiClientUtilsTest.java | 165 ++++++++++++++++++ 3 files changed, 185 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/google/firebase/internal/ApiClientUtils.java b/src/main/java/com/google/firebase/internal/ApiClientUtils.java index 6121ddae5..1421a5634 100644 --- a/src/main/java/com/google/firebase/internal/ApiClientUtils.java +++ b/src/main/java/com/google/firebase/internal/ApiClientUtils.java @@ -16,15 +16,13 @@ package com.google.firebase.internal; -import com.google.api.client.googleapis.util.Utils; import com.google.api.client.http.HttpRequestFactory; import com.google.api.client.http.HttpResponse; import com.google.api.client.http.HttpTransport; import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.client.json.gson.GsonFactory; import com.google.common.collect.ImmutableList; import com.google.firebase.FirebaseApp; - import java.io.IOException; /** @@ -80,17 +78,30 @@ public static void disconnectQuietly(HttpResponse response) { } } + /** + * Returns the default {@link JsonFactory} instance used across the SDK when one is not + * explicitly configured via {@link com.google.firebase.FirebaseOptions}. + * + * @return The default {@link JsonFactory} instance. + */ public static JsonFactory getDefaultJsonFactory() { - // Force using the Jackson2 parser for this project for now. Eventually we should switch - // to Gson, but there are some issues that's preventing this migration at the moment. - // See https://github.com/googleapis/google-api-java-client/issues/1779 for details. - return JacksonFactory.getDefaultInstance(); + return JsonFactoryInstanceHolder.INSTANCE; } + /** + * Returns the default {@link HttpTransport} instance used across the SDK when one is not + * explicitly configured via {@link com.google.firebase.FirebaseOptions}. + * + * @return The default {@link HttpTransport} instance. + */ public static HttpTransport getDefaultTransport() { return TransportInstanceHolder.INSTANCE; } + private static class JsonFactoryInstanceHolder { + static final JsonFactory INSTANCE = GsonFactory.getDefaultInstance(); + } + private static class TransportInstanceHolder { static final HttpTransport INSTANCE = new ApacheHttp2Transport(); } diff --git a/src/test/java/com/google/firebase/FirebaseOptionsTest.java b/src/test/java/com/google/firebase/FirebaseOptionsTest.java index 1219eabaf..636320645 100644 --- a/src/test/java/com/google/firebase/FirebaseOptionsTest.java +++ b/src/test/java/com/google/firebase/FirebaseOptionsTest.java @@ -116,6 +116,7 @@ public void createOptionsWithOnlyMandatoryValuesSet() throws IOException { .setCredentials(GoogleCredentials.fromStream(ServiceAccount.EDITOR.asStream())) .build(); assertNotNull(firebaseOptions.getJsonFactory()); + assertTrue(firebaseOptions.getJsonFactory() instanceof GsonFactory); assertNotNull(firebaseOptions.getHttpTransport()); assertNotNull(firebaseOptions.getThreadManager()); assertNull(firebaseOptions.getDatabaseUrl()); @@ -145,6 +146,7 @@ public AccessToken refreshAccessToken() { .build(); assertNotNull(firebaseOptions.getJsonFactory()); + assertTrue(firebaseOptions.getJsonFactory() instanceof GsonFactory); assertNotNull(firebaseOptions.getHttpTransport()); assertNull(firebaseOptions.getDatabaseUrl()); assertNull(firebaseOptions.getStorageBucket()); diff --git a/src/test/java/com/google/firebase/internal/ApiClientUtilsTest.java b/src/test/java/com/google/firebase/internal/ApiClientUtilsTest.java index 56eb14022..c2a3bdbd4 100644 --- a/src/test/java/com/google/firebase/internal/ApiClientUtilsTest.java +++ b/src/test/java/com/google/firebase/internal/ApiClientUtilsTest.java @@ -18,7 +18,10 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import com.google.api.client.http.GenericUrl; @@ -27,15 +30,27 @@ import com.google.api.client.http.HttpResponse; import com.google.api.client.http.HttpTransport; import com.google.api.client.http.HttpUnsuccessfulResponseHandler; +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.gson.GsonFactory; import com.google.api.client.testing.http.MockHttpTransport; import com.google.api.client.testing.http.MockLowLevelHttpResponse; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.io.ByteStreams; import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; import com.google.firebase.TestOnlyImplFirebaseTrampolines; import com.google.firebase.auth.MockGoogleCredentials; import com.google.firebase.internal.RetryInitializer.RetryHandlerDecorator; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import com.google.firebase.remoteconfig.Template; import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Set; import org.junit.After; import org.junit.Test; @@ -137,4 +152,154 @@ public void testVerifyDefaultTransportReused() { assertEquals(t1, t2); } + @Test + public void testDefaultJsonFactoryUsesGsonAndReused() throws IOException { + JsonFactory f1 = ApiClientUtils.getDefaultJsonFactory(); + JsonFactory f2 = ApiClientUtils.getDefaultJsonFactory(); + assertTrue(f1 instanceof GsonFactory); + assertSame(f1, f2); + assertEquals("0.2", f1.toString(0.2f)); + assertThrows( + IOException.class, + () -> f1.createJsonParser("not json").parseAndClose(new HashMap())); + assertThrows( + IOException.class, + () -> f1.createJsonParser("not json").parseAndClose(GenericJson.class)); + } + + @Test + public void testMessagingWorksWithoutJackson2OnClasspath() throws Exception { + ClassLoader isolatedLoader = new PackageBlockingClassLoader( + ApiClientUtilsTest.class.getClassLoader(), + ImmutableSet.of( + "com.google.api.client.json.jackson2.", + "com.fasterxml.jackson.")); + assertThrows( + ClassNotFoundException.class, + () -> isolatedLoader.loadClass("com.google.api.client.json.jackson2.JacksonFactory")); + + Class probe = isolatedLoader.loadClass(JacksonExcludedProbe.class.getName()); + Method method = probe.getMethod("sendWithDefaultJsonFactory"); + String messageId = (String) method.invoke(null); + assertEquals("projects/test-project/messages/msg-123", messageId); + } + + @Test + public void testCustomJsonFactoryDoesNotLoadDefaultJsonFactoryImpl() throws Exception { + ClassLoader isolatedLoader = new PackageBlockingClassLoader( + ApiClientUtilsTest.class.getClassLoader(), + ImmutableSet.of( + "com.google.api.client.json.jackson2.", + "com.fasterxml.jackson.", + "com.google.api.client.json.gson.")); + assertThrows( + ClassNotFoundException.class, + () -> isolatedLoader.loadClass("com.google.api.client.json.gson.GsonFactory")); + + Class probe = isolatedLoader.loadClass(JacksonExcludedProbe.class.getName()); + Method method = probe.getMethod("sendWithCustomJsonFactory", JsonFactory.class); + String messageId = (String) method.invoke(null, GsonFactory.getDefaultInstance()); + assertEquals("projects/test-project/messages/msg-456", messageId); + } + + /** + * Loaded inside {@link PackageBlockingClassLoader} to verify SDK initialization and FCM requests + * when optional JSON parser artifacts are absent from the classpath. + */ + public static final class JacksonExcludedProbe { + + private JacksonExcludedProbe() { } + + /** + * Exercises default JsonFactory paths (FCM send and Remote Config Template) without Jackson2. + */ + public static String sendWithDefaultJsonFactory() throws Exception { + Template template = Template.fromJSON("{\"etag\": \"etag-123\"}"); + assertEquals("etag-123", template.getETag()); + assertNotNull(template.toJSON()); + + MockLowLevelHttpResponse response = new MockLowLevelHttpResponse() + .setContent("{\"name\": \"projects/test-project/messages/msg-123\"}"); + HttpTransport transport = new MockHttpTransport.Builder() + .setLowLevelHttpResponse(response) + .build(); + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(new MockGoogleCredentials("test-token")) + .setProjectId("test-project") + .setHttpTransport(transport) + .build(); + FirebaseApp app = FirebaseApp.initializeApp(options); + try { + return FirebaseMessaging.getInstance(app) + .send(Message.builder().setTopic("test-topic").build()); + } finally { + TestOnlyImplFirebaseTrampolines.clearInstancesForTest(); + } + } + + /** Sends an FCM message using an explicit JsonFactory when default impls are absent. */ + public static String sendWithCustomJsonFactory(JsonFactory jsonFactory) throws Exception { + MockLowLevelHttpResponse response = new MockLowLevelHttpResponse() + .setContent("{\"name\": \"projects/test-project/messages/msg-456\"}"); + HttpTransport transport = new MockHttpTransport.Builder() + .setLowLevelHttpResponse(response) + .build(); + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(new MockGoogleCredentials("test-token")) + .setProjectId("test-project") + .setHttpTransport(transport) + .setJsonFactory(jsonFactory) + .build(); + FirebaseApp app = FirebaseApp.initializeApp(options); + try { + return FirebaseMessaging.getInstance(app) + .send(Message.builder().setTopic("test-topic").build()); + } finally { + TestOnlyImplFirebaseTrampolines.clearInstancesForTest(); + } + } + } + + private static final class PackageBlockingClassLoader extends ClassLoader { + + private final Set blockedPrefixes; + + PackageBlockingClassLoader(ClassLoader parent, Set blockedPrefixes) { + super(parent); + this.blockedPrefixes = blockedPrefixes; + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + if (loaded != null) { + return loaded; + } + for (String prefix : blockedPrefixes) { + if (name.startsWith(prefix)) { + throw new ClassNotFoundException(name); + } + } + if (name.startsWith("com.google.firebase.")) { + String resourcePath = name.replace('.', '/') + ".class"; + try (InputStream in = getParent().getResourceAsStream(resourcePath)) { + if (in == null) { + throw new ClassNotFoundException(name); + } + byte[] bytes = ByteStreams.toByteArray(in); + Class defined = defineClass(name, bytes, 0, bytes.length); + if (resolve) { + resolveClass(defined); + } + return defined; + } catch (IOException e) { + throw new ClassNotFoundException(name, e); + } + } + return super.loadClass(name, resolve); + } + } + } + }