diff --git a/CHANGELOG.md b/CHANGELOG.md index 7497938295..796cfbee91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Session Replay: Improve network body parsing and truncation handling ([#4958](https://github.com/getsentry/sentry-java/pull/4958)) + ### Internal - Support `metric` envelope item type ([#4956](https://github.com/getsentry/sentry-java/pull/4956)) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt index 7bdcbf9c59..5405b33eea 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverter.kt @@ -241,7 +241,12 @@ public open class DefaultReplayBreadcrumbConverter() : ReplayBreadcrumbConverter networkData.request?.let { request -> val requestData = mutableMapOf() request.size?.let { requestData["size"] = it } - request.body?.let { requestData["body"] = it.value } + request.body?.let { + requestData["body"] = it.body + it.warnings?.let { warnings -> + requestData["warnings"] = warnings.map { warning -> warning.value } + } + } if (request.headers.isNotEmpty()) { requestData["headers"] = request.headers @@ -255,7 +260,12 @@ public open class DefaultReplayBreadcrumbConverter() : ReplayBreadcrumbConverter networkData.response?.let { response -> val responseData = mutableMapOf() response.size?.let { responseData["size"] = it } - response.body?.let { responseData["body"] = it.value } + response.body?.let { + responseData["body"] = it.body + it.warnings?.let { warnings -> + responseData["warnings"] = warnings.map { warning -> warning.value } + } + } if (response.headers.isNotEmpty()) { responseData["headers"] = response.headers diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt index 4517655eca..3da118190f 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/DefaultReplayBreadcrumbConverterTest.kt @@ -423,7 +423,7 @@ class DefaultReplayBreadcrumbConverterTest { fakeOkHttpNetworkDetails.setRequestDetails( ReplayNetworkRequestOrResponse( 100L, - NetworkBody.fromString("request body content"), + NetworkBody("request body content"), mapOf("Content-Type" to "application/json"), ) ) @@ -431,7 +431,7 @@ class DefaultReplayBreadcrumbConverterTest { 200, ReplayNetworkRequestOrResponse( 500L, - NetworkBody.fromJsonObject(mapOf("status" to "success", "message" to "OK")), + NetworkBody(mapOf("status" to "success", "message" to "OK")), mapOf("Content-Type" to "text/plain"), ), ) @@ -485,7 +485,7 @@ class DefaultReplayBreadcrumbConverterTest { fakeOkHttpNetworkDetails.setRequestDetails( ReplayNetworkRequestOrResponse( 150L, - NetworkBody.fromJsonArray(listOf("item1", "item2", "item3")), + NetworkBody(listOf("item1", "item2", "item3")), mapOf("Content-Type" to "application/json"), ) ) @@ -493,7 +493,7 @@ class DefaultReplayBreadcrumbConverterTest { 404, ReplayNetworkRequestOrResponse( 550L, - NetworkBody.fromJsonObject(mapOf("status" to "success", "message" to "OK")), + NetworkBody(mapOf("status" to "success", "message" to "OK")), mapOf("Content-Type" to "text/plain"), ), ) @@ -540,7 +540,7 @@ class DefaultReplayBreadcrumbConverterTest { networkRequestData.setRequestDetails( ReplayNetworkRequestOrResponse( 100L, - NetworkBody.fromString("request body content"), + NetworkBody("request body content"), mapOf("Content-Type" to "application/json"), ) ) @@ -548,7 +548,7 @@ class DefaultReplayBreadcrumbConverterTest { 200, ReplayNetworkRequestOrResponse( 100L, - NetworkBody.fromString("respnse body content"), + NetworkBody("response body content"), mapOf("Content-Type" to "application/json"), ), ) diff --git a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt index af7b25a782..6e20ecbcdb 100644 --- a/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt +++ b/sentry-okhttp/src/main/java/io/sentry/okhttp/SentryOkHttpInterceptor.kt @@ -317,13 +317,10 @@ public open class SentryOkHttpInterceptor( val contentTypeString = contentType?.toString() val maxBodySize = SentryReplayOptions.MAX_NETWORK_BODY_SIZE - val contentLength = responseBody.contentLength() - if (contentLength > maxBodySize * 2) { - return NetworkBody.fromString("[Response body too large: $contentLength bytes]") - } - // Peek at the body (doesn't consume it) - val peekBody = peekBody(maxBodySize.toLong()) + // We +1 here in order to properly truncate within NetworkBodyParser.fromBytes + // and be able to distinguish from an oversized request and a request matching maxBodySize + val peekBody = peekBody(maxBodySize.toLong() + 1) val bodyBytes = peekBody.bytes() val charset = contentType?.charset(Charsets.UTF_8)?.name() ?: "UTF-8" diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 0bc833222a..05d53d0b36 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7481,29 +7481,22 @@ public final class io/sentry/util/UrlUtils$UrlDetails { public fun getUrlOrFallback ()Ljava/lang/String; } -public abstract interface class io/sentry/util/network/NetworkBody { - public static fun fromJsonArray (Ljava/util/List;)Lio/sentry/util/network/NetworkBody; - public static fun fromJsonObject (Ljava/util/Map;)Lio/sentry/util/network/NetworkBody; - public static fun fromString (Ljava/lang/String;)Lio/sentry/util/network/NetworkBody; - public abstract fun getValue ()Ljava/lang/Object; -} - -public final class io/sentry/util/network/NetworkBody$JsonArrayImpl : io/sentry/util/network/NetworkBody { - public synthetic fun getValue ()Ljava/lang/Object; - public fun getValue ()Ljava/util/List; - public fun toString ()Ljava/lang/String; -} - -public final class io/sentry/util/network/NetworkBody$JsonObjectImpl : io/sentry/util/network/NetworkBody { - public synthetic fun getValue ()Ljava/lang/Object; - public fun getValue ()Ljava/util/Map; +public final class io/sentry/util/network/NetworkBody { + public fun (Ljava/lang/Object;)V + public fun (Ljava/lang/Object;Ljava/util/List;)V + public fun getBody ()Ljava/lang/Object; + public fun getWarnings ()Ljava/util/List; public fun toString ()Ljava/lang/String; } -public final class io/sentry/util/network/NetworkBody$StringBodyImpl : io/sentry/util/network/NetworkBody { - public synthetic fun getValue ()Ljava/lang/Object; +public final class io/sentry/util/network/NetworkBody$NetworkBodyWarning : java/lang/Enum { + public static final field BODY_PARSE_ERROR Lio/sentry/util/network/NetworkBody$NetworkBodyWarning; + public static final field INVALID_JSON Lio/sentry/util/network/NetworkBody$NetworkBodyWarning; + public static final field JSON_TRUNCATED Lio/sentry/util/network/NetworkBody$NetworkBodyWarning; + public static final field TEXT_TRUNCATED Lio/sentry/util/network/NetworkBody$NetworkBodyWarning; public fun getValue ()Ljava/lang/String; - public fun toString ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/util/network/NetworkBody$NetworkBodyWarning; + public static fun values ()[Lio/sentry/util/network/NetworkBody$NetworkBodyWarning; } public final class io/sentry/util/network/NetworkBodyParser { diff --git a/sentry/src/main/java/io/sentry/util/network/NetworkBody.java b/sentry/src/main/java/io/sentry/util/network/NetworkBody.java index 494aa4ab5a..5b4f6365ad 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkBody.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkBody.java @@ -1,113 +1,61 @@ package io.sentry.util.network; import java.util.List; -import java.util.Map; -import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; /** - * Represents the body content of a network request or response. Can be one of: JSON object, JSON - * array, or string. + * Represents the body content of a network request or response. * *

See Javascript * types */ -public interface NetworkBody { +@ApiStatus.Internal +public final class NetworkBody { - /** - * Creates a NetworkBody from a JSON object. - * - * @param value The map representing the JSON object - * @return A NetworkBody instance for the JSON object - */ - static @NotNull NetworkBody fromJsonObject(@NotNull final Map value) { - return new JsonObjectImpl(value); - } + private final @Nullable Object body; + private final @Nullable List warnings; - /** - * Creates a NetworkBody from a JSON array. - * - * @param value The list representing the JSON array - * @return A NetworkBody instance for the JSON array - */ - static @NotNull NetworkBody fromJsonArray(@NotNull final List value) { - return new JsonArrayImpl(value); + public NetworkBody(final @Nullable Object body) { + this(body, null); } - /** - * Creates a NetworkBody from string content. - * - * @param value The string content - * @return A NetworkBody instance for the string - */ - static @NotNull NetworkBody fromString(@NotNull final String value) { - return new StringBodyImpl(value); + public NetworkBody( + final @Nullable Object body, final @Nullable List warnings) { + this.body = body; + this.warnings = warnings; } - /** - * Gets the underlying value of this NetworkBody. - * - * @return The value as an Object (could be Map, List, or String) - */ - @NotNull - Object getValue(); - - // Private implementation classes - - /** Implementation for JSON object bodies */ - final class JsonObjectImpl implements NetworkBody { - private final @NotNull Map value; - - JsonObjectImpl(@NotNull final Map value) { - this.value = value; - } - - @Override - public @NotNull Map getValue() { - return value; - } - - @Override - public String toString() { - return "NetworkBody.JsonObject{" + value + '}'; - } + public @Nullable Object getBody() { + return body; } - /** Implementation for JSON array bodies */ - final class JsonArrayImpl implements NetworkBody { - private final @NotNull List value; - - JsonArrayImpl(@NotNull final List value) { - this.value = value; - } - - @Override - public @NotNull List getValue() { - return value; - } - - @Override - public String toString() { - return "NetworkBody.JsonArray{" + value + '}'; - } + public @Nullable List getWarnings() { + return warnings; } - /** Implementation for string bodies */ - final class StringBodyImpl implements NetworkBody { - private final @NotNull String value; + // Based on + // https://github.com/getsentry/sentry/blob/ccb61aa9b0f33e1333830093a5ce3bd5db88ef33/static/app/utils/replays/replay.tsx#L5-L12 + public enum NetworkBodyWarning { + JSON_TRUNCATED("JSON_TRUNCATED"), + TEXT_TRUNCATED("TEXT_TRUNCATED"), + INVALID_JSON("INVALID_JSON"), + BODY_PARSE_ERROR("BODY_PARSE_ERROR"); + + private final String value; - StringBodyImpl(@NotNull final String value) { + NetworkBodyWarning(String value) { this.value = value; } - @Override - public @NotNull String getValue() { + public String getValue() { return value; } + } - @Override - public String toString() { - return "NetworkBody.StringBody{" + value + '}'; - } + @Override + public String toString() { + return "NetworkBody{" + "body=" + body + ", warnings=" + warnings + '}'; } } diff --git a/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java b/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java index 7a3c5bdbf4..49325a9900 100644 --- a/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java +++ b/sentry/src/main/java/io/sentry/util/network/NetworkBodyParser.java @@ -1,19 +1,24 @@ package io.sentry.util.network; import io.sentry.ILogger; -import io.sentry.JsonObjectReader; import io.sentry.SentryLevel; +import io.sentry.vendor.gson.stream.JsonReader; import java.io.StringReader; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; /** Utility class for parsing and creating NetworkBody instances. */ +@ApiStatus.Internal public final class NetworkBodyParser { private NetworkBodyParser() {} @@ -22,7 +27,7 @@ private NetworkBodyParser() {} * Creates a NetworkBody from raw bytes with content type information. This is useful for handling * binary or unknown content types. * - * @param bytes The raw bytes of the body + * @param bytes The raw bytes of the body, may be truncated * @param contentType Optional content type hint to help with parsing * @param charset Optional charset to use for text conversion (defaults to UTF-8) * @param maxSizeBytes Maximum size to process @@ -42,31 +47,29 @@ private NetworkBodyParser() {} if (contentType != null && isBinaryContentType(contentType)) { // For binary content, return a description instead of the actual content - return NetworkBody.fromString( + return new NetworkBody( "[Binary data, " + bytes.length + " bytes, type: " + contentType + "]"); } - // Check size limit and truncate if necessary - if (bytes.length > maxSizeBytes) { - logger.log( - SentryLevel.WARNING, "Content exceeds max size limit of " + maxSizeBytes + " bytes"); - return createTruncatedNetworkBody(bytes, maxSizeBytes, charset); - } - // Convert to string and parse try { - String effectiveCharset = charset != null ? charset : "UTF-8"; - String content = new String(bytes, effectiveCharset); - return parse(content, contentType, logger); + final String effectiveCharset = charset != null ? charset : "UTF-8"; + final int size = Math.min(bytes.length, maxSizeBytes); + final boolean isPartial = bytes.length > maxSizeBytes; + final String content = new String(bytes, 0, size, effectiveCharset); + return parse(content, contentType, isPartial, logger); } catch (UnsupportedEncodingException e) { logger.log(SentryLevel.WARNING, "Failed to decode bytes: " + e.getMessage()); - return NetworkBody.fromString("[Failed to decode bytes, " + bytes.length + " bytes]"); + return new NetworkBody( + "[Failed to decode bytes, " + bytes.length + " bytes]", + Collections.singletonList(NetworkBody.NetworkBodyWarning.BODY_PARSE_ERROR)); } } private static @Nullable NetworkBody parse( @Nullable final String content, @Nullable final String contentType, + final boolean isPartial, @Nullable final ILogger logger) { if (content == null || content.isEmpty()) { @@ -75,46 +78,53 @@ private NetworkBodyParser() {} // Handle based on content type hint if provided if (contentType != null) { - String lowerContentType = contentType.toLowerCase(); - + final @NotNull String lowerContentType = contentType.toLowerCase(Locale.ROOT); if (lowerContentType.contains("application/x-www-form-urlencoded")) { - return parseFormUrlEncoded(content, logger); - } - - if (lowerContentType.contains("xml")) { - // For XML, return as string (could be enhanced to parse XML structure) - return NetworkBody.fromString(content); + return parseFormUrlEncoded(content, isPartial, logger); + } else if (lowerContentType.contains("application/json")) { + return parseJson(content, isPartial, logger); } } - // Try to parse as JSON using the existing JsonObjectReader - String trimmed = content.trim(); - if (trimmed.startsWith("{") || trimmed.startsWith("[")) { - try (JsonObjectReader reader = new JsonObjectReader(new StringReader(trimmed))) { - Object parsed = reader.nextObjectOrNull(); - if (parsed instanceof Map) { - @SuppressWarnings("unchecked") - Map map = (Map) parsed; - return NetworkBody.fromJsonObject(map); - } else if (parsed instanceof List) { - @SuppressWarnings("unchecked") - List list = (List) parsed; - return NetworkBody.fromJsonArray(list); - } - } catch (Exception e) { - if (logger != null) { - logger.log(SentryLevel.WARNING, "Failed to parse JSON: " + e.getMessage()); + // Default to string representation, e.g. for XML + final List warnings = + isPartial ? Collections.singletonList(NetworkBody.NetworkBodyWarning.TEXT_TRUNCATED) : null; + return new NetworkBody(content, warnings); + } + + @NotNull + private static NetworkBody parseJson( + final @NotNull String content, final boolean isPartial, final @Nullable ILogger logger) { + try (final JsonReader reader = new JsonReader(new StringReader(content))) { + final @NotNull SaferJsonParser.Result result = SaferJsonParser.parse(reader); + final @Nullable Object data = result.data; + if (data == null && !isPartial && !result.errored && !result.hitMaxDepth) { + // In case the actual JSON body is simply null, simply return null + return new NetworkBody(null); + } else { + final @Nullable List warnings; + if (isPartial || result.hitMaxDepth) { + warnings = Collections.singletonList(NetworkBody.NetworkBodyWarning.JSON_TRUNCATED); + } else if (result.errored) { + warnings = Collections.singletonList(NetworkBody.NetworkBodyWarning.INVALID_JSON); + } else { + warnings = null; } + return new NetworkBody(data, warnings); + } + } catch (Exception e) { + if (logger != null) { + logger.log(SentryLevel.WARNING, "Failed to parse JSON: " + e.getMessage()); } } - - // Default to string representation - return NetworkBody.fromString(content); + return new NetworkBody( + null, Collections.singletonList(NetworkBody.NetworkBodyWarning.INVALID_JSON)); } /** Parses URL-encoded form data into a JsonObject NetworkBody. */ - private static @Nullable NetworkBody parseFormUrlEncoded( - @NotNull final String content, @Nullable final ILogger logger) { + @NotNull + private static NetworkBody parseFormUrlEncoded( + @NotNull final String content, final boolean isPartial, @Nullable final ILogger logger) { try { Map params = new HashMap<>(); String[] pairs = content.split("&", -1); @@ -144,45 +154,25 @@ private NetworkBodyParser() {} } } } - - return NetworkBody.fromJsonObject(params); + final List warnings; + if (isPartial) { + warnings = Collections.singletonList(NetworkBody.NetworkBodyWarning.TEXT_TRUNCATED); + } else { + warnings = null; + } + return new NetworkBody(params, warnings); } catch (UnsupportedEncodingException e) { if (logger != null) { logger.log(SentryLevel.WARNING, "Failed to parse form data: " + e.getMessage()); } - return null; - } - } - - /** Creates a truncated NetworkBody from oversized bytes with proper UTF-8 character handling. */ - private static @NotNull NetworkBody createTruncatedNetworkBody( - @NotNull final byte[] bytes, final int maxSizeBytes, @Nullable final String charset) { - byte[] truncatedBytes = new byte[maxSizeBytes]; - System.arraycopy(bytes, 0, truncatedBytes, 0, maxSizeBytes); - - try { - String effectiveCharset = charset != null ? charset : "UTF-8"; - String content = new String(truncatedBytes, effectiveCharset); - - // Find the last complete character by checking for replacement character - int lastValidIndex = content.length(); - while (lastValidIndex > 0 && content.charAt(lastValidIndex - 1) == '\uFFFD') { - lastValidIndex--; - } - if (lastValidIndex < content.length()) { - content = content.substring(0, lastValidIndex); - } - content += "...[truncated]"; - return NetworkBody.fromString(content); - } catch (UnsupportedEncodingException e) { - return NetworkBody.fromString( - "[Failed to decode truncated bytes, " + bytes.length + " bytes]"); } + return new NetworkBody( + null, Collections.singletonList(NetworkBody.NetworkBodyWarning.BODY_PARSE_ERROR)); } /** Checks if the content type is binary and shouldn't be converted to string. */ private static boolean isBinaryContentType(@NotNull final String contentType) { - String lower = contentType.toLowerCase(); + final @NotNull String lower = contentType.toLowerCase(Locale.ROOT); return lower.contains("image/") || lower.contains("video/") || lower.contains("audio/") @@ -191,4 +181,89 @@ private static boolean isBinaryContentType(@NotNull final String contentType) { || lower.contains("application/zip") || lower.contains("application/gzip"); } + + private static class SaferJsonParser { + + private static final int MAX_DEPTH = 100; + + private static class Result { + private @Nullable Object data; + private boolean hitMaxDepth; + private boolean errored; + } + + final Result result = new Result(); + + private SaferJsonParser() {} + + @NotNull + public static SaferJsonParser.Result parse(final @NotNull JsonReader reader) { + final SaferJsonParser parser = new SaferJsonParser(); + parser.result.data = parser.parse(reader, 0); + return parser.result; + } + + @Nullable + private Object parse(final @NotNull JsonReader reader, final int currentDepth) { + if (result.errored) { + return null; + } + if (currentDepth >= MAX_DEPTH) { + result.hitMaxDepth = true; + return null; + } + try { + switch (reader.peek()) { + case BEGIN_OBJECT: + final @NotNull Map map = new LinkedHashMap<>(); + try { + reader.beginObject(); + while (reader.hasNext() && !result.errored) { + final String name = reader.nextName(); + map.put(name, parse(reader, currentDepth + 1)); + } + reader.endObject(); + } catch (Exception e) { + result.errored = true; + return map; + } + return map; + + case BEGIN_ARRAY: + final @NotNull List list = new ArrayList<>(); + try { + reader.beginArray(); + while (reader.hasNext() && !result.errored) { + list.add(parse(reader, currentDepth + 1)); + } + reader.endArray(); + } catch (Exception e) { + result.errored = true; + return list; + } + return list; + + case STRING: + return reader.nextString(); + + case NUMBER: + return reader.nextDouble(); + + case BOOLEAN: + return reader.nextBoolean(); + + case NULL: + reader.nextNull(); + return null; + + default: + result.errored = true; + return null; + } + } catch (final Exception ignored) { + result.errored = true; + return null; + } + } + } } diff --git a/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt b/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt new file mode 100644 index 0000000000..3b1da25a0c --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/network/NetworkBodyParserTest.kt @@ -0,0 +1,369 @@ +package io.sentry.util.network + +import io.sentry.ILogger +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.Test +import org.mockito.kotlin.mock + +class NetworkBodyParserTest { + + @Test + fun `null body gets detected correctly`() { + val logger = mock() + + assertNull(NetworkBodyParser.fromBytes(null, "application/json", null, 512, logger)) + assertNull(NetworkBodyParser.fromBytes(ByteArray(0), "application/json", null, 512, logger)) + } + + @Test + fun `null json body gets detected correctly`() { + val logger = mock() + val rawJson = "null" + val bytes = rawJson.toByteArray() + val maxSize = bytes.size + + val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, maxSize, logger) + assertNotNull(body) + assertNull(body.warnings) + assertNull(body.body) + } + + @Test + fun `json gets detected correctly`() { + val logger = mock() + val rawJson = "[1, 2, 3]" + val bytes = rawJson.toByteArray() + val maxSize = bytes.size + + val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, maxSize, logger) + assertNotNull(body) + assertNull(body.warnings) + assertEquals(listOf(1.0, 2.0, 3.0), body.body) + } + + @Test + fun `partial json gets parsed correctly`() { + val logger = mock() + val rawJson = "[1, 2, 3]" + val bytes = rawJson.toByteArray() + val maxSize = bytes.size + + val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, maxSize - 1, logger) + assertNotNull(body) + assertEquals(listOf(NetworkBody.NetworkBodyWarning.JSON_TRUNCATED), body.warnings) + assertEquals(listOf(1.0, 2.0, 3.0), body.body) + } + + @Test + fun `json object gets parsed correctly`() { + val logger = mock() + val rawJson = """{"name":"John","age":30,"active":true}""" + val bytes = rawJson.toByteArray() + + val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger) + assertNotNull(body) + assertNull(body.warnings) + + @Suppress("UNCHECKED_CAST") val map = body.body as Map + assertEquals("John", map["name"]) + assertEquals(30.0, map["age"]) + assertEquals(true, map["active"]) + } + + @Test + fun `json with different data types gets parsed correctly`() { + val logger = mock() + val rawJson = + """{"string":"text","number":42,"bool":false,"null":null,"array":[1,2],"nested":{"key":"value"}}""" + val bytes = rawJson.toByteArray() + + val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger) + assertNotNull(body) + + @Suppress("UNCHECKED_CAST") val map = body.body as Map + assertEquals("text", map["string"]) + assertEquals(42.0, map["number"]) + assertEquals(false, map["bool"]) + assertNull(map["null"]) + assertEquals(listOf(1.0, 2.0), map["array"]) + + @Suppress("UNCHECKED_CAST") val nested = map["nested"] as Map + assertEquals("value", nested["key"]) + } + + @Test + fun `json string value gets parsed correctly`() { + val logger = mock() + val rawJson = "\"hello world\"" + val bytes = rawJson.toByteArray() + + val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger) + assertNotNull(body) + assertEquals("hello world", body.body) + } + + @Test + fun `json number value gets parsed correctly`() { + val logger = mock() + val rawJson = "123.45" + val bytes = rawJson.toByteArray() + + val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger) + assertNotNull(body) + assertEquals(123.45, body.body) + } + + @Test + fun `json boolean value gets parsed correctly`() { + val logger = mock() + val rawJson = "true" + val bytes = rawJson.toByteArray() + + val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger) + assertNotNull(body) + assertEquals(true, body.body) + } + + @Test + fun `json null value gets parsed correctly`() { + val logger = mock() + val rawJson = "null" + val bytes = rawJson.toByteArray() + + val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger) + assertNotNull(body) + assertNull(body.body) + } + + @Test + fun `completely malformed json returns warning`() { + val logger = mock() + val rawJson = "not json at all" + val bytes = rawJson.toByteArray() + + val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, bytes.size, logger) + assertNotNull(body) + assertNull(body.body) + assertEquals(listOf(NetworkBody.NetworkBodyWarning.INVALID_JSON), body.warnings) + } + + @Test + fun `highly nested json gets truncated`() { + val logger = mock() + fun wrap(json: String) = "{\"key\": $json}" + + var rawJson = "{\"key\": \"value\"}" + for (i in 0..200) { + rawJson = wrap(rawJson) + } + + val bytes = rawJson.toByteArray() + val maxSize = bytes.size + + val body = NetworkBodyParser.fromBytes(bytes, "application/json", null, maxSize, logger) + assertNotNull(body) + + var map = body.body + var depth = 0 + while (map is Map<*, *>) { + depth++ + map = map["key"] + } + + assertEquals(listOf(NetworkBody.NetworkBodyWarning.JSON_TRUNCATED), body.warnings) + assertEquals(100, depth) + } + + @Test + fun `form urlencoded gets parsed correctly`() { + val logger = mock() + val formData = "name=John&age=30&city=NewYork" + val bytes = formData.toByteArray() + + val body = + NetworkBodyParser.fromBytes( + bytes, + "application/x-www-form-urlencoded", + null, + bytes.size, + logger, + ) + assertNotNull(body) + assertNull(body.warnings) + + @Suppress("UNCHECKED_CAST") val map = body.body as Map + assertEquals("John", map["name"]) + assertEquals("30", map["age"]) + assertEquals("NewYork", map["city"]) + } + + @Test + fun `form urlencoded with special characters gets decoded correctly`() { + val logger = mock() + val formData = "message=Hello+World&email=test%40example.com" + val bytes = formData.toByteArray() + + val body = + NetworkBodyParser.fromBytes( + bytes, + "application/x-www-form-urlencoded", + null, + bytes.size, + logger, + ) + assertNotNull(body) + + @Suppress("UNCHECKED_CAST") val map = body.body as Map + assertEquals("Hello World", map["message"]) + assertEquals("test@example.com", map["email"]) + } + + @Test + fun `form urlencoded with multiple values for same key gets parsed as list`() { + val logger = mock() + val formData = "tag=java&tag=kotlin&tag=android" + val bytes = formData.toByteArray() + + val body = + NetworkBodyParser.fromBytes( + bytes, + "application/x-www-form-urlencoded", + null, + bytes.size, + logger, + ) + assertNotNull(body) + + @Suppress("UNCHECKED_CAST") val map = body.body as Map + val tags = map["tag"] as List + assertEquals(listOf("java", "kotlin", "android"), tags) + } + + @Test + fun `form urlencoded with empty value gets parsed correctly`() { + val logger = mock() + val formData = "key1=value1&key2=&key3=value3" + val bytes = formData.toByteArray() + + val body = + NetworkBodyParser.fromBytes( + bytes, + "application/x-www-form-urlencoded", + null, + bytes.size, + logger, + ) + assertNotNull(body) + + @Suppress("UNCHECKED_CAST") val map = body.body as Map + assertEquals("value1", map["key1"]) + assertEquals("", map["key2"]) + assertEquals("value3", map["key3"]) + } + + @Test + fun `partial form urlencoded gets parsed with warning`() { + val logger = mock() + val suffix = "&more=more" + val formData = "name=John&age=30$suffix" + val bytes = formData.toByteArray() + + val body = + NetworkBodyParser.fromBytes( + bytes, + "application/x-www-form-urlencoded", + null, + bytes.size - suffix.toByteArray().size, + logger, + ) + assertNotNull(body) + assertEquals(listOf(NetworkBody.NetworkBodyWarning.TEXT_TRUNCATED), body.warnings) + + @Suppress("UNCHECKED_CAST") val map = body.body as Map + assertEquals(2, map.size) + assertEquals("John", map["name"]) + assertEquals("30", map["age"]) + } + + @Test + fun `plain text gets parsed as string`() { + val logger = mock() + val text = "This is plain text content" + val bytes = text.toByteArray() + + val body = NetworkBodyParser.fromBytes(bytes, "text/plain", null, bytes.size, logger) + assertNotNull(body) + assertNull(body.warnings) + assertEquals(text, body.body) + } + + @Test + fun `plain text without content type gets parsed as string`() { + val logger = mock() + val text = "This is plain text content" + val bytes = text.toByteArray() + + val body = NetworkBodyParser.fromBytes(bytes, null, null, bytes.size, logger) + assertNotNull(body) + assertNull(body.warnings) + assertEquals(text, body.body) + } + + @Test + fun `partial plain text gets warning`() { + val logger = mock() + val truncated = "..." + val text = "This is plain text content" + val fullText = "$text$truncated" + val bytes = fullText.toByteArray() + + val body = + NetworkBodyParser.fromBytes( + bytes, + "text/plain", + null, + bytes.size - truncated.toByteArray().size, + logger, + ) + assertNotNull(body) + assertEquals(listOf(NetworkBody.NetworkBodyWarning.TEXT_TRUNCATED), body.warnings) + assertEquals(text, body.body) + } + + @Test + fun `binary content returns description`() { + val logger = mock() + val bytes = ByteArray(100) { it.toByte() } + + val body = NetworkBodyParser.fromBytes(bytes, "image/png", null, bytes.size, logger) + assertNotNull(body) + assertEquals("[Binary data, 100 bytes, type: image/png]", body.body) + } + + @Test + fun `custom charset gets used for decoding`() { + val logger = mock() + val text = "Hello World" + val bytes = text.toByteArray(Charsets.UTF_16) + + val body = NetworkBodyParser.fromBytes(bytes, "text/plain", "UTF-16", bytes.size, logger) + assertNotNull(body) + assertEquals(text, body.body) + } + + @Test + fun `invalid charset returns error warning`() { + val logger = mock() + val bytes = "test".toByteArray() + + val body = + NetworkBodyParser.fromBytes(bytes, "text/plain", "INVALID-CHARSET-NAME", bytes.size, logger) + assertNotNull(body) + assertTrue(body.body is String) + assertTrue((body.body as String).contains("Failed to decode bytes")) + assertEquals(listOf(NetworkBody.NetworkBodyWarning.BODY_PARSE_ERROR), body.warnings) + } +}