[flags] Generic resolve method#2923
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## feature/feature-flagging #2923 +/- ##
============================================================
+ Coverage 70.81% 70.88% +0.06%
============================================================
Files 837 841 +4
Lines 30446 30574 +128
Branches 5136 5167 +31
============================================================
+ Hits 21560 21670 +110
- Misses 7432 7447 +15
- Partials 1454 1457 +3
🚀 New features to boost your workflow:
|
2b5bcf6 to
211e111
Compare
77e34af to
287fa8a
Compare
be2f0e8 to
626593b
Compare
| fun resolveDoubleValue(String, Double): Double | ||
| fun resolveIntValue(String, Int): Int | ||
| fun resolveStructureValue(String, org.json.JSONObject): org.json.JSONObject | ||
| fun <T> resolve(String, T): com.datadog.android.flags.model.ResolutionDetails<T> |
There was a problem hiding this comment.
Specification says that even for concrete methods above we need to return ResolutionDetails https://openfeature.dev/specification/sections/providers, but we are returning just the objects. Is it fine to not use this structure there?
There was a problem hiding this comment.
We are not implementing the OpenFeature specification, rather we're exposing enough to allow a shim package to implement the OpenFeature Provider spec. This is the resolve method.
The concrete methods above mirror the OpenFeature Client interface, which the dev uses in their app. This is to make this SDK still be functional and useful, even without OpenFeature layered on top.
| * @param defaultValue The value to return if the flag cannot be retrieved or parsed. | ||
| * @return [ResolutionDetails] containing the value, variant, reason, error info, and metadata. | ||
| */ | ||
| fun <T> resolve(flagKey: String, defaultValue: T): ResolutionDetails<T> |
There was a problem hiding this comment.
Should we support nullable types (values) here? If it is not the case, type should be T : Any
There was a problem hiding this comment.
non null all the way.
| * @param flagKey The name of the flag to query. Cannot be null. | ||
| * @param defaultValue The value to return if the flag cannot be retrieved. | ||
| * @return The string value of the flag, or the default value if unavailable. | ||
| * @param defaultValue The value to return if the flag cannot be found resolved for any reason. Cannot be null. |
There was a problem hiding this comment.
| * @param defaultValue The value to return if the flag cannot be found resolved for any reason. Cannot be null. | |
| * @param defaultValue The value to return if the flag cannot be found or resolved for any reason. Cannot be null. |
| private fun <T> resolveValue(flagKey: String, defaultValue: T): T = | ||
| resolveTracked(resolveInternal(flagKey, defaultValue)) |
There was a problem hiding this comment.
I think this is confusing: here we have 3 resolveXXX methods, and the fact that there is resolveInternal with a result passed to resolveTracked doesn't add clarity. Should resolveInternal be renamed, maybe?
There was a problem hiding this comment.
yes, I agree.
I renamed to readAndParseAssignment
| * @param defaultValue The value to return if the flag cannot be retrieved or parsed. | ||
| * @return [ResolutionDetails] with either the parsed value and metadata, or an error. | ||
| */ | ||
| override fun <T> resolve(flagKey: String, defaultValue: T): ResolutionDetails<T> { |
There was a problem hiding this comment.
minor: it is better to move this up, so that we have interface methods grouped first and only after them we have private methods.
| * @param extraLogging The JSONObject containing additional flag metadata. | ||
| * @return An immutable map of metadata, or null if the JSONObject is empty. | ||
| */ | ||
| private fun extractMetadata(extraLogging: JSONObject): Map<String, Any>? { |
There was a problem hiding this comment.
do we need to explicitly make return type nullable? Cannot we return empty collection if JSONObject is empty?
There was a problem hiding this comment.
good idea. there is no reason to return null
| ) { | ||
| // Catch all conversion exceptions (including JSONException) and return null | ||
| // The caller is responsible for logging errors as appropriate | ||
| null |
There was a problem hiding this comment.
maybe it is worth to log it with MAINTAINER target?
There was a problem hiding this comment.
I was on the fence about this one b/c I wanted to keep the singleton. It would be useful to get a handle on any exception that happens here. I just discovered Kotlin's Result, so using that instead
| fun <T> isTypeCompatible(variationType: String, defaultValue: T): Boolean = when (defaultValue) { | ||
| is Boolean -> variationType == VariationType.BOOLEAN.value | ||
| is String -> variationType == VariationType.STRING.value | ||
| is Int -> variationType == VariationType.INTEGER.value |
There was a problem hiding this comment.
should we also check for VariationType.NUMBER here as below?
There was a problem hiding this comment.
yes, you're right. The number type that the backend is sending will evolve into sending specifically a float or an integer variant type so it should be usable as either now.
| @Suppress("UNCHECKED_CAST") | ||
| private fun <T> getConverterForType(defaultValue: T): (String) -> T? = when (defaultValue) { | ||
| is Boolean -> { s: String -> s.lowercase(Locale.US).toBooleanStrictOrNull() as? T } | ||
| is String -> { s: String -> s as? T } |
There was a problem hiding this comment.
I think this one can be just { s: String -> s }: input and output types are the same, so cast is not needed
| DOUBLE("double"), | ||
| JSON("json") | ||
| NUMBER("number"), | ||
| FLOAT("float"), |
There was a problem hiding this comment.
we still have the usage of double term in API, should we keep it here? Or is it the change per spec?
There was a problem hiding this comment.
These are the types that are sent over the wire. We actually need only one numeric resolution function - the number type is what is required for open feature. We'll treat number, float, and integer as satisfying the "resolveDouble" method. integer type and numeric type will satisfy resolveInteger
| * @property flagMetadata Optional map of arbitrary metadata associated with the flag (string keys, primitive values). | ||
| */ | ||
| data class ResolutionDetails<T>( | ||
| val value: T, |
There was a problem hiding this comment.
Can value be null? If not, it is better to do data class ResolutionDetails<T: Any>
There was a problem hiding this comment.
thanks for the tip.
| if (flagsConfiguration.rumIntegrationEnabled && resolution.flag.doLog) { | ||
| logEvaluation( | ||
| key = resolution.flagKey, | ||
| value = resolution.value as Any |
There was a problem hiding this comment.
If value is never null, you should make T: Any everywhere like I described here https://github.com/DataDog/dd-sdk-android/pull/2923/files#r2444011674
| /** | ||
| * Error codes for flag resolution failures, aligned with the OpenFeature specification. | ||
| */ | ||
| enum class ErrorCode { |
There was a problem hiding this comment.
I only see that FLAG_NOT_FOUND, PARSE_ERROR and TYPE_MISMATCH are used in this PR. Will others be used in other PRs or are they not needed?
| * @param T The type of the resolved flag value (Boolean, String, Int, Double, JSONObject). | ||
| * @property value The resolved flag value. This is always present, either from flag evaluation or the default value. | ||
| * @property variant Optional string identifier for the resolved variant (e.g., "control", "treatment"). | ||
| * @property reason Optional string explaining why this value was resolved (e.g., "TARGETING_MATCH", "DEFAULT", "ERROR"). |
There was a problem hiding this comment.
e.g., "TARGETING_MATCH", "DEFAULT", "ERROR"
Is it possible to make this a enum instead of a String?
| * @param defaultValue The default value (used to infer the target type) | ||
| * @return The converted value, or null if types are incompatible or conversion fails | ||
| */ | ||
| @Suppress("UNCHECKED_CAST") |
There was a problem hiding this comment.
@Suppress("UNCHECKED_CAST")
this is not needed here
| * @return The converted value, or null if types are incompatible or conversion fails | ||
| */ | ||
| @Suppress("UNCHECKED_CAST") | ||
| fun <T> convert(variationValue: String, variationType: String, defaultValue: T): T? { |
There was a problem hiding this comment.
nit
You don't actually need a defaultValue here. You can pass the class itself using KClass.
internal object FlagValueConverter {
fun <T : Any> convert(variationValue: String, variationType: String, resultType: KClass<T>): T? {
if (!isTypeCompatible(variationType, resultType)) {
return null
}
return try {
doConvert(variationValue, resultType)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException")
e: Exception
) {
null
}
}
fun isTypeCompatible(variationType: String, resultType: KClass<*>): Boolean = when (resultType) {
Boolean::class -> variationType == VariationType.BOOLEAN.value
String::class -> variationType == VariationType.STRING.value
Int::class -> variationType == VariationType.INTEGER.value
Double::class -> variationType == VariationType.NUMBER.value || variationType == VariationType.FLOAT.value
JSONObject::class -> variationType == VariationType.OBJECT.value
else -> false
}
@Suppress("UNCHECKED_CAST")
private fun <T : Any> doConvert(s: String, resultType: KClass<T>): T? = when (resultType) {
Boolean::class -> s.lowercase(Locale.US).toBooleanStrictOrNull() as? T
String::class -> s as? T
Int::class -> s.toIntOrNull() as? T
Double::class -> s.toDoubleOrNull() as? T
JSONObject::class -> JSONObject(s) as? T
else -> null
}
fun getTypeName(type: KClass<*>): String = when (type) {
Boolean::class -> "Boolean"
String::class -> "String"
Int::class -> "Int"
Double::class -> "Double"
JSONObject::class -> "JSONObject"
else -> type.simpleName ?: "Unknown"
}
}
There was a problem hiding this comment.
this is great, thank you
35c1888 to
9c296a3
Compare
9c296a3 to
a73b2bd
Compare
6e6806a
jonathanmos
left a comment
There was a problem hiding this comment.
lgtm - see minor comments
Co-authored-by: Nikita Ogorodnikov <[email protected]>
9ff9d37
|
Thanks. Just need one more stamp to merge please and thanks |
|
/merge |
|
View all feedbacks in Devflow UI.
This merge request is not mergeable according to GitHub. Common reasons include pending required checks, missing approvals, or merge conflicts — but it could also be blocked by other repository rules or settings.
The expected merge time in
Tests failed on this commit d076ddd: What to do next?
|
PR #2923: [flags] Generic resolve method
What does this PR do?
This PR implements the generic
resolve<T>()method providing OpenFeature-compatible flag resolution with detailed error information, metadata, and variant tracking.Key additions:
public API
resolve<T>(flagKey, defaultValue)method that returnsResolutionDetails<T>ResolutionDetails<T>data class containing value, variant, reason, error code, error message, and flag metadataErrorCodeenum with OpenFeature-spec error codes (FLAG_NOT_FOUND, TYPE_MISMATCH, PARSE_ERROR, etc.)internal
FlagValueConverterobject for centralized, side-effect-free type conversionInternalResolutionsealed classMotivation
OpenFeature Specification Alignment
The OpenFeature specification defines a
resolve()method as the core evaluation API, providing rich resolution details beyond just the flag value. This enables:Additional Notes
Anything else we should know when reviewing?
Review checklist (to be filled by reviewers)