diff --git a/build.gradle.kts b/build.gradle.kts index 20280f7..a6cfb90 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { // Uncomment when needed testImplementation("io.mockk:mockk:1.13.8") testImplementation("io.kotest:kotest-assertions-core:5.7.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.0") testRuntimeOnly("org.junit.platform:junit-platform-launcher") @@ -65,7 +66,6 @@ dependencies { implementation("com.squareup.okhttp3:okhttp:4.11.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") implementation("org.yaml:snakeyaml:2.2") - implementation("com.google.code.gson:gson:2.10.1") } // Apply a specific Java toolchain to ease working on different environments. diff --git a/gradle.properties b/gradle.properties index 185c1b3..0dfcc03 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,4 @@ group=com.featurevisor version=0.0.1-SNAPSHOT +org.gradle.daemon=true +org.gradle.parallel=true diff --git a/src/main/kotlin/com/featurevisor/sdk/DatafileReader.kt b/src/main/kotlin/com/featurevisor/sdk/DatafileReader.kt index b39d6bd..7a51df0 100644 --- a/src/main/kotlin/com/featurevisor/sdk/DatafileReader.kt +++ b/src/main/kotlin/com/featurevisor/sdk/DatafileReader.kt @@ -8,15 +8,15 @@ import com.featurevisor.types.FeatureKey import com.featurevisor.types.Segment import com.featurevisor.types.SegmentKey -class DatafileReader constructor( +class DatafileReader ( datafileContent: DatafileContent, ) { private val schemaVersion: String = datafileContent.schemaVersion private val revision: String = datafileContent.revision - private val attributes: Map = datafileContent.attributes.associateBy { it.key } - private val segments: Map = datafileContent.segments.associateBy { it.key } - private val features: Map = datafileContent.features.associateBy { it.key } + private val attributes: Map = datafileContent.getAttributes().associateBy { it.key } + private val segments: Map = datafileContent.getSegment().associateBy { it.key } + private val features: Map = datafileContent.getFeature().associateBy { it.key } fun getRevision(): String { return revision diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt index 9e3cef9..7aa339b 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt @@ -73,7 +73,6 @@ fun FeaturevisorInstance.isEnabled(featureKey: FeatureKey, context: Context = em return evaluation.enabled == true } -@Suppress("UNREACHABLE_CODE") fun FeaturevisorInstance.evaluateVariation(featureKey: FeatureKey, context: Context = emptyMap()): Evaluation { var evaluation: Evaluation try { @@ -125,7 +124,7 @@ fun FeaturevisorInstance.evaluateVariation(featureKey: FeatureKey, context: Cont return evaluation } - if (feature.variations.isNullOrEmpty()) { + if (feature.getVariations().isEmpty()) { // no variations evaluation = Evaluation( featureKey = featureKey, @@ -141,7 +140,7 @@ fun FeaturevisorInstance.evaluateVariation(featureKey: FeatureKey, context: Cont // forced val force = findForceFromFeature(feature, context, datafileReader) if (force != null) { - val variation = feature.variations.firstOrNull { it.value == force.variation } + val variation = feature.getVariations().firstOrNull { it.value == force.variation } if (variation != null) { evaluation = Evaluation( @@ -160,18 +159,17 @@ fun FeaturevisorInstance.evaluateVariation(featureKey: FeatureKey, context: Cont val bucketValue = getBucketValue(feature, finalContext) val matchedTrafficAndAllocation = getMatchedTrafficAndAllocation( - feature.traffic, + feature.getTraffic(), finalContext, bucketValue, datafileReader, - logger ) val matchedTraffic = matchedTrafficAndAllocation.matchedTraffic // override from rule if (matchedTraffic?.variation != null) { - val variation = feature.variations.firstOrNull { it.value == matchedTraffic.variation } + val variation = feature.getVariations().firstOrNull { it.value == matchedTraffic.variation } if (variation != null) { evaluation = Evaluation( featureKey = feature.key, @@ -191,7 +189,7 @@ fun FeaturevisorInstance.evaluateVariation(featureKey: FeatureKey, context: Cont // regular allocation if (matchedAllocation != null) { - val variation = feature.variations?.firstOrNull { it.value == matchedAllocation.variation } + val variation = feature.getVariations().firstOrNull { it.value == matchedAllocation.variation } if (variation != null) { evaluation = Evaluation( featureKey = feature.key, @@ -229,7 +227,6 @@ fun FeaturevisorInstance.evaluateVariation(featureKey: FeatureKey, context: Cont } -@Suppress("UNREACHABLE_CODE") fun FeaturevisorInstance.evaluateFlag(featureKey: FeatureKey, context: Context = emptyMap()): Evaluation { var evaluation: Evaluation @@ -301,8 +298,8 @@ fun FeaturevisorInstance.evaluateFlag(featureKey: FeatureKey, context: Context = } // required - if (feature.required.isNullOrEmpty().not()) { - val requiredFeaturesAreEnabled = feature.required?.all { item -> + if (feature.getRequired().isNullOrEmpty().not()) { + val requiredFeaturesAreEnabled = feature.getRequired()?.all { item -> var requiredKey: FeatureKey? = null var requiredVariation: VariationValue? = null when (item) { @@ -347,7 +344,7 @@ fun FeaturevisorInstance.evaluateFlag(featureKey: FeatureKey, context: Context = val bucketValue = getBucketValue(feature = feature, context = finalContext) val matchedTraffic = getMatchedTraffic( - traffic = feature.traffic, + traffic = feature.getTraffic(), context = finalContext, datafileReader = datafileReader, ) @@ -440,7 +437,6 @@ fun FeaturevisorInstance.evaluateFlag(featureKey: FeatureKey, context: Context = } } -@Suppress("UNREACHABLE_CODE") fun FeaturevisorInstance.evaluateVariable( featureKey: FeatureKey, variableKey: VariableKey, @@ -497,7 +493,7 @@ fun FeaturevisorInstance.evaluateVariable( return evaluation } - val variableSchema = feature.variablesSchema?.firstOrNull { variableSchema -> + val variableSchema = feature.getVariablesSchema().firstOrNull { variableSchema -> variableSchema.key == variableKey } @@ -537,11 +533,10 @@ fun FeaturevisorInstance.evaluateVariable( val bucketValue = getBucketValue(feature, finalContext) val matchedTrafficAndAllocation = getMatchedTrafficAndAllocation( - traffic = feature.traffic, + traffic = feature.getTraffic(), context = finalContext, bucketValue = bucketValue, datafileReader = datafileReader, - logger = logger ) matchedTrafficAndAllocation.matchedTraffic?.let { matchedTraffic -> @@ -571,7 +566,7 @@ fun FeaturevisorInstance.evaluateVariable( matchedAllocation.variation } - val variation = feature.variations?.firstOrNull { variation -> + val variation = feature.getVariations().firstOrNull { variation -> variation.value == variationValue } @@ -654,10 +649,10 @@ fun FeaturevisorInstance.evaluateVariable( private fun FeaturevisorInstance.getBucketKey(feature: Feature, context: Context): BucketKey { val featureKey = feature.key - var type: String - var attributeKeys: List + val type: String + val attributeKeys: List - when (val bucketBy = feature.bucketBy) { + when (val bucketBy = feature.getBucketBy()) { is BucketBy.Single -> { type = "plain" attributeKeys = listOf(bucketBy.bucketBy) diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Feature.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Feature.kt index 46cd705..46d27db 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Feature.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Feature.kt @@ -21,7 +21,7 @@ internal fun FeaturevisorInstance.findForceFromFeature( datafileReader: DatafileReader, ): Force? { - return feature.force?.firstOrNull { force -> + return feature.getForce().firstOrNull { force -> when { force.conditions != null -> allConditionsAreMatched(force.conditions, context) force.segments != null -> allGroupSegmentsAreMatched( @@ -46,7 +46,7 @@ internal fun FeaturevisorInstance.getMatchedTraffic( } } -internal fun FeaturevisorInstance.getMatchedAllocation( +internal fun getMatchedAllocation( traffic: Traffic, bucketValue: Int, ): Allocation? { @@ -68,7 +68,6 @@ internal fun FeaturevisorInstance.getMatchedTrafficAndAllocation( context: Context, bucketValue: Int, datafileReader: DatafileReader, - logger: Logger?, ): MatchedTrafficAndAllocation { var matchedAllocation: Allocation? = null diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Fetch.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Fetch.kt index ea950c8..545a00d 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Fetch.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Fetch.kt @@ -2,22 +2,23 @@ package com.featurevisor.sdk import com.featurevisor.types.DatafileContent import kotlinx.serialization.decodeFromString -import java.io.IOException import okhttp3.* -import kotlinx.serialization.json.Json import okhttp3.HttpUrl.Companion.toHttpUrl -import java.lang.IllegalArgumentException +import java.io.IOException + +const val BODY_BYTE_COUNT = 1000000L +val client = OkHttpClient() // MARK: - Fetch datafile content @Throws(IOException::class) -internal fun FeaturevisorInstance.fetchDatafileContent( +fun FeaturevisorInstance.fetchDatafileContent( url: String, handleDatafileFetch: DatafileFetchHandler? = null, - completion: (Result) -> Unit, + completion: (Result>) -> Unit, ) { handleDatafileFetch?.let { handleFetch -> - val result = handleFetch(url) - completion(result) + val result = handleFetch(url).getOrNull()!! + completion(Result.success(Pair(result, ""))) } ?: run { fetchDatafileContentFromUrl(url, completion) } @@ -25,7 +26,7 @@ internal fun FeaturevisorInstance.fetchDatafileContent( private fun fetchDatafileContentFromUrl( url: String, - completion: (Result) -> Unit, + completion: (Result>) -> Unit, ) { try { val httpUrl = url.toHttpUrl() @@ -40,26 +41,21 @@ private fun fetchDatafileContentFromUrl( } } -const val BODY_BYTE_COUNT = 1000000L private inline fun fetch( request: Request, - crossinline completion: (Result) -> Unit, + crossinline completion: (Result>) -> Unit, ) { - val client = OkHttpClient() val call = client.newCall(request) call.enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { val responseBody = response.peekBody(BODY_BYTE_COUNT) if (response.isSuccessful) { - val json = Json { - ignoreUnknownKeys = true - } val responseBodyString = responseBody.string() FeaturevisorInstance.companionLogger?.debug(responseBodyString) try { - val content = json.decodeFromString(responseBodyString) - completion(Result.success(content)) - } catch(throwable: Throwable) { + val content = JsonConfigFeatureVisor.json.decodeFromString(responseBodyString) + completion(Result.success(Pair(content, responseBodyString))) + } catch (throwable: Throwable) { completion( Result.failure( FeaturevisorError.UnparsableJson( diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Refresh.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Refresh.kt index f3094e6..936aedc 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Refresh.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Refresh.kt @@ -1,9 +1,7 @@ package com.featurevisor.sdk -import com.featurevisor.sdk.FeaturevisorError.* +import com.featurevisor.sdk.FeaturevisorError.MissingDatafileUrlWhileRefreshing import com.featurevisor.types.EventName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -17,7 +15,7 @@ fun FeaturevisorInstance.startRefreshing() = when { refreshJob != null -> logger?.warn("refreshing has already started") refreshInterval == null -> logger?.warn("no `refreshInterval` option provided") else -> { - refreshJob = CoroutineScope(Dispatchers.Unconfined).launch { + refreshJob = coroutineScope.launch { while (isActive) { refresh() delay(refreshInterval) @@ -32,7 +30,7 @@ fun FeaturevisorInstance.stopRefreshing() { logger?.warn("refreshing has stopped") } -private fun FeaturevisorInstance.refresh() { +private suspend fun FeaturevisorInstance.refresh() { logger?.debug("refreshing datafile") when { statuses.refreshInProgress -> logger?.warn("refresh in progress, skipping") @@ -40,17 +38,15 @@ private fun FeaturevisorInstance.refresh() { else -> { statuses.refreshInProgress = true fetchDatafileContent( - datafileUrl, - handleDatafileFetch, + url = datafileUrl, + handleDatafileFetch = handleDatafileFetch, ) { result -> - - if (result.isSuccess) { - val datafileContent = result.getOrThrow() + result.onSuccess { datafileContent -> val currentRevision = getRevision() - val newRevision = datafileContent.revision + val newRevision = datafileContent.first.revision val isNotSameRevision = currentRevision != newRevision - datafileReader = DatafileReader(datafileContent) + datafileReader = DatafileReader(datafileContent.first) logger?.info("refreshed datafile") emitter.emit(EventName.REFRESH) @@ -59,7 +55,7 @@ private fun FeaturevisorInstance.refresh() { } statuses.refreshInProgress = false - } else { + }.onFailure { logger?.error( "failed to refresh datafile", mapOf("error" to result) diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Segments.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Segments.kt index b71d893..097216a 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Segments.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Segments.kt @@ -2,31 +2,12 @@ package com.featurevisor.sdk import com.featurevisor.sdk.Conditions.allConditionsAreMatched import com.featurevisor.types.Context -import com.featurevisor.types.FeatureKey import com.featurevisor.types.GroupSegment import com.featurevisor.types.GroupSegment.* import com.featurevisor.types.Segment -import com.featurevisor.types.VariationValue - -internal fun FeaturevisorInstance.segmentIsMatched( - featureKey: FeatureKey, - context: Context, -): VariationValue? { - val evaluation = evaluateVariation(featureKey, context) - - if (evaluation.variationValue != null) { - return evaluation.variationValue - } - - if (evaluation.variation != null) { - return evaluation.variation.value - } - - return null -} internal fun segmentIsMatched(segment: Segment, context: Context): Boolean { - return allConditionsAreMatched(segment.conditions, context) + return allConditionsAreMatched(segment.getCondition(), context) } internal fun FeaturevisorInstance.allGroupSegmentsAreMatched( diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Status.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Status.kt index 9cb74d6..350e9b0 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Status.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Status.kt @@ -2,6 +2,6 @@ package com.featurevisor.sdk data class Statuses(var ready: Boolean, var refreshInProgress: Boolean) -internal fun FeaturevisorInstance.isReady(): Boolean { +fun FeaturevisorInstance.isReady(): Boolean { return statuses.ready } diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Variable.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Variable.kt index 30fda18..7dfa3ef 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Variable.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Variable.kt @@ -1,16 +1,11 @@ package com.featurevisor.sdk +import com.featurevisor.testRunner.getVariableValues import com.featurevisor.types.Context import com.featurevisor.types.FeatureKey import com.featurevisor.types.VariableKey import com.featurevisor.types.VariableValue -import com.featurevisor.types.VariableValue.ArrayValue -import com.featurevisor.types.VariableValue.BooleanValue -import com.featurevisor.types.VariableValue.DoubleValue -import com.featurevisor.types.VariableValue.IntValue -import com.featurevisor.types.VariableValue.JsonValue -import com.featurevisor.types.VariableValue.ObjectValue -import com.featurevisor.types.VariableValue.StringValue +import com.featurevisor.types.VariableValue.* import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromJsonElement @@ -76,15 +71,21 @@ inline fun FeaturevisorInstance.getVariableObject( context: Context, ): T? { val objectValue = getVariable(featureKey, variableKey, context) as? ObjectValue + val actualValue = objectValue?.value?.keys?.map { + mapOf( + it to getVariableValues(objectValue.value[it]).toString() + ) + }?.firstOrNull() + return try { - val encoded = Json.encodeToJsonElement(objectValue?.value) + val encoded = Json.encodeToJsonElement(actualValue) return Json.decodeFromJsonElement(encoded) } catch (e: Exception) { null } } -inline fun FeaturevisorInstance.getVariableJSON( +inline fun FeaturevisorInstance.getVariableJSON( featureKey: FeatureKey, variableKey: VariableKey, context: Context, @@ -96,4 +97,3 @@ inline fun FeaturevisorInstance.getVariableJSON( null } } - diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance.kt b/src/main/kotlin/com/featurevisor/sdk/Instance.kt index af7ff2a..d60d167 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance.kt @@ -6,9 +6,9 @@ package com.featurevisor.sdk import com.featurevisor.sdk.FeaturevisorError.MissingDatafileOptions import com.featurevisor.types.* import com.featurevisor.types.EventName.* -import kotlinx.coroutines.Job +import kotlinx.coroutines.* import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json +import kotlin.coroutines.resume typealias ConfigureBucketKey = (Feature, Context, BucketKey) -> BucketKey typealias ConfigureBucketValue = (Feature, Context, BucketValue) -> BucketValue @@ -16,7 +16,7 @@ typealias InterceptContext = (Context) -> Context typealias DatafileFetchHandler = (datafileUrl: String) -> Result var emptyDatafile = DatafileContent( - schemaVersion = "1", + schemaVersion = "1", revision = "unknown", attributes = emptyList(), segments = emptyList(), @@ -56,6 +56,8 @@ class FeaturevisorInstance private constructor(options: InstanceOptions) { internal var configureBucketKey = options.configureBucketKey internal var configureBucketValue = options.configureBucketValue internal var refreshJob: Job? = null + private var fetchJob: Job? = null + internal val coroutineScope = CoroutineScope(Dispatchers.IO) init { with(options) { @@ -99,16 +101,24 @@ class FeaturevisorInstance private constructor(options: InstanceOptions) { } datafileUrl != null -> { - datafileReader = DatafileReader(options.datafile?: emptyDatafile) - fetchDatafileContent(datafileUrl, handleDatafileFetch) { result -> - if (result.isSuccess) { - datafileReader = DatafileReader(result.getOrThrow()) - statuses.ready = true - emitter.emit(READY, result.getOrThrow()) - if (refreshInterval != null) startRefreshing() - } else { - logger?.error("Failed to fetch datafile: $result") - emitter.emit(ERROR) + if (::datafileReader.isInitialized.not()) { + datafileReader = DatafileReader(options.datafile ?: emptyDatafile) + } + fetchJob = coroutineScope.launch { + fetchDatafileContent( + url = datafileUrl, + handleDatafileFetch = handleDatafileFetch, + ) { result -> + result.onSuccess { datafileContent -> + datafileReader = DatafileReader(datafileContent.first) + statuses.ready = true + emitter.emit(READY, datafileContent.first, datafileContent.second) + if (refreshInterval != null) startRefreshing() + }.onFailure { error -> + logger?.error("Failed to fetch datafile: $error") + emitter.emit(ERROR) + } + cancelFetchJob() } } } @@ -118,14 +128,35 @@ class FeaturevisorInstance private constructor(options: InstanceOptions) { } } + // Provide a mechanism to cancel the fetch job if retry count is more than one + private fun cancelFetchJob() { + fetchJob?.cancel() + fetchJob = null + } + fun setLogLevels(levels: List) { this.logger?.setLevels(levels) } + suspend fun onReady(): FeaturevisorInstance { + return suspendCancellableCoroutine { continuation -> + if (this.statuses.ready) { + continuation.resume(this) + } + + val cb: (result: Array) -> Unit = { + this.emitter.removeListener(READY) + continuation.resume(this) + } + + this.emitter.addListener(READY, cb) + } + } + fun setDatafile(datafileJSON: String) { val data = datafileJSON.toByteArray(Charsets.UTF_8) try { - val datafileContent = Json.decodeFromString(String(data)) + val datafileContent = JsonConfigFeatureVisor.json.decodeFromString(String(data)) datafileReader = DatafileReader(datafileContent = datafileContent) } catch (e: Exception) { logger?.error("could not parse datafile", mapOf("error" to e)) diff --git a/src/main/kotlin/com/featurevisor/sdk/Utils.kt b/src/main/kotlin/com/featurevisor/sdk/Utils.kt new file mode 100644 index 0000000..0a95de6 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/Utils.kt @@ -0,0 +1,93 @@ +package com.featurevisor.sdk + +import com.featurevisor.sdk.serializers.* +import com.featurevisor.types.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.modules.SerializersModule + +object JsonConfigFeatureVisor { + val json: Json by lazy { + Json { + serializersModule = SerializersModule { + contextual(BucketBy::class, BucketBySerializer) + contextual(Condition::class, ConditionSerializer) + contextual(ConditionValue::class, ConditionValueSerializer) + contextual(GroupSegment::class, GroupSegmentSerializer) + contextual(Required::class, RequiredSerializer) + contextual(VariableValue::class, VariableValueSerializer) + } + ignoreUnknownKeys = true + isLenient = true + allowStructuredMapKeys = true + } + } +} + +fun Segment.getCondition(): Condition { + return synchronized(this) { + if (conditions == null) { + conditions = JsonConfigFeatureVisor.json.decodeFromString(ConditionSerializer, conditionStrings) + } + conditions!! + } +} + +fun Feature.getVariablesSchema(): List { + return synchronized(this) { + if (variablesSchema == null) { + variablesSchema = variablesSchemaString?.let { + JsonConfigFeatureVisor.json.decodeFromJsonElement>(it) + } + } + variablesSchema.orEmpty() + } +} + +fun Feature.getVariations(): List { + return synchronized(this) { + if (variations == null) { + variations = variationStrings?.let { + JsonConfigFeatureVisor.json.decodeFromJsonElement>(it) + } + } + variations.orEmpty() + } +} + +fun Feature.getBucketBy(): BucketBy { + return synchronized(this) { + if (bucketBy == null) { + bucketBy = JsonConfigFeatureVisor.json.decodeFromJsonElement(BucketBy.serializer(), bucketByString) + } + bucketBy!! + } +} + +fun Feature.getTraffic(): List { + return synchronized(this) { + if (traffic == null) { + traffic = JsonConfigFeatureVisor.json.decodeFromJsonElement>(trafficString) + } + traffic.orEmpty() + } +} + +fun Feature.getForce(): List { + return synchronized(this) { + if (force == null) { + force = forceString?.let { + JsonConfigFeatureVisor.json.decodeFromJsonElement>(it) + } + } + force.orEmpty() + } +} + +fun Feature.getRequired() = required + +fun DatafileContent.getAttributes() = attributes + +fun DatafileContent.getFeature() = features + +fun DatafileContent.getSegment() = segments diff --git a/src/main/kotlin/com/featurevisor/sdk/serializers/Serializers.kt b/src/main/kotlin/com/featurevisor/sdk/serializers/Serializers.kt index 43456dc..c95803b 100644 --- a/src/main/kotlin/com/featurevisor/sdk/serializers/Serializers.kt +++ b/src/main/kotlin/com/featurevisor/sdk/serializers/Serializers.kt @@ -9,326 +9,315 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.* import java.text.SimpleDateFormat @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) @Serializer(forClass = Required::class) -object RequiredSerializer: KSerializer{ +object RequiredSerializer : KSerializer { override val descriptor: SerialDescriptor = buildSerialDescriptor("package.Required", PolymorphicKind.SEALED) override fun deserialize(decoder: Decoder): Required { val input = decoder as? JsonDecoder - ?: throw SerializationException("This class can be decoded only by Json format") - return when (val tree = input.decodeJsonElement()) { - is JsonPrimitive ->{ - Required.FeatureKey(tree.content) - } - is JsonArray -> { - // Never lies in JsonArray block - Required.FeatureKey(tree.toString()) - } - is JsonObject ->{ - val requiredWithVariation = RequiredWithVariation(tree["key"]?.jsonPrimitive?.content.orEmpty(),tree["variation"]?.jsonPrimitive?.content.orEmpty()) - Required.WithVariation(requiredWithVariation) + ?: throw SerializationException("This class can only be decoded using the Json format") + + return when (val element = input.decodeJsonElement()) { + is JsonPrimitive -> Required.FeatureKey(element.content) + is JsonObject -> { + val key = element["key"]?.jsonPrimitive?.content.orEmpty() + val variation = element["variation"]?.jsonPrimitive?.content.orEmpty() + Required.WithVariation(RequiredWithVariation(key, variation)) } + + else -> throw SerializationException("Unexpected JSON element: ${element::class.simpleName}") } } } -@OptIn(InternalSerializationApi::class) -@Serializer(forClass = Condition::class) +@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) object ConditionSerializer : KSerializer { override val descriptor: SerialDescriptor = buildSerialDescriptor("package.Condition", PolymorphicKind.SEALED) override fun deserialize(decoder: Decoder): Condition { val input = decoder as? JsonDecoder - ?: throw SerializationException("This class can be decoded only by Json format") - return when (val tree = input.decodeJsonElement()) { + ?: throw SerializationException("This class can only be decoded using the Json format") + + return when (val element = input.decodeJsonElement()) { is JsonArray -> { - Condition.And(tree.map { jsonElement -> - input.json.decodeFromJsonElement( - Condition::class.serializer(), - jsonElement - ) - }) + val conditions = element.map { + input.json.decodeFromJsonElement(Condition.serializer(), it) + } + Condition.And(conditions) } is JsonObject -> { - FeaturevisorInstance.companionLogger?.debug("Segment deserializing: ${tree["attribute"]?.jsonPrimitive?.content}, tree: $tree") + FeaturevisorInstance.companionLogger?.debug( + "Segment deserializing: ${element["attribute"]?.jsonPrimitive?.content}, tree: $element" + ) + when { - tree.containsKey("and") -> Condition.And( - tree["and"]!!.jsonArray.map { - input.json.decodeFromJsonElement( - Condition::class.serializer(), - it - ) + "and" in element -> Condition.And( + element["and"]!!.jsonArray.map { + input.json.decodeFromJsonElement(Condition.serializer(), it) } ) - tree.containsKey("or") -> Condition.Or( - tree["or"]!!.jsonArray.map { - input.json.decodeFromJsonElement( - Condition::class.serializer(), - it - ) + "or" in element -> Condition.Or( + element["or"]!!.jsonArray.map { + input.json.decodeFromJsonElement(Condition.serializer(), it) } ) - tree.containsKey("not") -> Condition.Not( - tree["not"]!!.jsonArray.map { - input.json.decodeFromJsonElement( - Condition::class.serializer(), - it - ) + "not" in element -> Condition.Not( + element["not"]!!.jsonArray.map { + input.json.decodeFromJsonElement(Condition.serializer(), it) } ) else -> Condition.Plain( - attributeKey = tree["attribute"]?.jsonPrimitive?.content ?: "", - operator = mapOperator(tree["operator"]?.jsonPrimitive?.content ?: ""), + attributeKey = element["attribute"]?.jsonPrimitive?.content.orEmpty(), + operator = mapOperator(element["operator"]?.jsonPrimitive?.content.orEmpty()), value = input.json.decodeFromJsonElement( - ConditionValue::class.serializer(), - tree["value"]!! - ), + ConditionValue.serializer(), + element["value"]!! + ) ) } } is JsonPrimitive -> { - val jsonElement = input.json.parseToJsonElement(tree.content) - input.json.decodeFromJsonElement( - Condition::class.serializer(), - jsonElement - ) + val parsedElement = input.json.parseToJsonElement(element.content) + input.json.decodeFromJsonElement(Condition.serializer(), parsedElement) } + + else -> throw SerializationException("Unexpected JSON element: ${element::class.simpleName}") } } override fun serialize(encoder: Encoder, value: Condition) { - // TODO: Later if needed + // TODO: Implement if serialization is required in the future } } @OptIn(InternalSerializationApi::class) -@Serializer(forClass = GroupSegment::class) object GroupSegmentSerializer : KSerializer { + + @OptIn(ExperimentalSerializationApi::class) override val descriptor: SerialDescriptor = buildSerialDescriptor("package.GroupSegment", PolymorphicKind.SEALED) override fun deserialize(decoder: Decoder): GroupSegment { val input = decoder as? JsonDecoder - ?: throw SerializationException("This class can be decoded only by Json format") - return when (val tree = input.decodeJsonElement()) { - is JsonArray -> GroupSegment.Multiple(tree.map { jsonElement -> - input.json.decodeFromJsonElement( - GroupSegment::class.serializer(), - jsonElement - ) - }) + ?: throw SerializationException("This class can only be decoded by Json format") - is JsonObject -> { - when { - tree.containsKey("and") -> GroupSegment.And( - AndGroupSegment( - tree["and"]!!.jsonArray.map { - input.json.decodeFromJsonElement( - GroupSegment::class.serializer(), - it - ) - } - ) - ) + return when (val jsonElement = input.decodeJsonElement()) { + is JsonArray -> parseJsonArray(input, jsonElement) + is JsonObject -> parseJsonObject(input, jsonElement) + is JsonPrimitive -> parseJsonPrimitive(input, jsonElement) + else -> throw SerializationException("Unexpected GroupSegment element type") + } + } - tree.containsKey("or") -> GroupSegment.Or( - OrGroupSegment( - tree["or"]!!.jsonArray.map { - input.json.decodeFromJsonElement( - GroupSegment::class.serializer(), - it - ) - } - ) - ) + private fun parseJsonArray(input: JsonDecoder, jsonArray: JsonArray): GroupSegment.Multiple { + val elements = jsonArray.map { + input.json.decodeFromJsonElement(GroupSegment::class.serializer(), it) + } + return GroupSegment.Multiple(elements) + } - tree.containsKey("not") -> GroupSegment.Not( - NotGroupSegment( - tree["not"]!!.jsonArray.map { - input.json.decodeFromJsonElement( - GroupSegment::class.serializer(), - it - ) - } - ) - ) + private fun parseJsonObject(input: JsonDecoder, jsonObject: JsonObject): GroupSegment { + val keys = jsonObject.keys + return when { + "and" in keys -> GroupSegment.And( + AndGroupSegment(parseNestedArray(input, jsonObject["and"]!!.jsonArray)) + ) - else -> throw Exception("Unexpected GroupSegment element content") - } - } + "or" in keys -> GroupSegment.Or( + OrGroupSegment(parseNestedArray(input, jsonObject["or"]!!.jsonArray)) + ) - is JsonPrimitive -> { - val isString = tree.content.none { it in setOf('{', '}', ':', '[', ']') } - if (isString) { - GroupSegment.Plain(tree.content) - } else { - val jsonElement = Json.parseToJsonElement(tree.content) - input.json.decodeFromJsonElement( - GroupSegment::class.serializer(), - jsonElement, - ) - } - } + "not" in keys -> GroupSegment.Not( + NotGroupSegment(parseNestedArray(input, jsonObject["not"]!!.jsonArray)) + ) + + else -> throw SerializationException("Unexpected GroupSegment object keys: $keys") + } + } + + private fun parseJsonPrimitive(input: JsonDecoder, jsonPrimitive: JsonPrimitive): GroupSegment { + val content = jsonPrimitive.content + return if (content.none { it in setOf('{', '}', ':', '[', ']') }) { + GroupSegment.Plain(content) + } else { + val parsedElement = Json.parseToJsonElement(content) + input.json.decodeFromJsonElement(GroupSegment::class.serializer(), parsedElement) + } + } + + private fun parseNestedArray(input: JsonDecoder, jsonArray: JsonArray): List { + return jsonArray.map { + input.json.decodeFromJsonElement(GroupSegment::class.serializer(), it) } } override fun serialize(encoder: Encoder, value: GroupSegment) { - // TODO: Later if needed + // TODO: Implement serialization logic if needed } } @OptIn(InternalSerializationApi::class) -@Serializer(forClass = BucketBy::class) object BucketBySerializer : KSerializer { + + @OptIn(ExperimentalSerializationApi::class) override val descriptor: SerialDescriptor = buildSerialDescriptor("package.BucketBy", PolymorphicKind.SEALED) override fun deserialize(decoder: Decoder): BucketBy { val input = decoder as? JsonDecoder - ?: throw SerializationException("This class can be decoded only by Json format") - return when (val tree = input.decodeJsonElement()) { - is JsonArray -> { - BucketBy.And(tree.map { jsonElement -> - jsonElement.jsonPrimitive.content - }) - } + ?: throw SerializationException("This class can only be decoded by Json format") - is JsonObject -> { - when { - tree.containsKey("or") -> BucketBy.Or(tree["or"]!!.jsonArray.map { it.jsonPrimitive.content }) - tree.containsKey("and") -> BucketBy.And(tree["and"]!!.jsonArray.map { it.jsonPrimitive.content }) - else -> throw Exception("Unexpected BucketBy element content") - } - } + return when (val jsonElement = input.decodeJsonElement()) { + is JsonArray -> parseJsonArray(jsonElement) + is JsonObject -> parseJsonObject(jsonElement) + is JsonPrimitive -> parseJsonPrimitive(input, jsonElement) + else -> throw SerializationException("Unexpected BucketBy element type") + } + } - is JsonPrimitive -> { - val isString = tree.content.none { it in setOf('{', '}', ':', '[', ']') } - if (isString) { - BucketBy.Single(tree.content) - } else { - val jsonElement = Json.parseToJsonElement(tree.content) - input.json.decodeFromJsonElement( - BucketBy::class.serializer(), - jsonElement - ) - } - } + private fun parseJsonArray(jsonArray: JsonArray): BucketBy.And { + val elements = jsonArray.map { it.jsonPrimitive.content } + return BucketBy.And(elements) + } + + private fun parseJsonObject(jsonObject: JsonObject): BucketBy { + return when { + "or" in jsonObject -> BucketBy.Or( + parseJsonArrayContent(jsonObject["or"]!!.jsonArray) + ) + + "and" in jsonObject -> BucketBy.And( + parseJsonArrayContent(jsonObject["and"]!!.jsonArray) + ) + + else -> throw SerializationException("Unexpected BucketBy object keys: ${jsonObject.keys}") + } + } + + private fun parseJsonPrimitive(input: JsonDecoder, jsonPrimitive: JsonPrimitive): BucketBy { + val content = jsonPrimitive.content + return if (content.none { it in setOf('{', '}', ':', '[', ']') }) { + BucketBy.Single(content) + } else { + val parsedElement = Json.parseToJsonElement(content) + input.json.decodeFromJsonElement(BucketBy::class.serializer(), parsedElement) } } + private fun parseJsonArrayContent(jsonArray: JsonArray): List { + return jsonArray.map { it.jsonPrimitive.content } + } + override fun serialize(encoder: Encoder, value: BucketBy) { - // TODO: Later if needed + // TODO: Implement serialization logic if needed } } @OptIn(InternalSerializationApi::class) -@Serializer(forClass = ConditionValue::class) object ConditionValueSerializer : KSerializer { + + @OptIn(ExperimentalSerializationApi::class) override val descriptor: SerialDescriptor = buildSerialDescriptor("package.ConditionValue", PolymorphicKind.SEALED) override fun deserialize(decoder: Decoder): ConditionValue { val input = decoder as? JsonDecoder - ?: throw SerializationException("This class can be decoded only by Json format") - return when (val tree = input.decodeJsonElement()) { - is JsonPrimitive -> { - tree.intOrNull?.let { - ConditionValue.IntValue(it) - } ?: tree.booleanOrNull?.let { - ConditionValue.BooleanValue(it) - } ?: tree.doubleOrNull?.let { - ConditionValue.DoubleValue(it) - } ?: tree.content.let { - try { - val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") - val date = dateFormat.parse(it) - ConditionValue.DateTimeValue(date) - }catch (e:Exception){ - ConditionValue.StringValue(it) - } - } - } + ?: throw SerializationException("This class can only be decoded by Json format") - is JsonArray -> { - ConditionValue.ArrayValue(tree.jsonArray.map { jsonElement -> jsonElement.jsonPrimitive.content }) - } + return when (val jsonElement = input.decodeJsonElement()) { + is JsonPrimitive -> parseJsonPrimitive(jsonElement) + is JsonArray -> parseJsonArray(jsonElement) + is JsonObject -> throw NotImplementedError("ConditionValue does not support JsonObject") + else -> throw SerializationException("Unexpected ConditionValue element type") + } + } - is JsonObject -> { - throw NotImplementedError("ConditionValue does not support JsonObject") - } + private fun parseJsonPrimitive(jsonPrimitive: JsonPrimitive): ConditionValue { + return jsonPrimitive.intOrNull?.let { ConditionValue.IntValue(it) } + ?: jsonPrimitive.booleanOrNull?.let { ConditionValue.BooleanValue(it) } + ?: jsonPrimitive.doubleOrNull?.let { ConditionValue.DoubleValue(it) } + ?: parseStringValue(jsonPrimitive.content) + } + + private fun parseStringValue(content: String): ConditionValue { + return try { + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") + val date = dateFormat.parse(content) + ConditionValue.DateTimeValue(date) + } catch (e: Exception) { + ConditionValue.StringValue(content) } } + private fun parseJsonArray(jsonArray: JsonArray): ConditionValue.ArrayValue { + val elements = jsonArray.map { it.jsonPrimitive.content } + return ConditionValue.ArrayValue(elements) + } + override fun serialize(encoder: Encoder, value: ConditionValue) { - // TODO: Later if needed + // TODO: Implement serialization logic if needed } } @OptIn(InternalSerializationApi::class) -@Serializer(forClass = VariableValue::class) object VariableValueSerializer : KSerializer { + + @OptIn(ExperimentalSerializationApi::class) override val descriptor: SerialDescriptor = buildSerialDescriptor("package.VariableValue", PolymorphicKind.SEALED) override fun deserialize(decoder: Decoder): VariableValue { val input = decoder as? JsonDecoder - ?: throw SerializationException("This class can be decoded only by Json format") - return when (val tree = input.decodeJsonElement()) { - is JsonPrimitive -> { - if (tree.isString) { - if (isValidJson(tree.content)) { - VariableValue.JsonValue(tree.content) - } else { - VariableValue.StringValue(tree.content) - } + ?: throw SerializationException("This class can only be decoded by Json format") + + return when (val jsonElement = input.decodeJsonElement()) { + is JsonPrimitive -> parseJsonPrimitive(jsonElement) + is JsonArray -> parseJsonArray(jsonElement) + is JsonObject -> parseJsonObject(jsonElement) + else -> throw SerializationException("Unexpected VariableValue element type") + } + } + + private fun parseJsonPrimitive(jsonPrimitive: JsonPrimitive): VariableValue { + return when { + jsonPrimitive.isString -> { + if (isValidJson(jsonPrimitive.content)) { + VariableValue.JsonValue(jsonPrimitive.content) } else { - tree.intOrNull?.let { - VariableValue.IntValue(it) - } ?: tree.booleanOrNull?.let { - VariableValue.BooleanValue(it) - } ?: tree.doubleOrNull?.let { - VariableValue.DoubleValue(it) - } ?: tree.content.let { - VariableValue.StringValue(it) - } + VariableValue.StringValue(jsonPrimitive.content) } } - is JsonArray -> { - VariableValue.ArrayValue(tree.jsonArray.map { jsonElement -> jsonElement.jsonPrimitive.content }) - } - - is JsonObject -> { - FeaturevisorInstance.companionLogger?.debug("VariableValueSerializer, JsonObject, tree.jsonObject: ${tree.jsonObject}, tree: $tree") - VariableValue.JsonValue(tree.jsonObject.toString()) - } + jsonPrimitive.intOrNull != null -> VariableValue.IntValue(jsonPrimitive.int) + jsonPrimitive.booleanOrNull != null -> VariableValue.BooleanValue(jsonPrimitive.boolean) + jsonPrimitive.doubleOrNull != null -> VariableValue.DoubleValue(jsonPrimitive.double) + else -> VariableValue.StringValue(jsonPrimitive.content) } } + private fun parseJsonArray(jsonArray: JsonArray): VariableValue.ArrayValue { + val elements = jsonArray.map { it.jsonPrimitive.content } + return VariableValue.ArrayValue(elements) + } + + private fun parseJsonObject(jsonObject: JsonObject): VariableValue.JsonValue { + FeaturevisorInstance.companionLogger?.debug("VariableValueSerializer, JsonObject: $jsonObject") + return VariableValue.JsonValue(jsonObject.toString()) + } + override fun serialize(encoder: Encoder, value: VariableValue) { - // TODO: Later if needed + // TODO: Implement serialization logic if needed } } diff --git a/src/main/kotlin/com/featurevisor/testRunner/Parser.kt b/src/main/kotlin/com/featurevisor/testRunner/Parser.kt index b3f140d..69c9de5 100644 --- a/src/main/kotlin/com/featurevisor/testRunner/Parser.kt +++ b/src/main/kotlin/com/featurevisor/testRunner/Parser.kt @@ -1,9 +1,11 @@ package com.featurevisor.testRunner +import com.featurevisor.sdk.JsonConfigFeatureVisor import com.featurevisor.sdk.serializers.isValidJson import com.featurevisor.sdk.serializers.mapOperator import com.featurevisor.types.* -import com.google.gson.Gson +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import org.yaml.snakeyaml.Yaml import java.io.File import java.util.* @@ -66,28 +68,34 @@ internal fun parseTestFeatureAssertions(yamlFilePath: String) = } private fun mapMatrixValues(value: Any) = - when(value){ + when (value) { is Boolean -> { - if (value){ + if (value) { AttributeValue.StringValue("yes") - }else{ + } else { AttributeValue.StringValue("no") } } + is Int -> { AttributeValue.IntValue(value) } + is Double -> { AttributeValue.DoubleValue(value) } + is String -> { AttributeValue.StringValue(value) } + is Date -> { AttributeValue.DateValue(value) } - else -> { AttributeValue.StringValue("")} + else -> { + AttributeValue.StringValue("") + } } private fun parseWeightValue(value: Any): WeightType { @@ -118,7 +126,7 @@ private fun parseVariableValue(value: Any?): VariableValue { } is Map<*, *> -> { - val json = Gson().toJson(value) + val json = Json.encodeToString(value) VariableValue.JsonValue(json) } @@ -156,7 +164,7 @@ private fun parseAttributeValue(value: Any?): AttributeValue { } is Map<*, *> -> { - val json = Gson().toJson(value) + val json = Json.encodeToString(value) AttributeValue.StringValue(json) } @@ -174,14 +182,14 @@ internal fun parseYamlSegment(segmentFilePath: String) = val data = yaml.load>(yamlContent) val archived = data["archived"] as? Boolean - val description = data["description"] as? String val conditionsData = data["conditions"] Segment( archived = archived, key = "", - conditions = parseCondition(conditionsData) + conditions = parseCondition(conditionsData), + conditionStrings = "" ) } catch (e: Exception) { @@ -245,6 +253,7 @@ private fun parseConditionValue(value: Any?): ConditionValue { ConditionValue.DoubleValue(value.toDouble()) } ?: ConditionValue.StringValue(value) } + is Int -> ConditionValue.IntValue(value) is Double -> ConditionValue.DoubleValue(value) is Boolean -> ConditionValue.BooleanValue(value) @@ -258,6 +267,6 @@ private fun parseConditionValue(value: Any?): ConditionValue { } fun parseConfiguration(projectRootPath: String) = - json.decodeFromString(Configuration.serializer(),getConfigurationJson(projectRootPath)!!) + JsonConfigFeatureVisor.json.decodeFromString(Configuration.serializer(), getConfigurationJson(projectRootPath)!!) diff --git a/src/main/kotlin/com/featurevisor/testRunner/TestFeature.kt b/src/main/kotlin/com/featurevisor/testRunner/TestFeature.kt index 379647d..fb9a465 100644 --- a/src/main/kotlin/com/featurevisor/testRunner/TestFeature.kt +++ b/src/main/kotlin/com/featurevisor/testRunner/TestFeature.kt @@ -1,9 +1,6 @@ package com.featurevisor.testRunner -import com.featurevisor.sdk.Logger -import com.featurevisor.sdk.getVariable -import com.featurevisor.sdk.getVariation -import com.featurevisor.sdk.isEnabled +import com.featurevisor.sdk.* import com.featurevisor.types.* import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json @@ -11,7 +8,7 @@ import kotlinx.serialization.json.JsonElement fun testFeature( testFeature: TestFeature, - datafileContentByEnvironment:MutableMap, + datafileContentByEnvironment: MutableMap, option: TestProjectOption ): TestResult { val testStartTime = System.currentTimeMillis() @@ -121,9 +118,9 @@ fun testFeature( val actualValue = sdk.getVariable(featureKey, variableKey, it.context) val passed: Boolean - val variableSchema = datafileContent.features.find { feature -> + val variableSchema = datafileContent.getFeature().find { feature -> feature.key == testFeature.key - }?.variablesSchema?.find { variableSchema -> + }?.getVariablesSchema()?.find { variableSchema -> variableSchema.key.equals(variableKey, ignoreCase = true) } diff --git a/src/main/kotlin/com/featurevisor/testRunner/Utils.kt b/src/main/kotlin/com/featurevisor/testRunner/Utils.kt index d83a328..bf73d5d 100644 --- a/src/main/kotlin/com/featurevisor/testRunner/Utils.kt +++ b/src/main/kotlin/com/featurevisor/testRunner/Utils.kt @@ -2,8 +2,10 @@ package com.featurevisor.testRunner import com.featurevisor.sdk.FeaturevisorInstance import com.featurevisor.sdk.InstanceOptions +import com.featurevisor.sdk.JsonConfigFeatureVisor import com.featurevisor.sdk.emptyDatafile import com.featurevisor.types.* +import com.featurevisor.types.VariableValue.* import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -21,11 +23,6 @@ internal const val ANSI_GREEN = "\u001B[32m" internal const val MAX_BUCKETED_NUMBER = 100000 -internal val json = Json { - ignoreUnknownKeys = true - isLenient = true -} - internal fun printMessageInGreenColor(message: String) = println("$ANSI_GREEN$message$ANSI_RESET") @@ -65,7 +62,7 @@ internal fun initializeSdkWithDataFileContent(datafileContent: DatafileContent?) internal fun getFileForSpecificPath(path: String) = File(path) -internal inline fun String.convertToDataClass() = json.decodeFromString(this) +internal inline fun String.convertToDataClass() = JsonConfigFeatureVisor.json.decodeFromString(this) internal fun getRootProjectDir(): String { var currentDir = File("../").absoluteFile @@ -177,6 +174,18 @@ fun getContextValues(contextValue: AttributeValue?) = null -> null } +fun getVariableValues(variableValue: VariableValue?) = + when (variableValue) { + is IntValue -> variableValue.value + is DoubleValue -> variableValue.value + is StringValue -> variableValue.value + is BooleanValue -> variableValue.value + is ArrayValue -> variableValue.values + is JsonValue -> variableValue.value + is ObjectValue -> variableValue.value + null -> null + } + fun checkIfArraysAreEqual(a: Array, b: Array): Boolean { if (a.size != b.size) return false @@ -238,13 +247,15 @@ fun checkJsonIsEquals(a: String, b: String): Boolean { return map1 == map2 } - -fun buildDataFileAsPerEnvironment(projectRootPath: String,environment: String) = try { +fun buildDataFileAsPerEnvironment(projectRootPath: String, environment: String) = try { getJsonForDataFile(environment = environment, projectRootPath = projectRootPath)?.run { - convertToDataClass() + printMessageInRedColor("Start reading ${System.currentTimeMillis()}") + val abc = convertToDataClass() + printMessageInRedColor("End reading ${System.currentTimeMillis()}") + return@run abc } ?: emptyDatafile } catch (e: Exception) { - printMessageInRedColor("Unable to parse data file") + printMessageInRedColor("Unable to parse data file $e") emptyDatafile } @@ -262,11 +273,11 @@ fun getDataFileContent(featureName: String, environment: String, projectRootPath null } -fun convertNanoSecondToMilliSecond(timeInNanoSecond:Double):String { - val timeInMilliSecond = timeInNanoSecond/1000000 - return if (timeInMilliSecond > 1000){ +fun convertNanoSecondToMilliSecond(timeInNanoSecond: Double): String { + val timeInMilliSecond = timeInNanoSecond / 1000000 + return if (timeInMilliSecond > 1000) { "${timeInMilliSecond / 1000} s" - }else{ + } else { "$timeInMilliSecond ms" } } diff --git a/src/main/kotlin/com/featurevisor/types/Types.kt b/src/main/kotlin/com/featurevisor/types/Types.kt index dff5b19..e3b2365 100644 --- a/src/main/kotlin/com/featurevisor/types/Types.kt +++ b/src/main/kotlin/com/featurevisor/types/Types.kt @@ -3,6 +3,8 @@ package com.featurevisor.types import com.featurevisor.sdk.serializers.* import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.json.JsonElement import java.util.* typealias Context = Map @@ -13,16 +15,22 @@ typealias VariableKey = String enum class VariableType { @SerialName("boolean") BOOLEAN, + @SerialName("string") STRING, + @SerialName("integer") INTEGER, + @SerialName("double") DOUBLE, + @SerialName("array") ARRAY, + @SerialName("object") OBJECT, + @SerialName("json") JSON } @@ -46,7 +54,7 @@ data class VariableOverride( // one of the below must be present in YAML val conditions: Condition? = null, - val segments: GroupSegment?=null, + val segments: GroupSegment? = null, ) @Serializable @@ -91,20 +99,6 @@ data class Force( val variables: VariableValues? = null, ) -data class Slot( - // @TODO: allow false? - val feature: FeatureKey? = null, - - // 0 to 100 - val percentage: Weight, -) - -data class Group( - val key: String, - val description: String, - val slots: List, -) - typealias BucketKey = String // 0 to 100,000 typealias BucketValue = Int @@ -119,43 +113,6 @@ typealias Weight = Double typealias EnvironmentKey = String typealias RuleKey = String -data class Rule( - val key: RuleKey, - val segments: GroupSegment, - val percentage: Weight, - - val enabled: Boolean? = null, - val variation: VariationValue? = null, - val variables: VariableValues? = null, -) - -data class Environment( - val expose: Boolean? = null, - val rules: List, - val force: List? = null, -) - -typealias Environments = Map - -data class ParsedFeature( - val key: FeatureKey, - - val archived: Boolean?, - val deprecated: Boolean?, - - val description: String, - val tags: List, - - val bucketBy: BucketBy, - - val required: List?, - - val variablesSchema: List?, - val variations: List?, - - val environments: Environments, -) - typealias AttributeKey = String @Serializable @@ -187,8 +144,6 @@ sealed class Condition { data class Not(val not: List) : Condition() } -const val TAG = "FeaturevisorService" - @Serializable(with = ConditionValueSerializer::class) sealed class ConditionValue { data class StringValue(val value: String?) : ConditionValue() @@ -205,7 +160,12 @@ typealias SegmentKey = String data class Segment( val archived: Boolean? = null, val key: SegmentKey, - val conditions: Condition, + + @SerialName("conditions") + val conditionStrings: String, + + @Transient + var conditions: Condition? = null, ) data class AndGroupSegment( @@ -319,15 +279,44 @@ sealed class Required { data class Feature( val key: FeatureKey, val deprecated: Boolean? = null, - val variablesSchema: List? = null, - val variations: List? = null, - val bucketBy: BucketBy, - val required: List? = null, - val traffic: List, - val force: List? = null, - // if in a Group (mutex), these are available slot ranges + @SerialName("variablesSchema") + val variablesSchemaString: JsonElement? = null, + + @SerialName("variations") + val variationStrings: JsonElement? = null, + + @SerialName("bucketBy") + val bucketByString: JsonElement, + + @SerialName("traffic") + val trafficString: JsonElement, + + @SerialName("force") + val forceString: JsonElement? = null, + + val required: List? = null, val ranges: List? = null, + + @Transient + @SerialName("variablesSchemaObject") + var variablesSchema: List? = null, + + @Transient + @SerialName("variationsObject") + var variations: List? = null, + + @Transient + @SerialName("bucketByObject") + var bucketBy: BucketBy? = null, + + @Transient + @SerialName("trafficObject") + var traffic: List? = null, + + @Transient + @SerialName("forceObject") + var force: List? = null ) @Serializable @@ -354,14 +343,14 @@ typealias AssertionMatrix = Map> data class FeatureAssertion( - var description: String?=null, - var environment: EnvironmentKey="staging", + var description: String? = null, + var environment: EnvironmentKey = "staging", // bucket weight: 0 to 100 var at: WeightType = WeightType.IntType(40), var context: Context = mapOf("devMode" to AttributeValue.BooleanValue(false)), - val expectedToBeEnabled: Boolean?=null, - val expectedVariation: VariationValue?=null, - val expectedVariables: VariableValues?=null, + val expectedToBeEnabled: Boolean? = null, + val expectedVariation: VariationValue? = null, + val expectedVariables: VariableValues? = null, val matrix: AssertionMatrix? = null ) @@ -371,7 +360,7 @@ data class TestFeature( ) data class SegmentAssertion( - var description: String?=null, + var description: String? = null, var context: Context, val expectedToMatch: Boolean, val matrix: AssertionMatrix? = null @@ -387,20 +376,20 @@ sealed class Test { data class Segment(val value: TestSegment) : Test() } -sealed class WeightType{ - data class IntType(val value: Int):WeightType() +sealed class WeightType { + data class IntType(val value: Int) : WeightType() - data class DoubleType(val value: Double):WeightType() + data class DoubleType(val value: Double) : WeightType() - data class StringType(val value: String):WeightType() + data class StringType(val value: String) : WeightType() } data class TestResultAssertionError( val type: String, - val expected: Any?=null, - val actual: Any?=null, - val message: String?=null, - val details: Map?=null + val expected: Any? = null, + val actual: Any? = null, + val message: String? = null, + val details: Map? = null ) data class TestResultAssertion( @@ -414,7 +403,7 @@ data class TestResultAssertion( data class TestResult( val type: String, val key: FeatureKey, - var notFound: Boolean?=null, + var notFound: Boolean? = null, var passed: Boolean, var duration: Long, val assertions: List @@ -426,29 +415,24 @@ data class ExecutionResult( ) data class AssertionsCount( - var passed: Int=0, - var failed: Int=0 -) - -data class DataFile( - val stagingDataFiles: DatafileContent? = null, - val productionDataFiles: DatafileContent? = null + var passed: Int = 0, + var failed: Int = 0 ) @Serializable data class Configuration( - val environments:List, + val environments: List, val tags: List, - val defaultBucketBy:String, - val prettyState:Boolean, - val prettyDatafile:Boolean, - val stringify:Boolean, - val featuresDirectoryPath:String, - val segmentsDirectoryPath:String, - val attributesDirectoryPath:String, - val groupsDirectoryPath:String, - val testsDirectoryPath:String, - val stateDirectoryPath:String, - val outputDirectoryPath:String, - val siteExportDirectoryPath:String + val defaultBucketBy: String, + val prettyState: Boolean, + val prettyDatafile: Boolean, + val stringify: Boolean, + val featuresDirectoryPath: String, + val segmentsDirectoryPath: String, + val attributesDirectoryPath: String, + val groupsDirectoryPath: String, + val testsDirectoryPath: String, + val stateDirectoryPath: String, + val outputDirectoryPath: String, + val siteExportDirectoryPath: String ) diff --git a/src/test/kotlin/com/featurevisor/sdk/InstanceTest.kt b/src/test/kotlin/com/featurevisor/sdk/InstanceTest.kt index e3e505d..ab281c3 100644 --- a/src/test/kotlin/com/featurevisor/sdk/InstanceTest.kt +++ b/src/test/kotlin/com/featurevisor/sdk/InstanceTest.kt @@ -3,25 +3,37 @@ */ package com.featurevisor.sdk -import com.featurevisor.types.DatafileContent +import com.featurevisor.sdk.FeaturevisorInstance.Companion.createInstance +import com.featurevisor.sdk.Logger.Companion.createLogger +import com.featurevisor.sdk.Logger.LogLevel +import com.featurevisor.types.* import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.instanceOf import io.mockk.coEvery -import io.mockk.spyk +import io.mockk.mockk +import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.* +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.JsonObject +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +@OptIn(ExperimentalCoroutinesApi::class) class InstanceTest { private val datafileUrl = "https://www.testmock.com" - private val fetchHandler = object : (String) -> Result { - override fun invoke(param: String): Result = Result.failure(Throwable()) - } - private val mockDatafileFetchHandler: DatafileFetchHandler = spyk(fetchHandler) + private val mockDatafileFetchHandler: DatafileFetchHandler = mockk(relaxed = true) private val datafileContent = DatafileContent( - schemaVersion = "0", - revision = "0", - attributes = listOf(), - segments = listOf(), - features = listOf() + schemaVersion = "1", + revision = "1.0", + attributes = emptyList(), + segments = emptyList(), + features = emptyList() ) private var instanceOptions = InstanceOptions( bucketKeySeparator = "", @@ -41,31 +53,1541 @@ class InstanceTest { stickyFeatures = mapOf(), onError = {}, ) - private val systemUnderTest = FeaturevisorInstance.createInstance( - options = instanceOptions - ) + + private val dispatcher = TestCoroutineDispatcher() + private val testScope = TestCoroutineScope(dispatcher) + + @BeforeEach + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `sdk should be a function`() { + val sdk = createInstance( + instanceOptions + ) + + sdk shouldBe instanceOf() + } + + @Test + fun `sdk should create instance with datafile content`() { + val sdk = FeaturevisorInstance.createInstance( + instanceOptions + ) + + sdk shouldBe instanceOf() + sdk.statuses.ready shouldBe true + } + @Test fun `instance initialised properly`() { - systemUnderTest.statuses.ready shouldBe true + val sdk = createInstance( + options = instanceOptions + ) + + sdk.statuses.ready shouldBe true } @Test fun `instance fetches data using handleDatafileFetch`() { - coEvery { mockDatafileFetchHandler(datafileUrl) } returns Result.success(datafileContent) - instanceOptions = instanceOptions.copy( - datafileUrl = datafileUrl, - datafile = null, - handleDatafileFetch = mockDatafileFetchHandler, + testScope.launch { + coEvery { mockDatafileFetchHandler(datafileUrl) } returns Result.success(datafileContent) + + val sdk = createInstance( + options = instanceOptions.copy( + datafileUrl = datafileUrl, + datafile = null, + handleDatafileFetch = mockDatafileFetchHandler, + ) + ) + + sdk.statuses.ready shouldBe true + + verify(exactly = 1) { + mockDatafileFetchHandler(datafileUrl) + } + } + } + + @Test + fun `should trigger onReady event once`() { + testScope.launch { + var readyCount = 0 + + val sdk = createInstance( + instanceOptions.copy( + onReady = { + readyCount += 1 + } + ) + ) + + delay(0) + + readyCount shouldBe 1 + sdk.isReady() shouldBe true + } + } + + @Test + fun `should resolve onReady method as Promise when initialized synchronously`() { + testScope.launch { + var readyCount = 0 + + var sdk = createInstance( + instanceOptions.copy( + onReady = { + readyCount += 1 + } + ) + + ) + + delay(0) + + sdk = sdk.onReady() + + sdk.isReady() shouldBe true + readyCount shouldBe 1 + } + } + + @Test + fun `should resolve onReady method as Promise when fetching datafile remotely`() { + testScope.launch { + var readyCount = 0 + + var sdk = createInstance( + instanceOptions.copy( + datafileUrl = datafileUrl, + onReady = { + readyCount += 1 + } + ) + ) + + sdk = sdk.onReady() + + sdk.isReady() shouldBe true + readyCount shouldBe 1 + } + + } + + @Test + fun `should configure plain bucketBy`() { + var capturedBucketKey = "" + + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation(variation = "control", range = listOf(0, 100000)), + Allocation(variation = "treatment", range = listOf(0, 0)) + ) + ) + ) + ) + ) + ), + configureBucketKey = { _, _, bucketKey -> + capturedBucketKey = bucketKey + bucketKey + } + ) ) - FeaturevisorInstance.createInstance( - options = instanceOptions + val featureKey = "test" + val context = mapOf("userId" to AttributeValue.StringValue("123")) + + sdk.isEnabled(featureKey, context) shouldBe true + sdk.getVariation(featureKey, context) shouldBe "control" + "${AttributeValue.StringValue("123")}${AttributeValue.StringValue("test")}" shouldBe capturedBucketKey + } + + @Test + fun `should configure and bucketBy`() { + var capturedBucketKey = "" + + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.And(listOf("userId", "organizationId")), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation(variation = "control", range = listOf(0, 100000)), + Allocation(variation = "treatment", range = listOf(0, 0)) + ) + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ) + ) + ), + configureBucketKey = { feature, context, bucketKey -> + capturedBucketKey = bucketKey + bucketKey + } + ) + + ) + + val featureKey = "test" + val context = mapOf( + "userId" to AttributeValue.StringValue("123"), + "organizationId" to AttributeValue.StringValue("456") + ) + + sdk.getVariation(featureKey, context) shouldBe "control" + capturedBucketKey shouldBe "${AttributeValue.StringValue("123")}${AttributeValue.StringValue("456")}${ + AttributeValue.StringValue( + "test" + ) + }" + } + + @Test + fun `should configure or bucketBy`() { + var capturedBucketKey = "" + + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Or(listOf("userId", "deviceId")), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation(variation = "control", range = listOf(0, 100000)), + Allocation(variation = "treatment", range = listOf(0, 0)) + ) + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ) + ) + ), + configureBucketKey = { _, _, bucketKey -> + capturedBucketKey = bucketKey + bucketKey + } + ) + ) + + val context1 = mapOf( + "userId" to AttributeValue.StringValue("123"), + "deviceId" to AttributeValue.StringValue("456") + ) + + sdk.isEnabled("test", context1) shouldBe true + sdk.getVariation("test", context1) shouldBe "control" + capturedBucketKey shouldBe "${AttributeValue.StringValue("123")}${AttributeValue.StringValue("test")}" + + val context2 = mapOf( + "deviceId" to AttributeValue.StringValue("456") + ) + + sdk.getVariation("test", context2) shouldBe "control" + capturedBucketKey shouldBe "${AttributeValue.StringValue("456")}${AttributeValue.StringValue("test")}" + } + + @Test + fun `should intercept context`() { + var intercepted = false + + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 100000)), + Allocation("treatment", listOf(0, 0)) + ) + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ) + ) + ), + interceptContext = { context -> + intercepted = true + context // Return the context as is (modify if needed) + } + ) + ) + + val variation = sdk.getVariation( + "test", + mapOf("userId" to AttributeValue.StringValue("123")) ) - verify(exactly = 1) { - mockDatafileFetchHandler(datafileUrl) + variation shouldBe "control" + intercepted shouldBe true + } + + @Test + fun `should activate feature`() { + var activated = false + + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 100000)), + Allocation("treatment", listOf(0, 0)) + ) + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ) + ) + ), + onActivation = { + activated = true + } + ) + + ) + + val variation = sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("123"))) + + activated shouldBe false + variation shouldBe "control" + + val activatedVariation = sdk.activate("test", mapOf("userId" to AttributeValue.StringValue("123"))) + + activated shouldBe true + activatedVariation shouldBe "control" + } + + @Test + fun `should refresh datafile`() { + testScope.launch { + var refreshed = false + var updatedViaOption = false + + val sdk = createInstance( + instanceOptions.copy( + datafileUrl = datafileUrl, + datafile = null, + refreshInterval = 2L, + onReady = { + println("ready") + }, + onRefresh = { + refreshed = true + }, + onUpdate = { + updatedViaOption = true + } + ) + + ) + + sdk.isReady() shouldBe false + + delay(3) + + refreshed shouldBe true + updatedViaOption shouldBe true + sdk.isReady() shouldBe true + + sdk.stopRefreshing() } - systemUnderTest.statuses.ready shouldBe true + } + + @Test + fun `should initialize with sticky features`() { + + testScope.launch { + val sdk = createInstance( + instanceOptions.copy( + stickyFeatures = mapOf( + "test" to OverrideFeature( + enabled = true, + variation = "control", + variables = mapOf("color" to VariableValue.StringValue("red")) + ) + ), + datafile = datafileContent, + handleDatafileFetch = { + val content = DatafileContent( + schemaVersion = "1", + revision = "1.0", + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 0)), + Allocation("treatment", listOf(0, 100000)) + ) + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ) + ), + attributes = emptyList(), + segments = emptyList() + ) + + runBlocking { delay(50) } + Result.success(content) + } + ) + + ) + + sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe "control" + sdk.getVariable("test", "color", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe "red" + + delay(75) + + sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe "control" + + sdk.setStickyFeatures(emptyMap()) + + sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe "treatment" + + } + } + + @Test + fun `should initialize with initial features`() { + testScope.launch { + val sdk = createInstance( + instanceOptions.copy( + initialFeatures = mapOf( + "test" to OverrideFeature( + enabled = true, + variation = "control", + variables = mapOf("color" to VariableValue.StringValue("red")) + ) + ), + datafileUrl = datafileUrl, + handleDatafileFetch = { + Result.success( + datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 0)), + Allocation("treatment", listOf(0, 100000)) + ) + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ) + ) + ) + ) + } + ) + ) + + sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe "control" + sdk.getVariable("test", "color", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe "red" + + sdk.fetchDatafileContent(url = datafileUrl) { + sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe "treatment" + } + } + } + + + @Test + fun `should honour simple required features`() { + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "requiredKey", + bucketBy = BucketBy.Single("userId"), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 0, // disabled + allocation = emptyList() + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ), + Feature( + key = "myKey", + bucketBy = BucketBy.Single("userId"), + required = listOf( + Required.FeatureKey("requiredKey") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = emptyList() + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ) + ) + ) + ) + ) + + sdk.isEnabled("myKey") shouldBe false + + // enabling required should enable the feature too + val sdk2 = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "requiredKey", + bucketBy = BucketBy.Single("userId"), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, // enabled + allocation = emptyList() + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ), + Feature( + key = "myKey", + bucketBy = BucketBy.Single("userId"), + required = listOf(Required.FeatureKey("requiredKey")), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = emptyList() + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ) + ) + ) + ) + ) + + sdk2.isEnabled("myKey") shouldBe true + } + + @Test + fun `should honour required features with variation`() { + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "requiredKey", + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 0)), + Allocation("treatment", listOf(0, 100000)) + ) + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ), + Feature( + key = "myKey", + bucketBy = BucketBy.Single("userId"), + required = listOf( + Required.WithVariation( + RequiredWithVariation( + "requiredKey", + "control" + ) + ) // different variation + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = emptyList() + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ) + ) + ) + ) + ) + + sdk.isEnabled("myKey") shouldBe false + + // child should be enabled because required has desired variation + val sdk2 = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "requiredKey", + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 0)), + Allocation("treatment", listOf(0, 100000)) + ) + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ), + Feature( + key = "myKey", + bucketBy = BucketBy.Single("userId"), + required = listOf( + Required.WithVariation( + RequiredWithVariation( + "requiredKey", + "treatment" + ) + ) // desired variation + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = emptyList() + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ) + ) + ) + ) + ) + + sdk2.isEnabled("myKey") shouldBe true + } + + + @Test + fun `should emit warnings for deprecated feature`() { + var deprecatedCount = 0 + + val sdk = createInstance( + instanceOptions.copy( + datafile = + datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 100000)), + Allocation("treatment", listOf(0, 0)) + ) + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ), + Feature( + key = "deprecatedTest", + deprecated = true, + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 100000)), + Allocation("treatment", listOf(0, 0)) + ) + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ) + ) + ), + logger = createLogger { level, message, _ -> + if (level == LogLevel.WARN && message.contains("is deprecated")) { + deprecatedCount += 1 + } + } + ) + ) + + val testVariation = sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("123"))) + val deprecatedTestVariation = + sdk.getVariation("deprecatedTest", mapOf("userId" to AttributeValue.StringValue("123"))) + + testVariation shouldBe "control" + deprecatedTestVariation shouldBe "control" + deprecatedCount shouldBe 1 + } + + + @Test + fun `should check if enabled for overridden flags from rules`() { + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + traffic = listOf( + Traffic( + key = "2", + segments = GroupSegment.Multiple( + listOf( + GroupSegment.Plain("netherlands") + ) + ), + percentage = 100000, + enabled = false, + allocation = emptyList() + ), + Traffic( + key = "1", + segments = GroupSegment.Multiple( + listOf( + GroupSegment.Plain("*") + ) + ), + percentage = 100000, + allocation = emptyList() + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ) + ), + segments = listOf( + Segment( + key = "netherlands", + conditions = Condition.Plain( + "country", Operator.EQUALS, ConditionValue.StringValue("nl") + ), + conditionStrings = "" + ) + ) + ) + ) + + ) + + sdk.isEnabled( + "test", mapOf( + "userId" to AttributeValue.StringValue("user-123"), + "country" to AttributeValue.StringValue("de") + ) + ) shouldBe true + sdk.isEnabled( + "test", mapOf( + "userId" to AttributeValue.StringValue("user-123"), + "country" to AttributeValue.StringValue("nl") + ) + ) shouldBe false + } + + @Test + fun `should check if enabled for mutually exclusive features`() { + var bucketValue = 10000 + + val sdk = createInstance( + instanceOptions.copy( + configureBucketValue = { _, _, _ -> + bucketValue + }, + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "mutex", + bucketBy = BucketBy.Single("userId"), + ranges = listOf(listOf(0, 50000)), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Multiple( + listOf( + GroupSegment.Plain("*") + ) + ), + percentage = 50000, + allocation = emptyList() + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ) + ) + ) + ) + + ) + + sdk.isEnabled("test") shouldBe false + sdk.isEnabled("test", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe false + + bucketValue = 40000 + sdk.isEnabled("mutex", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe true + + bucketValue = 60000 + sdk.isEnabled("mutex", mapOf("userId" to AttributeValue.StringValue("123"))) shouldBe false + } + + @Test + fun `should get variation`() { + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + variations = listOf( + Variation(value = "control"), + Variation(value = "treatment") + ), + force = listOf( + Force( + conditions = Condition.And( + listOf( + Condition.Plain( + "userId", + Operator.EQUALS, + ConditionValue.StringValue("user-gb") + ) + ) + ), + enabled = false + ), + Force( + segments = GroupSegment.Multiple(listOf(GroupSegment.Plain("netherlands"))), + enabled = false + ) + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Multiple(listOf(GroupSegment.Plain("*"))), + percentage = 100000, + allocation = listOf( + Allocation("control", listOf(0, 0)), + Allocation("treatment", listOf(0, 100000)) + ) + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ), + Feature( + key = "testWithNoVariation", + bucketBy = BucketBy.Single("userId"), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Multiple(listOf(GroupSegment.Plain("*"))), + percentage = 100000, + allocation = emptyList() + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ) + ), + segments = listOf( + Segment( + key = "netherlands", + conditions = Condition.And( + listOf( + Condition.Plain( + "country", + Operator.EQUALS, + ConditionValue.StringValue("nl") + ) + ) + ), + conditionStrings = "", + ) + ) + ) + ) + + ) + + val context = mapOf("userId" to AttributeValue.StringValue("123")) + + sdk.getVariation("test", context) shouldBe "treatment" + sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("user-ch"))) shouldBe "treatment" + + // non-existing feature + sdk.getVariation("nonExistingFeature", context) shouldBe null + + // disabled features + sdk.getVariation("nonExistingFeature", mapOf("userId" to AttributeValue.StringValue("user-gb"))) shouldBe null + sdk.getVariation( + "nonExistingFeature", mapOf( + "userId" to AttributeValue.StringValue("user-gb"), + "country" to AttributeValue.StringValue("nl") + ) + ) shouldBe null + + // no variation + sdk.getVariation("testWithNoVariation", context) shouldBe null + } + + @Test + fun `should get variable`() { + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + variablesSchema = listOf( + VariableSchema( + key = "color", + type = VariableType.STRING, + defaultValue = VariableValue.StringValue("red") + ), + VariableSchema( + key = "showSidebar", + type = VariableType.BOOLEAN, + defaultValue = VariableValue.BooleanValue(false) + ), + VariableSchema( + key = "sidebarTitle", + type = VariableType.STRING, + defaultValue = VariableValue.StringValue("sidebar title") + ), + VariableSchema( + key = "count", + type = VariableType.INTEGER, + defaultValue = VariableValue.IntValue(0) + ), VariableSchema( + key = "price", + type = VariableType.DOUBLE, + defaultValue = VariableValue.DoubleValue(9.99) + ), + + VariableSchema( + key = "paymentMethods", + type = VariableType.ARRAY, + defaultValue = VariableValue.ArrayValue(listOf("paypal", "creditcard")) + ), + VariableSchema( + key = "flatConfig", + type = VariableType.OBJECT, + defaultValue = VariableValue.ObjectValue(mapOf("key" to VariableValue.StringValue("value"))) + ), + VariableSchema( + key = "nestedConfig", + type = VariableType.JSON, + defaultValue = VariableValue.JsonValue("""{"key":{"nested":"value"}}""") + ) + ), + variations = listOf( + Variation( + value = "control" + ), + Variation( + value = "treatment", + variables = listOf( + Variable( + key = "showSidebar", + value = VariableValue.BooleanValue(true), + overrides = listOf( + VariableOverride( + segments = + GroupSegment.Multiple( + listOf( + GroupSegment.Plain( + "netherlands" + ) + ) + ), + value = VariableValue.BooleanValue(false) + ), + VariableOverride( + conditions = Condition.Plain( + "country", + Operator.EQUALS, + ConditionValue.StringValue("de") + ), + value = VariableValue.BooleanValue(false) + ) + ) + ), + Variable( + key = "sidebarTitle", + value = VariableValue.StringValue("sidebar title from variation"), + overrides = listOf( + VariableOverride( + segments = + GroupSegment.Multiple( + listOf( + GroupSegment.Plain( + "netherlands" + ) + ) + ), + value = VariableValue.StringValue("Dutch title"), + + + ), + VariableOverride( + conditions = Condition.Plain( + "country", + Operator.EQUALS, + ConditionValue.StringValue("de") + ), + value = VariableValue.StringValue("German title") + ), + ) + ) + ) + ), + ), + force = listOf( + Force( + conditions = Condition.And( + listOf( + Condition.Plain( + attributeKey = "userId", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("user-ch") + ) + ) + ), + enabled = true, + variation = "control", + variables = mapOf("color" to VariableValue.StringValue("red and white")) + ), + Force( + conditions = Condition.And( + listOf( + Condition.Plain( + attributeKey = "userId", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("user-gb") + ) + ) + ), + enabled = false + ), + Force( + conditions = Condition.And( + listOf( + Condition.Plain( + attributeKey = "userId", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("user-forced-variation") + ) + ) + ), + enabled = true, + variation = "treatment" + ) + ), + traffic = listOf( + Traffic( + key = "2", + segments = GroupSegment.Multiple( + listOf( + GroupSegment.Plain( + "belgium" + ) + ) + ), + percentage = 100000, + allocation = listOf( + Allocation( + variation = "control", + range = listOf(0, 0) + ), + Allocation( + variation = "treatment", + range = listOf(0, 100000) + ) + ), + variation = "control", + variables = mapOf("color" to VariableValue.StringValue("black")) + ), + Traffic( + key = "1", + segments = GroupSegment.Plain( + "*" + ), + percentage = 100000, + allocation = listOf( + Allocation( + variation = "control", + range = listOf(0, 0) + ), + Allocation( + variation = "treatment", + range = listOf(0, 100000) + ) + ) + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ) + ), + attributes = listOf( + Attribute(key = "userId", type = "string", capture = true), + Attribute(key = "country", type = "string") + ), + segments = listOf( + Segment( + key = "netherlands", + conditions = Condition.Plain( + attributeKey = "country", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("nl") + ), + conditionStrings = "", + ), + Segment( + key = "belgium", + conditions = Condition.Plain( + attributeKey = "country", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("be") + ), + conditionStrings = "", + ) + ) + ) + ) + ) + + val context = mapOf("userId" to AttributeValue.StringValue("123")) + + sdk.getVariation("test", context) shouldBe "treatment" + sdk.getVariation("test", context + mapOf("country" to AttributeValue.StringValue("be"))) shouldBe "control" + sdk.getVariation("test", mapOf("userId" to AttributeValue.StringValue("user-ch"))) shouldBe "control" + + (sdk.getVariable("test", "color", context) as VariableValue.StringValue).value shouldBe "red" + sdk.getVariableString("test", "color", context) shouldBe "red" + (sdk.getVariable( + "test", + "color", + context.toMutableMap().apply { putAll(mapOf("country" to AttributeValue.StringValue("be"))) } + ) as VariableValue.StringValue).value shouldBe "black" + (sdk.getVariable( + "test", + "color", + mapOf("userId" to AttributeValue.StringValue("user-ch")) + ) as VariableValue.StringValue).value shouldBe "red and white" + + (sdk.getVariable("test", "showSidebar", context) as VariableValue.BooleanValue).value shouldBe true + sdk.getVariableBoolean("test", "showSidebar", context) shouldBe true + sdk.getVariableBoolean( + "test", + "showSidebar", + context + mapOf("country" to AttributeValue.StringValue("nl")) + ) shouldBe false + sdk.getVariableBoolean( + "test", + "showSidebar", + context + mapOf("country" to AttributeValue.StringValue("de")) + ) shouldBe false + + sdk.getVariableString( + "test", "sidebarTitle", + mapOf( + "userId" to AttributeValue.StringValue("user-forced-variation"), + "country" to AttributeValue.StringValue("de") + ) + ) shouldBe "German title" + sdk.getVariableString( + "test", + "sidebarTitle", + mapOf( + "userId" to AttributeValue.StringValue("user-forced-variation"), + "country" to AttributeValue.StringValue("nl") + ) + ) shouldBe "Dutch title" + sdk.getVariableString( + "test", + "sidebarTitle", + mapOf( + "userId" to AttributeValue.StringValue("user-forced-variation"), + "country" to AttributeValue.StringValue("be") + ) + ) shouldBe "sidebar title from variation" + + (sdk.getVariable("test", "count", context) as VariableValue.IntValue).value shouldBe 0 + sdk.getVariableInteger("test", "count", context) shouldBe 0 + + (sdk.getVariable("test", "price", context) as VariableValue.DoubleValue).value shouldBe 9.99 + sdk.getVariableDouble("test", "price", context) shouldBe 9.99 + + (sdk.getVariable( + "test", + "paymentMethods", + context + ) as VariableValue.ArrayValue).values shouldBe listOf("paypal", "creditcard") + sdk.getVariableArray("test", "paymentMethods", context) shouldBe listOf("paypal", "creditcard") + + (sdk.getVariable( + "test", + "flatConfig", + context + ) as VariableValue.ObjectValue).value shouldBe mapOf("key" to VariableValue.StringValue(value = "value")) + sdk.getVariableObject>("test", "flatConfig", context) shouldBe mapOf("key" to "value") + + (sdk.getVariable( + "test", + "nestedConfig", + context + ) as VariableValue.JsonValue).value shouldBe "{\"key\":{\"nested\":\"value\"}}" + mapOf("key" to mapOf("nested" to "value")) shouldBe sdk.getVariableJSON("test", "nestedConfig", context) + + // Non-existing + sdk.getVariable("test", "nonExisting", context) shouldBe null + sdk.getVariable("nonExistingFeature", "nonExisting", context) shouldBe null + + // Disabled + sdk.getVariable("test", "color", mapOf("userId" to AttributeValue.StringValue("user-gb"))) shouldBe null + } + + @Test + fun `should get variables without any variations`() { + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + schemaVersion = "1", + revision = "1.0", + attributes = listOf( + Attribute(key = "userId", type = "string", capture = true), + Attribute(key = "country", type = "string") + ), + segments = listOf( + Segment( + key = "netherlands", + conditions = Condition.Plain( + attributeKey = "country", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("nl") + ), + conditionStrings = "", + ) + ), + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + variablesSchema = listOf( + VariableSchema( + key = "color", + type = VariableType.STRING, + defaultValue = VariableValue.StringValue("red") + ) + ), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("netherlands"), + percentage = 100000, + variables = mapOf("color" to VariableValue.StringValue("orange")), + allocation = emptyList() + ), + Traffic( + key = "2", + segments = GroupSegment.Plain("*"), + percentage = 100000, + allocation = emptyList() + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ) + ) + ) + ) + ) + + val defaultContext = mapOf("userId" to AttributeValue.StringValue("123")) + + // Test default value + (sdk.getVariable("test", "color", defaultContext) as VariableValue.StringValue).value shouldBe "red" + + // Test override + (sdk.getVariable( + "test", + "color", + defaultContext + mapOf("country" to AttributeValue.StringValue("nl")) + ) as VariableValue.StringValue).value shouldBe "orange" + } + + @Test + fun `should check if enabled for individually named segments`() { + val sdk = createInstance( + instanceOptions.copy( + datafile = datafileContent.copy( + schemaVersion = "1", + revision = "1.0", + features = listOf( + Feature( + key = "test", + bucketBy = BucketBy.Single("userId"), + traffic = listOf( + Traffic( + key = "1", + segments = GroupSegment.Plain("netherlands"), + percentage = 100000, + allocation = emptyList() + ), + Traffic( + key = "2", + segments = GroupSegment.Multiple( + listOf( + GroupSegment.Plain("iphone"), + GroupSegment.Plain("unitedStates") + ) + ), + percentage = 100000, + allocation = emptyList() + ) + ), + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), + ) + ), + attributes = emptyList(), + segments = listOf( + Segment( + key = "netherlands", + conditions = Condition.Plain( + attributeKey = "country", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("nl") + ), + conditionStrings = "" + ), + Segment( + key = "iphone", + conditions = Condition.Plain( + attributeKey = "device", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("iphone") + ), + conditionStrings = "", + ), + Segment( + key = "unitedStates", + conditions = Condition.Plain( + attributeKey = "country", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("us") + ), + conditionStrings = "", + ) + ) + ) + ) + ) + + // Check if enabled + assertEquals(false, sdk.isEnabled("test")) + assertEquals(false, sdk.isEnabled("test", mapOf("userId" to AttributeValue.StringValue("123")))) + assertEquals( + false, + sdk.isEnabled( + "test", + mapOf("userId" to AttributeValue.StringValue("123"), "country" to AttributeValue.StringValue("de")) + ) + ) + assertEquals( + false, + sdk.isEnabled( + "test", + mapOf("userId" to AttributeValue.StringValue("123"), "country" to AttributeValue.StringValue("us")) + ) + ) + + assertEquals( + true, + sdk.isEnabled( + "test", + mapOf("userId" to AttributeValue.StringValue("123"), "country" to AttributeValue.StringValue("nl")) + ) + ) + assertEquals( + true, + sdk.isEnabled( + "test", + mapOf( + "userId" to AttributeValue.StringValue("123"), + "country" to AttributeValue.StringValue("us"), + "device" to AttributeValue.StringValue("iphone") + ) + ) + ) } } diff --git a/src/test/kotlin/com/featurevisor/sdk/factory/DatafileContentFactory.kt b/src/test/kotlin/com/featurevisor/sdk/factory/DatafileContentFactory.kt index 7dc13a7..b083a05 100644 --- a/src/test/kotlin/com/featurevisor/sdk/factory/DatafileContentFactory.kt +++ b/src/test/kotlin/com/featurevisor/sdk/factory/DatafileContentFactory.kt @@ -9,6 +9,7 @@ import com.featurevisor.types.Feature import com.featurevisor.types.Operator.EQUALS import com.featurevisor.types.Operator.NOT_EQUALS import com.featurevisor.types.Segment +import kotlinx.serialization.json.JsonObject object DatafileContentFactory { @@ -56,6 +57,7 @@ object DatafileContentFactory { ) ), ), + conditionStrings = "" ), ) @@ -70,6 +72,9 @@ object DatafileContentFactory { traffic = emptyList(), force = null, ranges = null, + bucketByString = JsonObject(emptyMap()), + variationStrings = JsonObject(emptyMap()), + trafficString = JsonObject(emptyMap()), ) ) }