Skip to content

Commit 8410eca

Browse files
Merge pull request #3044 from DataDog/typo/flags-resolveMap-api
feat: resolve map of primitives Co-authored-by: typotter <[email protected]>
2 parents d065edf + d4de1af commit 8410eca

File tree

11 files changed

+872
-33
lines changed

11 files changed

+872
-33
lines changed

detekt_custom_safe_calls.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1152,8 +1152,8 @@ datadog:
11521152
- "org.json.JSONArray.length()"
11531153
- "org.json.JSONArray.toJsonArray()"
11541154
- "org.json.JSONObject.constructor()"
1155-
- "org.json.JSONObject.optString(kotlin.String?, kotlin.String?)"
11561155
- "org.json.JSONObject.keys()"
1156+
- "org.json.JSONObject.optString(kotlin.String?, kotlin.String?)"
11571157
- "org.json.JSONObject.toJsonObject()"
11581158
# endregion
11591159
# region Opentelemetry

features/dd-sdk-android-flags/api/apiSurface

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ interface com.datadog.android.flags.FlagsClient
77
fun resolveDoubleValue(String, Double): Double
88
fun resolveIntValue(String, Int): Int
99
fun resolveStructureValue(String, org.json.JSONObject): org.json.JSONObject
10+
fun resolveStructureValue(String, Map<String, Any?>): Map<String, Any?>
1011
fun <T: Any> resolve(String, T): com.datadog.android.flags.model.ResolutionDetails<T>
1112
val state: StateObservable
1213
class Builder

features/dd-sdk-android-flags/api/dd-sdk-android-flags.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public abstract interface class com/datadog/android/flags/FlagsClient {
1717
public abstract fun resolveDoubleValue (Ljava/lang/String;D)D
1818
public abstract fun resolveIntValue (Ljava/lang/String;I)I
1919
public abstract fun resolveStringValue (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
20+
public abstract fun resolveStructureValue (Ljava/lang/String;Ljava/util/Map;)Ljava/util/Map;
2021
public abstract fun resolveStructureValue (Ljava/lang/String;Lorg/json/JSONObject;)Lorg/json/JSONObject;
2122
public abstract fun setEvaluationContext (Lcom/datadog/android/flags/model/EvaluationContext;)V
2223
}

features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,30 @@ interface FlagsClient {
108108
fun resolveIntValue(flagKey: String, defaultValue: Int): Int
109109

110110
/**
111-
* Resolves a structured flag value as a JSON object.
111+
* Resolves a structured flag value as a [JSONObject].
112112
*
113113
* @param flagKey The unique identifier of the flag to resolve.
114114
* @param defaultValue The value to return if the flag cannot be retrieved or parsed.
115115
* @return The JSON object value of the flag, or the default value if unavailable.
116116
*/
117117
fun resolveStructureValue(flagKey: String, defaultValue: JSONObject): JSONObject
118118

119+
/**
120+
* Resolves a structured flag value as a Map.
121+
*
122+
* The returned Map contains only primitives (String, Int, Long, Double, Boolean),
123+
* null values, nested Maps, and Lists. All nested structures are recursively
124+
* converted.
125+
*
126+
* This method is useful for integrations that prefer working with Kotlin collections
127+
* over JSON types.
128+
*
129+
* @param flagKey The unique identifier of the flag to resolve.
130+
* @param defaultValue The map to return if the flag cannot be retrieved or parsed.
131+
* @return The map value of the flag, or the default value if unavailable.
132+
*/
133+
fun resolveStructureValue(flagKey: String, defaultValue: Map<String, Any?>): Map<String, Any?>
134+
119135
/**
120136
* Resolves a flag value with detailed resolution information.
121137
*

features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,23 @@ internal class DatadogFlagsClient(
128128
override fun resolveStructureValue(flagKey: String, defaultValue: JSONObject): JSONObject =
129129
resolveValue(flagKey, defaultValue)
130130

131+
/**
132+
* Resolves a structured flag value as a Map (overload).
133+
*
134+
* The returned Map contains no JSON types - all nested structures are recursively
135+
* converted to Maps and Lists with only primitives, null, and nested collections.
136+
*
137+
* If the flag cannot be found or an error occurs, the default value is returned.
138+
*
139+
* @param flagKey The name of the flag to query. Cannot be null.
140+
* @param defaultValue The map to return if the flag cannot be found or resolved.
141+
* @return The map value of the flag, or the default value if unavailable.
142+
*/
143+
override fun resolveStructureValue(flagKey: String, defaultValue: Map<String, Any?>): Map<String, Any?> {
144+
val result = resolveValue(flagKey, defaultValue)
145+
return result
146+
}
147+
131148
/**
132149
* Resolves a flag value with detailed resolution information.
133150
*

features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagValueConverter.kt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,16 @@ internal object FlagValueConverter {
5555
String::class -> variationValue as T
5656
Int::class -> variationValue.toIntOrNull() as? T
5757
Double::class -> variationValue.toDoubleOrNull() as? T
58+
Map::class -> variationValue.toMap() as? T
5859
JSONObject::class -> JSONObject(variationValue) as? T
59-
else -> null
60+
else -> {
61+
// Check if targetType is a Map implementation
62+
if (Map::class.java.isAssignableFrom(targetType.java)) {
63+
variationValue.toMap() as? T
64+
} else {
65+
null
66+
}
67+
}
6068
}
6169

6270
result ?: throw IllegalArgumentException("Failed to parse value '$variationValue'")
@@ -78,7 +86,15 @@ internal object FlagValueConverter {
7886
variationType == VariationType.NUMBER.value || variationType == VariationType.FLOAT.value ||
7987
variationType == VariationType.INTEGER.value
8088
JSONObject::class -> variationType == VariationType.OBJECT.value
81-
else -> false
89+
Map::class -> variationType == VariationType.OBJECT.value
90+
else -> {
91+
// Check if targetType is a Map implementation (e.g., LinkedHashMap, HashMap, etc.)
92+
if (Map::class.java.isAssignableFrom(targetType.java)) {
93+
variationType == VariationType.OBJECT.value
94+
} else {
95+
false
96+
}
97+
}
8298
}
8399

84100
fun getTypeName(targetType: KClass<*>): String = when (targetType) {
@@ -87,6 +103,7 @@ internal object FlagValueConverter {
87103
Int::class -> "Int"
88104
Double::class -> "Double"
89105
JSONObject::class -> "JSONObject"
106+
Map::class -> "Map"
90107
else -> targetType.simpleName ?: "Unknown"
91108
}
92109
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
package com.datadog.android.flags.internal
8+
9+
import org.json.JSONArray
10+
import org.json.JSONException
11+
import org.json.JSONObject
12+
13+
/**
14+
* Parses a JSON string and converts it to a Map with no JSON types.
15+
*
16+
* The returned Map contains only primitives (String, Int, Long, Double, Boolean),
17+
* null, nested Maps, and nested Lists. Nested JSONObjects and JSONArrays are
18+
* recursively converted.
19+
*
20+
* @return A map with primitive types, null, nested Maps, and nested Lists
21+
* @throws JSONException if the string is not valid JSON
22+
*/
23+
internal fun String.toMap(): Map<String, Any?> {
24+
// Safe: Input validation happens at call site; JSONException is expected to propagate
25+
@Suppress("UnsafeThirdPartyFunctionCall")
26+
val jsonObject = JSONObject(this)
27+
return jsonObject.toMap()
28+
}
29+
30+
/**
31+
* Recursively converts a [JSONObject] to a [Map] with no JSON types.
32+
*
33+
* Nested JSONObjects and JSONArrays are recursively converted to Maps and Lists.
34+
* The returned Map contains only primitives (String, Int, Long, Double, Boolean),
35+
* null, nested Maps, and nested Lists.
36+
*
37+
* JSONObject.NULL values are converted to null.
38+
*
39+
* @return A map with primitive types, null, nested Maps, and nested Lists
40+
*/
41+
internal fun JSONObject.toMap(): Map<String, Any?> {
42+
val result = mutableMapOf<String, Any?>()
43+
val keys = this.keys()
44+
45+
// Safe: Standard iterator pattern - hasNext() checks state, next() is safe after hasNext() returns true
46+
@Suppress("UnsafeThirdPartyFunctionCall")
47+
while (keys.hasNext()) {
48+
@Suppress("UnsafeThirdPartyFunctionCall")
49+
val key = keys.next()
50+
// Safe: Key exists because it came from keys() iterator
51+
@Suppress("UnsafeThirdPartyFunctionCall")
52+
result[key] = convertJsonValue(this.get(key))
53+
}
54+
55+
return result
56+
}
57+
58+
/**
59+
* Recursively converts a [JSONArray] to a [List] with no JSON types.
60+
*
61+
* Nested JSONObjects and JSONArrays are recursively converted to Maps and Lists.
62+
* The returned List contains only primitives (String, Int, Long, Double, Boolean),
63+
* null, nested Maps, and nested Lists.
64+
*
65+
* JSONObject.NULL values are converted to null.
66+
*
67+
* @return A list with primitive types, null, nested Maps, and nested Lists
68+
*/
69+
internal fun JSONArray.toList(): List<Any?> {
70+
val result = mutableListOf<Any?>()
71+
72+
// Safe: Iterating within bounds (0 until length)
73+
@Suppress("UnsafeThirdPartyFunctionCall")
74+
for (i in 0 until this.length()) {
75+
// Safe: Index is within bounds (0 until length)
76+
@Suppress("UnsafeThirdPartyFunctionCall")
77+
result.add(convertJsonValue(this.get(i)))
78+
}
79+
80+
return result
81+
}
82+
83+
/**
84+
* Converts a Map to a JSONObject, recursively converting nested Maps and Lists.
85+
*
86+
* Nested Maps and Lists are recursively converted to JSONObjects and JSONArrays.
87+
* Null values are converted to JSONObject.NULL.
88+
*
89+
* @return A JSONObject with all nested structures converted
90+
*/
91+
internal fun Map<String, Any?>.toJSONObject(): JSONObject {
92+
val jsonObject = JSONObject()
93+
94+
forEach { (key, value) ->
95+
// Safe: convertToJsonValue ensures valid types (primitives, JSONObject.NULL, JSONObject, JSONArray)
96+
@Suppress("UnsafeThirdPartyFunctionCall")
97+
jsonObject.put(key, convertToJsonValue(value))
98+
}
99+
100+
return jsonObject
101+
}
102+
103+
/**
104+
* Converts a List to a JSONArray, recursively converting nested Maps and Lists.
105+
*
106+
* Nested Maps and Lists are recursively converted to JSONObjects and JSONArrays.
107+
* Null values are converted to JSONObject.NULL.
108+
*
109+
* @return A JSONArray with all nested structures converted
110+
*/
111+
internal fun List<*>.toJSONArray(): JSONArray {
112+
val jsonArray = JSONArray()
113+
114+
forEach { value ->
115+
// Safe: convertToJsonValue ensures valid types (primitives, JSONObject.NULL, JSONObject, JSONArray)
116+
@Suppress("UnsafeThirdPartyFunctionCall")
117+
jsonArray.put(convertToJsonValue(value))
118+
}
119+
120+
return jsonArray
121+
}
122+
123+
/**
124+
* Converts a Kotlin value to a JSON-compatible value.
125+
*
126+
* Recursively handles nested structures:
127+
* - Map<*, *> → JSONObject (non-String keys converted via toString())
128+
* - List<*> → JSONArray
129+
* - null → JSONObject.NULL
130+
* - Primitives → unchanged
131+
*
132+
* @param value The Kotlin value to convert
133+
* @return The JSON-compatible value
134+
*/
135+
private fun convertToJsonValue(value: Any?): Any = when (value) {
136+
null -> JSONObject.NULL
137+
is Map<*, *> -> {
138+
// Convert keys to String (supports non-String keys via toString())
139+
val stringMap = value.entries.associate { (k, v) -> k.toString() to v }
140+
stringMap.toJSONObject()
141+
}
142+
is List<*> -> value.toJSONArray()
143+
else -> value // Primitives: String, Int, Long, Double, Boolean
144+
}
145+
146+
/**
147+
* Converts a JSON value (from JSONObject.get or JSONArray.get) to a plain Kotlin type.
148+
*
149+
* Recursively handles nested structures:
150+
* - JSONObject → Map<String, Any?>
151+
* - JSONArray → List<Any?>
152+
* - Primitives → String, Int, Long, Double, Boolean
153+
* - JSONObject.NULL → null
154+
*
155+
* @param value The value from JSONObject or JSONArray
156+
* @return The converted value with no JSON types
157+
*/
158+
private fun convertJsonValue(value: Any?): Any? = when (value) {
159+
JSONObject.NULL -> null
160+
is JSONObject -> value.toMap()
161+
is JSONArray -> value.toList()
162+
else -> value // Primitives: String, Int, Long, Double, Boolean, or null
163+
}

features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,17 @@ internal class NoOpFlagsClient(
123123
return defaultValue
124124
}
125125

126+
/**
127+
* Returns the provided default value without any flag evaluation.
128+
* @param flagKey Ignored flag key.
129+
* @param defaultValue The map to return.
130+
* @return The provided default value.
131+
*/
132+
override fun resolveStructureValue(flagKey: String, defaultValue: Map<String, Any?>): Map<String, Any?> {
133+
logOperation("resolveStructureValue for flag '$flagKey'", InternalLogger.Level.WARN)
134+
return defaultValue
135+
}
136+
126137
/**
127138
* Logs an operation call on this NoOpFlagsClient using the policy-aware logging function.
128139
* This ensures visibility in both debug builds (MAINTAINER) and production (USER, if verbosity allows).

features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,4 +467,24 @@ internal class NoOpFlagsClientTest {
467467
}
468468

469469
// endregion
470+
471+
// region resolveStructureValue() - Map overload
472+
473+
@Test
474+
fun `M return default map W resolveStructureValue() {map default}`(forge: Forge) {
475+
// Given
476+
val fakeFlagKey = forge.anAlphabeticalString()
477+
val fakeDefaultValue = mapOf(
478+
"key1" to forge.anAlphabeticalString(),
479+
"key2" to forge.anInt()
480+
)
481+
482+
// When
483+
val result = testedClient.resolveStructureValue(fakeFlagKey, fakeDefaultValue)
484+
485+
// Then
486+
assertThat(result).isEqualTo(fakeDefaultValue)
487+
}
488+
489+
// endregion
470490
}

0 commit comments

Comments
 (0)