Skip to content

Instantly share code, notes, and snippets.

@krayong
Created June 16, 2025 20:12
Show Gist options
  • Select an option

  • Save krayong/18c1a86d5516d67df01713b0d7178c36 to your computer and use it in GitHub Desktop.

Select an option

Save krayong/18c1a86d5516d67df01713b0d7178c36 to your computer and use it in GitHub Desktop.
Field-Level JSON Parsing Profiler for Retrofit

Field-Level JSON Parsing Profiler for Retrofit

A custom Retrofit converter that profiles JSON parsing performance at the individual field level, helping you identify performance bottlenecks in your Android app's API responses.

🚀 Key Features

  • 🕒 Microsecond-precision timing for each JSON field
  • 📊 Multiple report formats for different analysis needs
  • 🌳 Hierarchical visualization of JSON structure with timing data
  • 🔄 Zero impact on existing code - works transparently

📊 Generated Reports

The profiler creates organized reports in Downloads/parsing_times/{page_name}/{timestamp}/:

1. Top Slowest Fields(top_100.txt)

  1: user.profile.addresses[0]: 1250.67 µs
  2: response.data.complexObject: 890.12 µs  
  3: items[5].metadata: 567.89 µs

Most actionable report - immediately shows performance bottlenecks

2. Original Response(response.json)

{
  "user": {
    "profile": {
      "name": "John Doe",
      "addresses": [...]
    }
  }
}

Clean reference copy of the original API response

3. Timing-Annotated JSON(tree.json)

{
  "user: 125.5 µs": {
    "profile: 45.2 µs": {
      "name": "John Doe",
      "addresses: 30.1 µs": [...]
    }
  }
}

JSON with embedded timing data for programmatic analysis

4. Hierarchical Tree View(tree.txt)

├── user: 125.5 µs
│   ├── profile: 45.2 µs (Path: user.profile)
│   │   ├── name (Value: "John Doe") (Path: user.profile.name)
│   │   └── addresses: 30.1 µs
│   │       └── [0]: 25.8 µs
│   │           ├── street (Value: "123 Main St")
│   │           └── city (Value: "Springfield")

Visual tree structure showing the complete JSON hierarchy

🛠 Setup

1. Add to your Retrofit Builder

val retrofit = Retrofit.Builder()
    .baseUrl("https://your-api.com/")
    .addConverterFactory(FieldLevelJsonParsingProfiler(applicationContext))
    .addConverterFactory(GsonConverterFactory.create()) // Your existing converter
    .build()

2. Custom Page Naming (optional)

val profiler = FieldLevelJsonParsingProfiler(
    applicationContext = context,
    getPageName = { type, response -> 
        when {
            type.toString().contains("User") -> "user_section"
            type is UserProfileResponse -> "user_profile"
            response is BaseResponse -> response.endpoint
            type is ProductListResponse -> "product_catalog"
            else -> type.simpleName.toLowerSnakeCase().removeSuffix("_response")
        }
    }
)

🔧 Troubleshooting

  1. Check Permissions: Ensure WRITE_EXTERNAL_STORAGE for Android < 10
  2. Verify Setup: Profiler should be added BEFORE any other Converter Factory
  3. Check Logs: Look for Timber error messages

⚠️ Note

This profiler introduces minimal overhead, but should not be included in production builds. Use it during profiling sessions or in debug environments.

import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.google.gson.internal.Streams
import com.google.gson.stream.JsonWriter
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import retrofit2.Converter
import retrofit2.Retrofit
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
import java.io.Serializable
import java.lang.reflect.Type
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
// Constants for file naming and organization
private const val RESPONSE_SUFFIX = "_response"
private const val ROOT = "root"
// Directory structure constants
private const val FOLDER_NAME_FIRST_PATH = "parsing_times"
private const val FOLDER_DATE_FORMAT = "yyyy/MMM/dd/hh-mm-ss aa" // e.g., "2025/Jan/28/02-30-45 PM"
// File naming constants
private const val RESPONSE_FILE_NAME = "response"
private const val TOP_FILE_PREFIX = "top_"
private const val TOP_COUNT_LIMIT = 100 // Maximum number of slowest fields to report
private const val TREE_FILE_NAME = "tree"
// MIME types and extensions for different file outputs
private const val MIME_TYPE_TEXT = "text/plain"
private const val TEXT_FILE_EXTENSION = "txt"
private const val MIME_TYPE_JSON = "application/json"
private const val JSON_FILE_EXTENSION = "json"
/**
* A custom Retrofit converter factory that profiles JSON parsing performance at the field level.
*
* This converter intercepts HTTP responses and measures the time taken to parse each individual
* field in the JSON structure. It generates comprehensive reports saved to device storage including:
* - Top slowest parsing fields ranked by time
* - Original JSON response for reference
* - JSON with embedded timing metadata
* - Hierarchical tree view of parsing times
*
* Use cases:
* - Performance optimization: Identify slow-parsing fields in API responses
* - API monitoring: Track parsing performance over time
* - Debugging: Understand which parts of large JSON responses cause bottlenecks
*
* Files are organized by page name and timestamp in the Downloads folder:
* Downloads/parsing_times/{page_name}/{timestamp}/
* ├── response.json (original response)
* ├── tree.json (response with timing metadata)
* ├── tree.txt (hierarchical text view)
* └── top_100.txt (ranked list of slowest fields)
*
* @param applicationContext Android application context for file system access
* @param getPageName Lambda function to extract page name from response type and data.
* Defaults to converting response type to snake_case (e.g., "UserProfileResponse" -> "user_profile")
*/
class FieldLevelJsonParsingProfiler(
private val applicationContext: Context,
private val getPageName: (type: Type, response: Any?) -> String = { type, response ->
((type as? Class<*>)?.simpleName ?: type.toString()).toLowerSnakeCase().removeSuffix(RESPONSE_SUFFIX)
}
): Converter.Factory() {
// Coroutine scope for background file operations with error handling
private val scope by lazy { CoroutineScope(Dispatchers.IO + CoroutineExceptionHandler { _, t -> Timber.e(t) }) }
/**
* Creates a response body converter that measures JSON parsing performance.
*
* This method is automatically called by Retrofit for each HTTP response.
* The converter works by:
* 1. Extracting the raw JSON string from the response
* 2. Letting Retrofit perform normal parsing (so app functionality is unaffected)
* 3. Performing detailed timing analysis in a background thread
* 4. Saving multiple report formats to device storage
*
* @param type The target type for conversion (e.g., UserProfileResponse::class.java)
* @param annotations Annotations present on the API method (unused but required by interface)
* @param retrofit The Retrofit instance (used to get the next converter in chain)
* @return A converter that measures parsing time while preserving normal functionality
*/
override fun responseBodyConverter(
type: Type, annotations: Array<Annotation>, retrofit: Retrofit
): Converter<ResponseBody, *> = Converter<ResponseBody, Any> { responseBody ->
// Extract raw JSON string before it's consumed by normal parsing
val rawBody = responseBody.string()
// Perform normal Retrofit parsing to maintain app functionality
// This uses the next converter in the chain (typically GsonConverter)
val response = retrofit
.nextResponseBodyConverter<Any>(this, type, annotations)
.convert(rawBody.toResponseBody(responseBody.contentType()))
// Perform detailed timing analysis asynchronously to avoid blocking the response
scope.launch {
// Parse JSON for timing analysis (separate from normal parsing)
val jsonElement = JsonParser.parseString(rawBody)
// Recursively measure parsing time for each field in the JSON structure
// Returns both timing data and a modified JSON with embedded timing info
val (parsingTimes, jsonWithTimingData) = measureParsingTime(jsonElement, "")
// Generate a human-readable name for this response type (e.g., "user_profile")
val pageName = getPageName(type, response)
// Create timestamp for file organization and traceability
val formattedDate = SimpleDateFormat(FOLDER_DATE_FORMAT, Locale.getDefault()).format(Date())
// Generate and save multiple report formats:
// 1. Top N slowest fields in ranked text format (most actionable for developers)
getTopFileOutputStream(
pageName, formattedDate, TOP_COUNT_LIMIT
)?.perform { printTopParsingTimes(parsingTimes, TOP_COUNT_LIMIT) }
// 2. Original JSON response for reference and debugging
getResponseOutputStream(pageName, formattedDate)?.perform { printResponse(jsonElement) }
// 3. JSON with embedded timing metadata (for programmatic analysis)
getJsonTreeOutputStream(pageName, formattedDate)?.perform { printResponse(jsonWithTimingData) }
// 4. Hierarchical tree view in text format (human-readable structure)
getTextTreeOutputStream(
pageName, formattedDate
)?.perform { printParsingTimeTree(buildParsingTimeTree(parsingTimes), 0) }
}
// Return the normally parsed response (converter is transparent to app logic)
response
}
/**
* Recursively measures the time taken to parse each field in a JSON structure.
*
* This method performs a depth-first traversal of the JSON tree, timing each operation.
* For each field, it records:
* - The full path to the field (e.g., "user.profile.addresses[0].street")
* - The time taken to parse that field (in microseconds)
* - The actual value of the field (for primitive types)
*
* The method also creates a parallel JSON structure with timing information embedded,
* which can be useful for visualization or further analysis.
*
* Timing methodology:
* - For objects/arrays: Measures time to process all children
* - For primitives: Records the value instead of timing (parsing is negligible)
* - Uses System.nanoTime() for high precision, converts to microseconds for readability
*
* @param element The JSON element to analyze (object, array, or primitive)
* @param path The current path in the JSON structure (e.g., "user.profile.name")
* @return Pair containing:
* - Map of field paths to timing data (path -> (microseconds, value))
* - Modified JSON element with timing information embedded in keys
*/
private fun measureParsingTime(
element: JsonElement, path: String = ""
): Pair<Map<String, Pair<Double?, String?>>, JsonElement> {
// Storage for timing data: path -> (parsing_time_microseconds, field_value)
val times = mutableMapOf<String, Pair<Double?, String?>>()
val newElement: JsonElement
// Record high-precision start time for this element
val startTime = System.nanoTime()
times[path] = when {
// Handle JSON arrays: ["item1", "item2", {...}]
element.isJsonArray -> {
// Initialize with null timing (will be calculated after processing children)
times[path] = Pair(null, null)
newElement = JsonArray()
val array = element.asJsonArray
// Process each array element with indexed path (e.g., "items[0]", "items[1]")
for (i in 0 until array.size()) {
val (childTimes, childElement) = measureParsingTime(array.get(i), "${path}[${i}]")
// Merge child timing data into our results
times.putAll(childTimes)
// Add processed element to new array
newElement.add(childElement)
}
// Calculate total time for processing this entire array
// Convert nanoseconds to microseconds (÷1000) for better readability
(System.nanoTime() - startTime) / 1000.0 to null
}
// Handle JSON objects: {"key1": "value1", "key2": {...}}
element.isJsonObject -> {
// Initialize with null timing (will be calculated after processing children)
times[path] = Pair(null, null)
newElement = JsonObject()
val obj = element.asJsonObject
// Process each object property with dotted path notation
for ((key, value) in obj.entrySet()) {
// Build hierarchical path (e.g., "" -> "user" -> "user.profile" -> "user.profile.name")
val keyPath = if (path.isEmpty()) key else "${path}.${key}"
val (childTimes, childElement) = measureParsingTime(value, keyPath)
// Merge child timing data into our results
times.putAll(childTimes)
// Create new key with timing information embedded for visualization
// e.g., "name" becomes "name: 123.45 µs" in the timing-annotated JSON
val newKey = childTimes[keyPath]?.first?.let { "${key}: $it µs" } ?: key
newElement.add(newKey, childElement)
}
// Calculate total time for processing this entire object
(System.nanoTime() - startTime) / 1000.0 to null
}
// Handle primitive values: strings, numbers, booleans, null
else -> {
// Primitives don't need timing (parsing is negligible)
// Just preserve the original element and record its value
newElement = element
// Store the actual value for reporting purposes
// First element is timing (null for primitives), second is the value
null to element.toString()
}
}
// Remove the empty root path entry to avoid clutter in reports
if (path == "") times.remove(path)
return times to newElement
}
/**
* Extension function to safely perform operations on OutputStream with automatic resource management.
*
* @param block The operation to perform on the stream
*/
private fun OutputStream.perform(block: OutputStream.() -> Unit) = try {
block()
} catch (t: Throwable) {
Timber.e(t)
} finally {
close()
}
/**
* Writes a ranked list of the slowest parsing fields to a text file.
*
* This generates a report like:
* ```
* 1: user.profile.largeImageArray[0]: 1250.67 µs
* 2: response.data.complexObject: 890.12 µs
* 3: items[5].metadata: 567.89 µs
* ```
*
* This is typically the most actionable report for developers, as it immediately
* highlights which fields are performance bottlenecks.
*
* @param parsingTimes Map of field paths to their timing data
* @param count Maximum number of entries to include in the report
*/
private fun OutputStream.printTopParsingTimes(
parsingTimes: Map<String, Pair<Double?, String?>>, @Suppress("SameParameterValue") count: Int
) {
// Filter out primitive values (no timing data) and sort by parsing time (slowest first)
val sortedParsingTimes =
parsingTimes.entries.filter { it.value.first != null }.sortedByDescending { it.value.first }.take(count)
// Write each entry with consistent formatting
for (i in sortedParsingTimes.indices) {
// Format ranking number with consistent width
val number = "${i + 1}".padStart(TOP_COUNT_LIMIT.toString().length)
val (path, entry) = sortedParsingTimes[i]
val (time, value) = entry
// Format timing information (should always be present for filtered entries)
val timeStr = if (time != null) ": ${time.roundTo2Digits()} µs" else ""
// Include value for context (usually null for objects/arrays)
val valueStr = if (value != null) " (Value: ${value})" else ""
// Write formatted line to file
write("${number}: ${path}${timeStr}${valueStr}\n".toByteArray())
}
}
/**
* Writes a JsonElement to the output stream as a nicely formatted JSON.
*
* @param response The JSON element to write (original response or timing-annotated version)
*/
private fun OutputStream.printResponse(response: JsonElement) {
val writer = JsonWriter(writer())
writer.setIndent(" ") // 4-space indentation for readability
writer.isLenient = true // Handle any JSON quirks gracefully
// Use Gson's internal streaming writer for efficient, formatted output
Streams.write(response, writer)
writer.flush()
writer.close()
}
/**
* Builds a hierarchical tree structure from the flat field-path timing data.
*
* This converts flat paths like:
* - "user.profile.name"
* - "user.profile.addresses[0].street"
* - "user.settings.notifications"
*
* Into a tree structure like:
* ```
* root
* └── user
* ├── profile
* │ ├── name
* │ └── addresses
* │ └── [0]
* │ └── street
* └── settings
* └── notifications
* ```
*
* This tree structure is then used to generate the hierarchical text report.
*
* Array handling:
* - "items[0]" becomes separate nodes: "items" -> "[0]"
* - This allows proper visualization of array structures in the tree
*
* @param parsingTimes Map of field paths to their timing data
* @return Root node of the constructed tree with all children populated
*/
private fun buildParsingTimeTree(parsingTimes: Map<String, Pair<Double?, String?>>): ResponseNode {
val root = ResponseNode(ROOT, isRoot = true)
// Process each field path from the timing data
for ((key, pair) in parsingTimes) {
var currentNode = root
// Split the path and traverse/build the tree structure
// e.g., "user.profile.name" becomes ["user", "profile", "name"]
for (segment in key.split(".")) {
if (segment.contains("[")) {
// Handle array notation: "items[0]" becomes "items" and "[0]"
val arrayName = segment.substringBefore("[") // "items"
// Find or create the array container node
val arrayNode = currentNode.children.find { it.name == arrayName } ?: run {
ResponseNode(
name = arrayName, path = key, parent = currentNode
).apply { currentNode.children.add(this) }
}
// Create node for the specific array index: "[0]", "[1]", etc.
val indexNodeName = "[${segment.substringAfter("[").substringBefore("]")}]"
currentNode = arrayNode.children.find { it.name == indexNodeName } ?: run {
ResponseNode(
name = indexNodeName, path = key, value = pair.second, time = pair.first, parent = arrayNode
).apply { arrayNode.children.add(this) }
}
} else {
// Handle regular object properties
currentNode = currentNode.children.find { it.name == segment } ?: run {
ResponseNode(
name = segment, path = key, value = pair.second, time = pair.first, parent = currentNode
).apply { currentNode.children.add(this) }
}
}
}
}
return root
}
/**
* Recursively prints a tree structure with ASCII art formatting.
*
* Creates a visual representation like:
* ```
* ├── user: 125.5 µs
* │ ├── profile: 45.2 µs (Path: user.profile)
* │ │ ├── name (Value: "John Doe") (Path: user.profile.name)
* │ │ └── addresses: 30.1 µs
* │ │ └── [0]: 25.8 µs
* │ │ ├── street (Value: "123 Main St") (Path: user.profile.addresses[0].street)
* │ │ └── city (Value: "Springfield") (Path: user.profile.addresses[0].city)
* │ └── settings: 80.3 µs (Path: user.settings)
* └── metadata: 15.7 µs (Path: metadata)
* ```
*
* @param node Current node to print
* @param depth Current depth in the tree (used for recursive spacing calculation)
*/
private fun OutputStream.printParsingTimeTree(node: ResponseNode, depth: Int = 0) {
// Determine the appropriate tree connector symbol
val prefix = if (depth > 0) {
// Use └── for last child, ├── for others
if (node == node.parent?.children?.last()) "└── " else "├── "
} else {
"" // No prefix for root level
}
// Format the timing information (if available)
val time = if (node.time != null) ": ${node.time.roundTo2Digits()} µs" else ""
// Format the value information (for primitive fields)
val value = if (node.value != null) " (Value: ${node.value})" else ""
// Include the full path for reference (helps with debugging)
val path = if (node.path.isNotBlank()) " (Path: ${node.path})" else ""
// Build the proper spacing for tree structure
// Walk up the parent hierarchy to determine vertical line placement
var spacing = ""
var nodeParent = node.parent
while (nodeParent != null && nodeParent.isRoot.not()) {
// Add vertical line if parent has more siblings, or spaces if it's the last child
spacing = (if (nodeParent == nodeParent.parent?.children?.last()) " " else "│ ") + spacing
nodeParent = nodeParent.parent
}
// Write the complete formatted line
write("${spacing}${prefix}${node.name}${time}${value}${path}\n".toByteArray())
// Recursively print all children with increased depth
for (child in node.children) printParsingTimeTree(child, depth + 1)
}
// File output stream creation methods for different report types
// These methods handle the complexity of Android's storage system across different API levels
/**
* Creates output stream for the top parsing times report (ranked text file).
*
* @param pageName API endpoint name (e.g., "user_profile")
* @param formattedDate Timestamp for file organization
* @param count Number of top entries (used in filename)
* @return OutputStream for writing the report, or null if creation fails
*/
private fun getTopFileOutputStream(
pageName: String, formattedDate: String, @Suppress("SameParameterValue") count: Int
): OutputStream? = getFileOutputStream(pageName, formattedDate, "${TOP_FILE_PREFIX}${count}", TEXT_FILE_EXTENSION)
/**
* Creates output stream for the original JSON response file.
*
* @param pageName API endpoint name
* @param formattedDate Timestamp for file organization
* @return OutputStream for writing the JSON response, or null if creation fails
*/
private fun getResponseOutputStream(pageName: String, formattedDate: String): OutputStream? =
getFileOutputStream(pageName, formattedDate, RESPONSE_FILE_NAME, JSON_FILE_EXTENSION)
/**
* Creates output stream for the JSON with embedded timing metadata.
*
* @param pageName API endpoint name
* @param formattedDate Timestamp for file organization
* @return OutputStream for writing the timing-annotated JSON, or null if creation fails
*/
private fun getJsonTreeOutputStream(pageName: String, formattedDate: String): OutputStream? =
getFileOutputStream(pageName, formattedDate, TREE_FILE_NAME, JSON_FILE_EXTENSION)
/**
* Creates output stream for the hierarchical text tree representation.
*
* @param pageName API endpoint name
* @param formattedDate Timestamp for file organization
* @return OutputStream for writing the ASCII tree, or null if creation fails
*/
private fun getTextTreeOutputStream(pageName: String, formattedDate: String): OutputStream? =
getFileOutputStream(pageName, formattedDate, TREE_FILE_NAME, TEXT_FILE_EXTENSION)
/**
* Generic method to create file output streams with proper Android storage handling.
*
* Android 10+ (API 29+): Uses scoped storage with MediaStore API
* Android 9 and below: Uses legacy File API
*
* File organization structure:
* Downloads/parsing_times/{pageName}/{formattedDate}/{fileName}.{extension}
*
* @param pageName Name of the API endpoint (e.g., "user_profile")
* @param formattedDate Timestamp string for file organization
* @param fileName Base name of the file (e.g., "response", "tree", "top_100")
* @param extension File extension ("json" or "txt")
* @return OutputStream for writing file content, or null if creation fails
*/
private fun getFileOutputStream(
pageName: String, formattedDate: String, fileName: String, extension: String
): OutputStream? {
// Build the folder structure for organizing files
val folderName = "${FOLDER_NAME_FIRST_PATH}/${pageName}/${formattedDate}"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Modern Android (10+): Use scoped storage with MediaStore
val contentValues = ContentValues().apply {
val mimeType = if (extension == JSON_FILE_EXTENSION) MIME_TYPE_JSON else MIME_TYPE_TEXT
put(MediaStore.Downloads.MIME_TYPE, mimeType)
put(MediaStore.Downloads.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/${folderName}")
put(MediaStore.Downloads.DISPLAY_NAME, "${fileName}.${extension}")
}
// Insert the file entry into MediaStore and get a content URI
val uri =
applicationContext.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
// Open an OutputStream to the created file
return uri?.let { applicationContext.contentResolver.openOutputStream(it) }
} else {
// Legacy Android (9 and below): Use direct file system access
// Check if external storage is available and writable
if (Environment.getExternalStorageState() != Environment.MEDIA_MOUNTED) return null
// Create the target directory structure
val folder =
File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), folderName)
// Create directories if they don't exist
if (folder.exists().not() && folder.mkdirs().not()) return null
// Create and return FileOutputStream for the target file
return FileOutputStream(File(folder, "${fileName}.${extension}"))
}
}
/**
* Rounds double values to 2 decimal places with intelligent formatting.
*
* This method:
* - Uses HALF_EVEN rounding mode for consistent behavior
* - Removes unnecessary ".0" for whole numbers (e.g., "123.0" -> "123")
* - Preserves meaningful decimal places (e.g., "123.45" stays "123.45")
*
* Examples:
* - 123.456789 -> "123.46"
* - 123.000000 -> "123"
* - 123.100000 -> "123.1"
*
* @return Formatted string representation of the number
*/
private fun Double.roundTo2Digits(): String =
BigDecimal(this).setScale(2, RoundingMode.HALF_EVEN) // Round to 2 decimal places
.toDouble().run {
// Remove unnecessary ".0" suffix for whole numbers
if (this - toInt() > 0) toString() else toInt().toString()
}
/**
* Data class representing a node in the parsing time tree structure.
*
* This class models the hierarchical structure of JSON data with associated timing information.
* Each node can represent:
* - A JSON object property (e.g., "user", "profile")
* - An array container (e.g., "items")
* - An array index (e.g., "[0]", "[1]")
* - A primitive value (e.g., "name", "age")
*
* The tree structure allows for easy traversal and visualization of complex JSON structures
* with their associated parsing performance data.
*
* @param name Display name of the node (field name or array index like "[0]")
* @param path Full path to this node in the JSON structure (e.g., "user.profile.addresses[0].street")
* @param value String representation of the field value (only for primitive types like strings, numbers)
* @param time Parsing time in microseconds (null for primitive values, as their parsing time is negligible)
* @param isRoot Whether this is the root node of the tree (used for formatting)
* @param parent Reference to parent node for tree traversal and formatting
* @param children Mutable list of child nodes (empty for primitive values)
*/
private data class ResponseNode(
val name: String,
val path: String = "",
val value: String? = null,
val time: Double? = null,
val isRoot: Boolean = false,
val parent: ResponseNode? = null,
val children: MutableList<ResponseNode> = mutableListOf(),
): Serializable
companion object {
/**
* Converts strings to snake_case format.
*
* Conversion rules:
* - Insert underscores before uppercase letters that follow lowercase letters
* - Convert all letters to lowercase
* - Replace spaces and dashes with underscores
* - Remove non-alphanumeric characters (except underscores)
* - Trim leading/trailing underscores
*
* Examples:
* - "UserProfileResponse" -> "user_profile_response"
* - "APIDataModel" -> "a_p_i_data_model"
* - "getUserData" -> "get_user_data"
* - "simple" -> "simple"
* - "HTTPSConnection" -> "h_t_t_p_s_connection"
*
* @return The converted snake_case string
*/
private fun String.toLowerSnakeCase(): String =
replace(Regex("([a-z])([A-Z])"), "$1_$2") // handle camelCase → snake_case
.replace(Regex("[\\s\\-]+"), "_") // convert spaces/dashes to underscores
.replace(Regex("[^a-zA-Z0-9_]"), "") // remove non-word characters
.lowercase().trim('_')
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment