Repository: apollographql/apollo-kotlin (kotlin)

Issue:
Increase the maximum JSON nesting level, or make it customisable
### Use case

I am querying a page of nested comments from a certain API, and getting an exception: `JsonDataException - Nesting too deep at [data, Page, threadComments, 0, childComments, 0, childComments, 0, childComments, 0, childComments, 0 ...`. Looking at past issues (#3308), I can see that the limited used to be 32 and was increased to 256. And the JSON response I am getting indeed has very deep nesting, however, it is a valid response and I want it to be parsed


### Describe the solution you'd like

I propose to either increase the nesting level limit further, or to make it possible to customize/change the limit (eg in the `ApolloClient.Builder`) so that it can be increased in cases like mine

Pull request description:
If the JSON is too nested, an OutOfMemory exception will be thrown.

Closes https://github.com/apollographql/apollo-kotlin/issues/5159

Patch:
diff --git a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/json/BufferedSinkJsonWriter.kt b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/json/BufferedSinkJsonWriter.kt
index d6aa2e5b635..347201e939b 100644
--- a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/json/BufferedSinkJsonWriter.kt
+++ b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/json/BufferedSinkJsonWriter.kt
@@ -16,9 +16,8 @@
 package com.apollographql.apollo3.api.json
 
 import com.apollographql.apollo3.api.Upload
-import com.apollographql.apollo3.api.json.BufferedSourceJsonReader.Companion.MAX_STACK_SIZE
+import com.apollographql.apollo3.api.json.BufferedSourceJsonReader.Companion.INITIAL_STACK_SIZE
 import com.apollographql.apollo3.api.json.internal.JsonScope
-import com.apollographql.apollo3.exception.JsonDataException
 import okio.BufferedSink
 import okio.IOException
 import kotlin.jvm.JvmOverloads
@@ -39,12 +38,10 @@ class BufferedSinkJsonWriter @JvmOverloads constructor(
     private val sink: BufferedSink,
     private val indent: String? = null,
 ) : JsonWriter {
-  // The nesting stack. Using a manual array rather than an ArrayList saves 20%. This stack permits  up to MAX_STACK_SIZE levels of nesting including
-  // the top-level document. Deeper nesting is prone to trigger StackOverflowErrors.
   private var stackSize = 0
-  private val scopes = IntArray(MAX_STACK_SIZE)
-  private val pathNames = arrayOfNulls<String>(MAX_STACK_SIZE)
-  private val pathIndices = IntArray(MAX_STACK_SIZE)
+  private var scopes = IntArray(INITIAL_STACK_SIZE)
+  private var pathNames = arrayOfNulls<String>(INITIAL_STACK_SIZE)
+  private var pathIndices = IntArray(INITIAL_STACK_SIZE)
 
   /** The name/value separator; either ":" or ": ".  */
   private val separator: String
@@ -256,7 +253,9 @@ class BufferedSinkJsonWriter @JvmOverloads constructor(
 
   private fun pushScope(newTop: Int) {
     if (stackSize == scopes.size) {
-      throw JsonDataException("Nesting too deep at $path: circular reference?")
+      scopes = scopes.copyOf(scopes.size * 2)
+      pathNames = pathNames.copyOf(pathNames.size * 2)
+      pathIndices = pathIndices.copyOf(pathIndices.size * 2)
     }
     scopes[stackSize++] = newTop
   }
diff --git a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/json/BufferedSourceJsonReader.kt b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/json/BufferedSourceJsonReader.kt
index 91ef4568022..c21e86a2e6f 100644
--- a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/json/BufferedSourceJsonReader.kt
+++ b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/json/BufferedSourceJsonReader.kt
@@ -52,19 +52,14 @@ class BufferedSourceJsonReader(private val source: BufferedSource) : JsonReader
    */
   private var peekedString: String? = null
 
-  /**
-   * The nesting stack. Using a manual array rather than an ArrayList saves 20%.
-   * This stack permits up to MAX_STACK_SIZE levels of nesting including the top-level document.
-   * Deeper nesting is prone to trigger StackOverflowErrors.
-   */
-  private val stack = IntArray(MAX_STACK_SIZE).apply {
+  private var stack = IntArray(INITIAL_STACK_SIZE).apply {
     this[0] = JsonScope.EMPTY_DOCUMENT
   }
   private var stackSize = 1
-  private val pathNames = arrayOfNulls<String>(MAX_STACK_SIZE)
-  private val pathIndices = IntArray(MAX_STACK_SIZE)
+  private var pathNames = arrayOfNulls<String>(INITIAL_STACK_SIZE)
+  private var pathIndices = IntArray(INITIAL_STACK_SIZE)
 
-  private val indexStack = IntArray(MAX_STACK_SIZE).apply {
+  private var indexStack = IntArray(INITIAL_STACK_SIZE).apply {
     this[0] = 0
   }
   private var indexStackSize = 1
@@ -746,7 +741,12 @@ class BufferedSourceJsonReader(private val source: BufferedSource) : JsonReader
   }
 
   private fun push(newTop: Int) {
-    if (stackSize == stack.size) throw JsonDataException("Nesting too deep at " + getPath())
+    if (stackSize == stack.size) {
+      stack = stack.copyOf(stack.size * 2)
+      pathNames = pathNames.copyOf(pathNames.size * 2)
+      pathIndices = pathIndices.copyOf(pathIndices.size * 2)
+      indexStack = indexStack.copyOf(indexStack.size * 2)
+    }
     stack[stackSize++] = newTop
   }
 
@@ -888,6 +888,6 @@ class BufferedSourceJsonReader(private val source: BufferedSource) : JsonReader
     private const val NUMBER_CHAR_EXP_SIGN = 6
     private const val NUMBER_CHAR_EXP_DIGIT = 7
 
-    internal const val MAX_STACK_SIZE = 256
+    internal const val INITIAL_STACK_SIZE = 64
   }
 }
diff --git a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/json/MapJsonReader.kt b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/json/MapJsonReader.kt
index b63f45a2f6e..f5f00e38f23 100644
--- a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/json/MapJsonReader.kt
+++ b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/json/MapJsonReader.kt
@@ -1,6 +1,6 @@
 package com.apollographql.apollo3.api.json
 
-import com.apollographql.apollo3.api.json.BufferedSourceJsonReader.Companion.MAX_STACK_SIZE
+import com.apollographql.apollo3.api.json.BufferedSourceJsonReader.Companion.INITIAL_STACK_SIZE
 import com.apollographql.apollo3.api.json.MapJsonReader.Companion.buffer
 import com.apollographql.apollo3.api.json.internal.toDoubleExact
 import com.apollographql.apollo3.api.json.internal.toIntExact
@@ -50,14 +50,14 @@ constructor(
    * - a String representing the current key to be read in a Map
    * - null if peekedToken is BEGIN_OBJECT
    */
-  private val path = arrayOfNulls<Any>(MAX_STACK_SIZE)
+  private var path = arrayOfNulls<Any>(INITIAL_STACK_SIZE)
 
   /**
    * The current object memorized in case we need to rewind
    */
-  private var containerStack = arrayOfNulls<Map<String, Any?>>(MAX_STACK_SIZE)
-  private val iteratorStack = arrayOfNulls<Iterator<*>>(MAX_STACK_SIZE)
-  private val nameIndexStack = IntArray(MAX_STACK_SIZE)
+  private var containerStack = arrayOfNulls<Map<String, Any?>>(INITIAL_STACK_SIZE)
+  private var iteratorStack = arrayOfNulls<Iterator<*>>(INITIAL_STACK_SIZE)
+  private var nameIndexStack = IntArray(INITIAL_STACK_SIZE)
 
   private var stackSize = 0
 
@@ -113,6 +113,16 @@ constructor(
     }
   }
 
+  private fun increaseStack() {
+    if (stackSize == path.size) {
+      path = path.copyOf(path.size * 2)
+      containerStack = containerStack.copyOf(containerStack.size * 2)
+      nameIndexStack = nameIndexStack.copyOf(nameIndexStack.size * 2)
+      iteratorStack = iteratorStack.copyOf(iteratorStack.size * 2)
+    }
+    stackSize++
+  }
+
   override fun beginArray() = apply {
     if (peek() != JsonReader.Token.BEGIN_ARRAY) {
       throw JsonDataException("Expected BEGIN_ARRAY but was ${peek()} at path ${getPathAsString()}")
@@ -120,10 +130,7 @@ constructor(
 
     val currentValue = peekedData as List<Any?>
 
-    check(stackSize < MAX_STACK_SIZE) {
-      "Nesting too deep"
-    }
-    stackSize++
+    increaseStack()
 
     path[stackSize - 1] = -1
     iteratorStack[stackSize - 1] = currentValue.iterator()
@@ -145,10 +152,8 @@ constructor(
       throw JsonDataException("Expected BEGIN_OBJECT but was ${peek()} at path ${getPathAsString()}")
     }
 
-    check(stackSize < MAX_STACK_SIZE) {
-      "Nesting too deep"
-    }
-    stackSize++
+    increaseStack()
+
     @Suppress("UNCHECKED_CAST")
     containerStack[stackSize - 1] = peekedData as Map<String, Any?>
 
diff --git a/libraries/apollo-api/src/jsMain/kotlin/com/apollographql/apollo3/api/json/DynamicJsJsonReader.kt b/libraries/apollo-api/src/jsMain/kotlin/com/apollographql/apollo3/api/json/DynamicJsJsonReader.kt
index da6c850c81d..b138c5b0cd4 100644
--- a/libraries/apollo-api/src/jsMain/kotlin/com/apollographql/apollo3/api/json/DynamicJsJsonReader.kt
+++ b/libraries/apollo-api/src/jsMain/kotlin/com/apollographql/apollo3/api/json/DynamicJsJsonReader.kt
@@ -1,6 +1,6 @@
 package com.apollographql.apollo3.api.json
 
-import com.apollographql.apollo3.api.json.BufferedSourceJsonReader.Companion.MAX_STACK_SIZE
+import com.apollographql.apollo3.api.json.BufferedSourceJsonReader.Companion.INITIAL_STACK_SIZE
 import com.apollographql.apollo3.api.json.MapJsonReader.Companion.buffer
 import com.apollographql.apollo3.api.json.internal.toDoubleExact
 import com.apollographql.apollo3.api.json.internal.toIntExact
@@ -74,14 +74,14 @@ constructor(
    * - a String representing the current key to be read in a Map
    * - null if peekedToken is BEGIN_OBJECT
    */
-  private val path = arrayOfNulls<Any>(MAX_STACK_SIZE)
+  private var path = arrayOfNulls<Any>(INITIAL_STACK_SIZE)
 
   /**
    * The current object memorized in case we need to rewind
    */
-  private var containerStack = arrayOfNulls<Any?>(MAX_STACK_SIZE)
-  private val iteratorStack = arrayOfNulls<IteratorWrapper>(MAX_STACK_SIZE)
-  private val nameIndexStack = IntArray(MAX_STACK_SIZE)
+  private var containerStack = arrayOfNulls<Any?>(INITIAL_STACK_SIZE)
+  private var iteratorStack = arrayOfNulls<IteratorWrapper>(INITIAL_STACK_SIZE)
+  private var nameIndexStack = IntArray(INITIAL_STACK_SIZE)
 
   private var stackSize = 0
 
@@ -143,17 +143,25 @@ constructor(
     }
   }
 
+  private fun increaseStack() {
+    if (stackSize == path.size) {
+      path = path.copyOf(path.size * 2)
+      containerStack = containerStack.copyOf(containerStack.size * 2)
+      nameIndexStack = nameIndexStack.copyOf(nameIndexStack.size * 2)
+      iteratorStack = iteratorStack.copyOf(iteratorStack.size * 2)
+    }
+    stackSize++
+  }
+
+
   override fun beginArray() = apply {
     if (peek() != JsonReader.Token.BEGIN_ARRAY) {
       throw JsonDataException("Expected BEGIN_ARRAY but was ${peek()} at path ${getPathAsString()}")
     }
 
     val currentValue = peekedData as Array<*>
-
-    check(stackSize < MAX_STACK_SIZE) {
-      "Nesting too deep"
-    }
-    stackSize++
+    
+    increaseStack()
 
     path[stackSize - 1] = -1
     iteratorStack[stackSize - 1] = IteratorWrapper.StandardIterator(currentValue.iterator())
@@ -175,10 +183,7 @@ constructor(
       throw JsonDataException("Expected BEGIN_OBJECT but was ${peek()} at path ${getPathAsString()}")
     }
 
-    check(stackSize < MAX_STACK_SIZE) {
-      "Nesting too deep"
-    }
-    stackSize++
+    increaseStack()
     containerStack[stackSize - 1] = peekedData.asDynamic()
 
     rewind()


Test patch:
diff --git a/libraries/apollo-api/src/commonTest/kotlin/test/JsonTest.kt b/libraries/apollo-api/src/commonTest/kotlin/test/JsonTest.kt
index 41a6202ecf4..10d697358a6 100644
--- a/libraries/apollo-api/src/commonTest/kotlin/test/JsonTest.kt
+++ b/libraries/apollo-api/src/commonTest/kotlin/test/JsonTest.kt
@@ -3,8 +3,12 @@ package test
 import com.apollographql.apollo3.api.AnyAdapter
 import com.apollographql.apollo3.api.CustomScalarAdapters
 import com.apollographql.apollo3.api.LongAdapter
+import com.apollographql.apollo3.api.json.MapJsonReader
 import com.apollographql.apollo3.api.json.MapJsonWriter
 import com.apollographql.apollo3.api.json.buildJsonString
+import com.apollographql.apollo3.api.json.jsonReader
+import com.apollographql.apollo3.api.json.readAny
+import okio.Buffer
 import kotlin.test.Test
 import kotlin.test.assertEquals
 
@@ -27,4 +31,36 @@ class JsonTest {
     }
     assertEquals("9223372036854775807", json)
   }
+
+  @Test
+  fun canReadAndWriteVeryDeeplyNestedJsonSource() {
+    val json = buildJsonString {
+      val nesting = 1025
+      repeat(nesting) {
+        beginObject()
+        name("child")
+      }
+      value("yooooo")
+      repeat(nesting) {
+        endObject()
+      }
+    }
+
+    Buffer().writeUtf8(json).jsonReader().readAny()
+  }
+
+  @Test
+  fun canReadVeryDeeplyNestedJsonMap() {
+    val root = mutableMapOf<String, Any>()
+    var map = root
+    val nesting = 1025
+
+    repeat(nesting) {
+      val newMap = mutableMapOf<String, Any>()
+      map.put("child", newMap)
+      map = newMap
+    }
+
+    MapJsonReader(root).readAny()
+  }
 }