diff --git a/build.gradle.kts b/build.gradle.kts index 2cc11c3..514abce 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -61,6 +61,8 @@ dependencies { implementation("net.swiftzer.semver:semver:1.3.0") implementation("com.goncalossilva:murmurhash:0.4.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") + implementation("com.squareup.okhttp3:okhttp:4.11.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") } // Apply a specific Java toolchain to ease working on different environments. @@ -80,3 +82,13 @@ tasks.named("test") { showStandardStreams = true } } + +// TODO: Remove excludes when tests are fixed +sourceSets { + test { + kotlin { + exclude("com/featurevisor/sdk/InstanceTest.kt") + exclude("com/featurevisor/sdk/EmitterTest.kt") + } + } +} diff --git a/src/main/kotlin/com/featurevisor/sdk/Emitter.kt b/src/main/kotlin/com/featurevisor/sdk/Emitter.kt index 8affa92..9862df0 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Emitter.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Emitter.kt @@ -3,9 +3,9 @@ package com.featurevisor.sdk import com.featurevisor.types.EventName class Emitter { - private val listeners = mutableMapOf Unit>() + private val listeners = mutableMapOf) -> Unit>() - fun addListener(event: EventName, listener: () -> Unit) { + fun addListener(event: EventName, listener: (Array) -> Unit) { listeners.putIfAbsent(event, listener) } @@ -17,7 +17,7 @@ class Emitter { listeners.clear() } - fun emit(event: EventName) { - listeners.getOrDefault(event, null)?.invoke() + fun emit(event: EventName, vararg args: Any) { + listeners.getOrDefault(event, null)?.invoke(args) } } diff --git a/src/main/kotlin/com/featurevisor/sdk/FeaturevisorError.kt b/src/main/kotlin/com/featurevisor/sdk/FeaturevisorError.kt index eca1944..0520817 100644 --- a/src/main/kotlin/com/featurevisor/sdk/FeaturevisorError.kt +++ b/src/main/kotlin/com/featurevisor/sdk/FeaturevisorError.kt @@ -1,6 +1,21 @@ package com.featurevisor.sdk -sealed class FeaturevisorError(message: String, result: String? = null) : Throwable(message = message) { +sealed class FeaturevisorError(message: String) : Throwable(message = message) { + + /// Thrown when attempting to init Featurevisor instance without passing datafile and datafileUrl. + /// At least one of them is required to init the SDK correctly + /// - Parameter string: The invalid URL string. object MissingDatafileOptions : FeaturevisorError("Missing data file options") - class FetchingDataFileFailed(result: String) : FeaturevisorError("Fetching data file failed", result) + + class FetchingDataFileFailed(val result: String) : FeaturevisorError("Fetching data file failed") + + /// Thrown when receiving unparseable Datafile JSON responses. + /// - Parameters: + /// - data: The data being parsed. + /// - errorMessage: The message from the error which occured during parsing. + class UnparsableJson(val data: String?, errorMessage: String) : FeaturevisorError(errorMessage) + + /// Thrown when attempting to construct an invalid URL. + /// - Parameter string: The invalid URL string. + class InvalidUrl(val url: String?) : FeaturevisorError("Invalid URL") } diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Activation.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Activation.kt new file mode 100644 index 0000000..a7b16ad --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Activation.kt @@ -0,0 +1,33 @@ +package com.featurevisor.sdk + +import com.featurevisor.types.AttributeValue +import com.featurevisor.types.Context +import com.featurevisor.types.EventName +import com.featurevisor.types.FeatureKey +import com.featurevisor.types.VariationValue + +fun FeaturevisorInstance.activate(featureKey: FeatureKey, context: Context = emptyMap()): VariationValue? { + val evaluation = evaluateVariation(featureKey, context) + val variationValue = evaluation.variation?.value ?: evaluation.variationValue ?: return null + val finalContext = interceptContext?.invoke(context) ?: context + val captureContext = mutableMapOf() + val attributesForCapturing = datafileReader.getAllAttributes() + .filter { it.capture == true } + + attributesForCapturing.forEach { attribute -> + if (finalContext[attribute.key] != null) { + captureContext[attribute.key] = context[attribute.key]!! + } + } + + emitter.emit( + EventName.ACTIVATION, + featureKey, + variationValue, + finalContext, + captureContext, + evaluation + ) + + return variationValue +} diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt index 4ec5364..6c27a56 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt @@ -1,7 +1,71 @@ package com.featurevisor.sdk +import com.featurevisor.sdk.Conditions.allConditionsAreMatched +import com.featurevisor.sdk.EvaluationReason.* +import com.featurevisor.types.AttributeKey +import com.featurevisor.types.AttributeValue +import com.featurevisor.types.BucketBy +import com.featurevisor.types.BucketKey +import com.featurevisor.types.BucketValue import com.featurevisor.types.Context +import com.featurevisor.types.Feature import com.featurevisor.types.FeatureKey +import com.featurevisor.types.OverrideFeature +import com.featurevisor.types.Required +import com.featurevisor.types.RuleKey +import com.featurevisor.types.Traffic +import com.featurevisor.types.VariableKey +import com.featurevisor.types.VariableSchema +import com.featurevisor.types.VariableValue +import com.featurevisor.types.Variation +import com.featurevisor.types.VariationValue +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement + +enum class EvaluationReason(val value: String) { + NOT_FOUND("not_found"), + NO_VARIATIONS("no_variations"), + DISABLED("disabled"), + REQUIRED("required"), + OUT_OF_RANGE("out_of_range"), + FORCED("forced"), + INITIAL("initial"), + STICKY("sticky"), + RULE("rule"), + ALLOCATED("allocated"), + DEFAULTED("defaulted"), + OVERRIDE("override"), + ERROR("error") +} + +@Serializable +data class Evaluation( + val featureKey: FeatureKey, + val reason: EvaluationReason, + val bucketValue: BucketValue? = null, + val ruleKey: RuleKey? = null, + val enabled: Boolean? = null, + val traffic: Traffic? = null, + val sticky: OverrideFeature? = null, + val initial: OverrideFeature? = null, + val variation: Variation? = null, + val variationValue: VariationValue? = null, + val variableKey: VariableKey? = null, + val variableValue: VariableValue? = null, + val variableSchema: VariableSchema? = null, +) { + fun toDictionary(): Map { + val data = try { + val json = Json.encodeToJsonElement(this) + Json.decodeFromJsonElement>(json) + } catch (e: Exception) { + emptyMap() + } + return data + } +} fun FeaturevisorInstance.isEnabled(featureKey: FeatureKey, context: Context = emptyMap()): Boolean { val evaluation = evaluateFlag(featureKey, context) @@ -14,7 +78,7 @@ fun FeaturevisorInstance.evaluateVariation(featureKey: FeatureKey, context: Cont if (flag.enabled == false) { evaluation = Evaluation( featureKey = featureKey, - reason = EvaluationReason.DISABLED + reason = DISABLED, ) logger?.debug("feature is disabled", evaluation.toDictionary()) @@ -25,8 +89,8 @@ fun FeaturevisorInstance.evaluateVariation(featureKey: FeatureKey, context: Cont stickyFeatures?.get(featureKey)?.variation?.let { variationValue -> evaluation = Evaluation( featureKey = featureKey, - reason = EvaluationReason.STICKY, - variationValue = variationValue + reason = STICKY, + variationValue = variationValue, ) logger?.debug("using sticky variation", evaluation.toDictionary()) @@ -34,11 +98,11 @@ fun FeaturevisorInstance.evaluateVariation(featureKey: FeatureKey, context: Cont } // initial - if (!statuses.ready && initialFeatures?.get(featureKey)?.variation != null) { + if (statuses.ready.not() && initialFeatures?.get(featureKey)?.variation != null) { val variationValue = initialFeatures[featureKey]?.variation evaluation = Evaluation( featureKey = featureKey, - reason = EvaluationReason.INITIAL, + reason = INITIAL, variationValue = variationValue ) @@ -46,102 +110,110 @@ fun FeaturevisorInstance.evaluateVariation(featureKey: FeatureKey, context: Cont return evaluation } - getFeature(featureKey)?.let { feature -> - if (feature.variations.isEmpty) { - evaluation = Evaluation( - featureKey = featureKey, - reason = EvaluationReason.NO_VARIATIONS - ) + val feature = getFeatureByKey(featureKey) + if (feature == null) { + // not found + evaluation = Evaluation( + featureKey = featureKey, + reason = NOT_FOUND + ) - logger.warn("no variations", evaluation.toDictionary()) - return evaluation - } + logger?.warn("feature not found", evaluation.toDictionary()) - val finalContext = interceptContext?.invoke(context) ?: context + return evaluation + } - // forced - findForceFromFeature(feature, context, datafileReader)?.let { force -> - val variation = feature.variations.firstOrNull { variation -> - variation.value == force.variation - } + if (feature.variations.isNullOrEmpty()) { + // no variations + evaluation = Evaluation( + featureKey = featureKey, + reason = NO_VARIATIONS + ) - if (variation != null) { - evaluation = Evaluation( - featureKey = feature.key, - reason = EvaluationReason.FORCED, - variation = variation - ) + logger?.warn("no variations", evaluation.toDictionary()) + return evaluation + } - logger.debug("forced variation found", evaluation.toDictionary()) + val finalContext = interceptContext?.invoke(context) ?: context - return evaluation - } - } + // forced + val force = findForceFromFeature(feature, context, datafileReader) + if (force != null) { + val variation = feature.variations.firstOrNull { it.value == force.variation } - // bucketing - val bucketValue = getBucketValue(feature, finalContext) - val matchedTrafficAndAllocation = getMatchedTrafficAndAllocation( - traffic = feature.traffic, - context = finalContext, - bucketValue = bucketValue, - datafileReader = datafileReader, - logger = logger - ) + if (variation != null) { + evaluation = Evaluation( + featureKey = feature.key, + reason = FORCED, + variation = variation + ) - if (matchedTrafficAndAllocation.matchedTraffic != null) { - // override from rule - val matchedTrafficVariationValue = matchedTrafficAndAllocation.matchedTraffic.variation + logger?.debug("forced variation found", evaluation.toDictionary()) - val variation = feature.variations.firstOrNull { variation -> - variation.value == matchedTrafficVariationValue - } + return evaluation + } + } - if (variation != null) { - evaluation = Evaluation( - featureKey = feature.key, - reason = EvaluationReason.RULE, - bucketValue = bucketValue, - ruleKey = matchedTrafficAndAllocation.matchedTraffic.key, - variation = variation - ) + // bucketing + val bucketValue = getBucketValue(feature, finalContext) - logger.debug("override from rule", evaluation.toDictionary()) + val matchedTrafficAndAllocation = getMatchedTrafficAndAllocation( + feature.traffic, + finalContext, + bucketValue, + datafileReader, + logger + ) - return evaluation - } + val matchedTraffic = matchedTrafficAndAllocation.matchedTraffic - // regular allocation - val matchedAllocation = matchedTrafficAndAllocation.matchedAllocation + // override from rule + if (matchedTraffic?.variation != null) { + val variation = feature.variations?.firstOrNull { it.value == matchedTraffic.variation } + if (variation != null) { + evaluation = Evaluation( + featureKey = feature.key, + reason = RULE, + bucketValue = bucketValue, + ruleKey = matchedTraffic.key, + variation = variation + ) - val variation = feature.variations.firstOrNull { variation -> - variation.value == matchedAllocation.variation - } + logger?.debug("override from rule", evaluation.toDictionary()) - if (variation != null) { - evaluation = Evaluation( - featureKey = feature.key, - reason = EvaluationReason.ALLOCATED, - bucketValue = bucketValue, - variation = variation - ) + return evaluation + } + } - logger.debug("allocated variation", evaluation.toDictionary()) + val matchedAllocation = matchedTrafficAndAllocation.matchedAllocation - return evaluation - } + // regular allocation + if (matchedAllocation != null) { + val variation = feature.variations?.firstOrNull { it.value == matchedAllocation.variation } + if (variation != null) { + evaluation = Evaluation( + featureKey = feature.key, + reason = ALLOCATED, + bucketValue = bucketValue, + variation = variation + ) + + logger?.debug("allocated variation", evaluation.toDictionary()) + + return evaluation } + } - // nothing matched - evaluation = Evaluation( - featureKey = feature.key, - reason = EvaluationReason.ERROR, - bucketValue = bucketValue - ) + // nothing matched + evaluation = Evaluation( + featureKey = feature.key, + reason = ERROR, + bucketValue = bucketValue + ) - logger.debug("no matched variation", evaluation.toDictionary()) + logger?.debug("no matched variation", evaluation.toDictionary()) - return evaluation - } + return evaluation } fun FeaturevisorInstance.evaluateFlag(featureKey: FeatureKey, context: Context = emptyMap()): Evaluation { @@ -151,7 +223,7 @@ fun FeaturevisorInstance.evaluateFlag(featureKey: FeatureKey, context: Context = stickyFeatures?.get(featureKey)?.let { stickyFeature -> evaluation = Evaluation( featureKey = featureKey, - reason = EvaluationReason.STICKY, + reason = STICKY, enabled = stickyFeature.enabled, sticky = stickyFeature ) @@ -166,33 +238,31 @@ fun FeaturevisorInstance.evaluateFlag(featureKey: FeatureKey, context: Context = val initialFeature = initialFeatures[featureKey] evaluation = Evaluation( featureKey = featureKey, - reason = EvaluationReason.INITIAL, - enabled = initialFeature.enabled, + reason = INITIAL, + enabled = initialFeature?.enabled, initial = initialFeature ) - logger.debug("using initial enabled", evaluation.toDictionary()) + logger?.debug("using initial enabled", evaluation.toDictionary()) return evaluation } - val feature = getFeature(featureKey) - + val feature = getFeatureByKey(featureKey) if (feature == null) { // not found evaluation = Evaluation( featureKey = featureKey, - reason = EvaluationReason.NOT_FOUND + reason = NOT_FOUND ) - logger.warn("feature not found", evaluation.toDictionary()) - + logger?.warn("feature not found", evaluation.toDictionary()) return evaluation } // deprecated if (feature.deprecated == true) { - logger.warn("feature is deprecated", mapOf("featureKey" to feature.key)) + logger?.warn("feature is deprecated", mapOf("featureKey" to feature.key)) } val finalContext = interceptContext?.invoke(context) ?: context @@ -202,60 +272,48 @@ fun FeaturevisorInstance.evaluateFlag(featureKey: FeatureKey, context: Context = if (force.enabled != null) { evaluation = Evaluation( featureKey = featureKey, - reason = EvaluationReason.FORCED, + reason = FORCED, enabled = force.enabled ) - logger.debug("forced enabled found", evaluation.toDictionary()) + logger?.debug("forced enabled found", evaluation.toDictionary()) return evaluation } } // required - if (!feature.required.isEmpty()) { - val requiredFeaturesAreEnabled = feature.required.all { item -> + if (feature.required.isNullOrEmpty().not()) { + val requiredFeaturesAreEnabled = feature.required!!.all { item -> + var requiredKey: FeatureKey + var requiredVariation: VariationValue? when (item) { - is FeatureKey -> { - val requiredKey = item - val requiredVariation: VariationValue? = null - val requiredIsEnabled = isEnabled(requiredKey, finalContext) - - if (!requiredIsEnabled) { - return@all false - } - - if (requiredVariation != null) { - val requiredVariationValue = getVariation(requiredKey, finalContext) - return requiredVariationValue == requiredVariation - } + is Required.FeatureKey -> { + requiredKey = item.required + requiredVariation = null + } - true + is Required.WithVariation -> { + requiredKey = item.required.key + requiredVariation = item.required.variation } - is WithVariation -> { - val variation = item - val requiredKey = variation.key - val requiredVariation = variation.variation - val requiredIsEnabled = isEnabled(requiredKey, finalContext) - - if (!requiredIsEnabled) { - return@all false - } + } - if (requiredVariation != null) { - val requiredVariationValue = getVariation(requiredKey, finalContext) - return requiredVariationValue == requiredVariation - } + val requiredIsEnabled = isEnabled(requiredKey, finalContext) - true - } + if (requiredIsEnabled.not()) { + return@all false } + + val requiredVariationValue = getVariation(requiredKey, finalContext) + + return@all requiredVariationValue == requiredVariation } - if (!requiredFeaturesAreEnabled) { + if (requiredFeaturesAreEnabled.not()) { evaluation = Evaluation( featureKey = feature.key, - reason = EvaluationReason.REQUIRED, + reason = REQUIRED, enabled = requiredFeaturesAreEnabled ) @@ -264,102 +322,103 @@ fun FeaturevisorInstance.evaluateFlag(featureKey: FeatureKey, context: Context = } // bucketing - val bucketValue = getBucketValue(feature, finalContext) + val bucketValue = getBucketValue(feature = feature, context = finalContext) + val matchedTraffic = getMatchedTraffic( traffic = feature.traffic, context = finalContext, - datafileReader = datafile - if (matchedTraffic != null) { - - if (!feature.ranges.isEmpty()) { + datafileReader = datafileReader, + ) - val matchedRange = feature.ranges.firstOrNull { range -> - bucketValue >= range.start && bucketValue < range.end - } + if (matchedTraffic != null) { - // matched - if (matchedRange != null) { - evaluation = Evaluation( - featureKey = feature.key, - reason = EvaluationReason.ALLOCATED, - bucketValue = bucketValue, - enabled = matchedTraffic.enabled ?: true - ) + if (feature.ranges.isNullOrEmpty().not()) { - return evaluation - } + val matchedRange = feature.ranges!!.firstOrNull { range -> + bucketValue >= range.start && bucketValue < range.end + } - // no match + // matched + if (matchedRange != null) { evaluation = Evaluation( featureKey = feature.key, - reason = EvaluationReason.OUT_OF_RANGE, + reason = ALLOCATED, bucketValue = bucketValue, - enabled = false + enabled = matchedTraffic.enabled ?: true ) - logger.debug("not matched", evaluation.toDictionary()) - return evaluation } - // override from rule - matchedTraffic.enabled?.let { matchedTrafficEnabled -> - evaluation = Evaluation( - featureKey = feature.key, - reason = EvaluationReason.OVERRIDE, - bucketValue = bucketValue, - ruleKey = matchedTraffic.key, - enabled = matchedTrafficEnabled, - traffic = matchedTraffic - ) + // no match + evaluation = Evaluation( + featureKey = feature.key, + reason = OUT_OF_RANGE, + bucketValue = bucketValue, + enabled = false + ) - logger.debug("override from rule", evaluation.toDictionary()) + logger?.debug("not matched", evaluation.toDictionary()) - return evaluation - } + return evaluation + } - // treated as enabled because of matched traffic - if (bucketValue < matchedTraffic.percentage) { - evaluation = Evaluation( - featureKey = feature.key, - reason = EvaluationReason.RULE, - bucketValue = bucketValue, - ruleKey = matchedTraffic.key, - enabled = true, - traffic = matchedTraffic - ) + // override from rule + val matchedTrafficEnabled = matchedTraffic.enabled + if (matchedTrafficEnabled != null) { + evaluation = Evaluation( + featureKey = feature.key, + reason = OVERRIDE, + bucketValue = bucketValue, + ruleKey = matchedTraffic.key, + enabled = matchedTrafficEnabled, + traffic = matchedTraffic + ) - return evaluation - } + logger?.debug("override from rule", evaluation.toDictionary()) + + return evaluation } - // nothing matched + // treated as enabled because of matched traffic + if (bucketValue < matchedTraffic.percentage) { + // @TODO: verify if range check should be inclusive or not evaluation = Evaluation( - featureKey = feature.key, - reason = EvaluationReason.ERROR, + featureKey = feature.key, + reason = RULE, + bucketValue = bucketValue, + ruleKey = matchedTraffic.key, + enabled = true, + traffic = matchedTraffic + ) + + return evaluation + } + } + + // nothing matched + evaluation = Evaluation( + featureKey = feature.key, + reason = ERROR, bucketValue = bucketValue, enabled = false ) return evaluation -} + } fun FeaturevisorInstance.evaluateVariable( featureKey: FeatureKey, variableKey: VariableKey, - context: Context = emptyMap() + context: Context = emptyMap(), ): Evaluation { val evaluation: Evaluation - val flag = evaluateFlag(featureKey, context) - if (flag.enabled == false) { - evaluation = Evaluation(featureKey = featureKey, reason = EvaluationReason.DISABLED) - - logger.debug("feature is disabled", evaluation.toDictionary()) - + evaluation = Evaluation(featureKey = featureKey, reason = DISABLED) + logger?.debug("feature is disabled", evaluation.toDictionary()) return evaluation } @@ -367,58 +426,53 @@ fun FeaturevisorInstance.evaluateVariable( stickyFeatures?.get(featureKey)?.variables?.get(variableKey)?.let { variableValue -> evaluation = Evaluation( featureKey = featureKey, - reason = EvaluationReason.STICKY, + reason = STICKY, variableKey = variableKey, variableValue = variableValue ) - logger.debug("using sticky variable", evaluation.toDictionary()) - + logger?.debug("using sticky variable", evaluation.toDictionary()) return evaluation } // initial if (!statuses.ready && initialFeatures?.get(featureKey)?.variables?.get(variableKey) != null) { - val variableValue = initialFeatures?.get(featureKey)?.variables?.get(variableKey) evaluation = Evaluation( featureKey = featureKey, - reason = EvaluationReason.INITIAL, + reason = INITIAL, variableKey = variableKey, variableValue = variableValue ) - logger.debug("using initial variable", evaluation.toDictionary()) - + logger?.debug("using initial variable", evaluation.toDictionary()) return evaluation } - getFeature(featureKey)?.let { feature -> - if (feature.variablesSchema.isEmpty()) { + getFeatureByKey(featureKey).let { feature -> + if (feature == null) { evaluation = Evaluation( featureKey = featureKey, - reason = EvaluationReason.NOT_FOUND, + reason = NOT_FOUND, variableKey = variableKey ) - logger.warn("feature not found in datafile", evaluation.toDictionary()) - + logger?.warn("feature not found in datafile", evaluation.toDictionary()) return evaluation } - val variableSchema = feature.variablesSchema.firstOrNull { variableSchema -> + val variableSchema = feature.variablesSchema?.firstOrNull { variableSchema -> variableSchema.key == variableKey } if (variableSchema == null) { evaluation = Evaluation( featureKey = featureKey, - reason = EvaluationReason.NOT_FOUND, + reason = NOT_FOUND, variableKey = variableKey ) - logger.warn("variable schema not found", evaluation.toDictionary()) - + logger?.warn("variable schema not found", evaluation.toDictionary()) return evaluation } @@ -426,18 +480,17 @@ fun FeaturevisorInstance.evaluateVariable( // forced findForceFromFeature(feature, context, datafileReader)?.let { force -> - if (force.variables.containsKey(variableKey)) { + if (force.variables?.containsKey(variableKey) == true) { val variableValue = force.variables[variableKey] evaluation = Evaluation( featureKey = feature.key, - reason = EvaluationReason.FORCED, + reason = FORCED, variableKey = variableKey, variableValue = variableValue, variableSchema = variableSchema ) - logger.debug("forced variable", evaluation.toDictionary()) - + logger?.debug("forced variable", evaluation.toDictionary()) return evaluation } } @@ -457,7 +510,7 @@ fun FeaturevisorInstance.evaluateVariable( matchedTraffic.variables?.get(variableKey)?.let { variableValue -> evaluation = Evaluation( featureKey = feature.key, - reason = EvaluationReason.RULE, + reason = RULE, bucketValue = bucketValue, ruleKey = matchedTraffic.key, variableKey = variableKey, @@ -465,14 +518,14 @@ fun FeaturevisorInstance.evaluateVariable( variableSchema = variableSchema ) - logger.debug("override from rule", evaluation.toDictionary()) + logger?.debug("override from rule", evaluation.toDictionary()) return evaluation } // regular allocation matchedTrafficAndAllocation.matchedAllocation?.let { matchedAllocation -> - val variation = feature.variations.firstOrNull { variation -> + val variation = feature.variations?.firstOrNull { variation -> variation.value == matchedAllocation.variation } @@ -497,7 +550,7 @@ fun FeaturevisorInstance.evaluateVariable( }?.let { override -> evaluation = Evaluation( featureKey = feature.key, - reason = EvaluationReason.OVERRIDE, + reason = OVERRIDE, bucketValue = bucketValue, ruleKey = matchedTraffic.key, variableKey = variableKey, @@ -505,15 +558,14 @@ fun FeaturevisorInstance.evaluateVariable( variableSchema = variableSchema ) - logger.debug("variable override", evaluation.toDictionary()) - + logger?.debug("variable override", evaluation.toDictionary()) return evaluation } if (variableFromVariation?.value != null) { evaluation = Evaluation( featureKey = feature.key, - reason = EvaluationReason.ALLOCATED, + reason = ALLOCATED, bucketValue = bucketValue, ruleKey = matchedTraffic.key, variableKey = variableKey, @@ -521,8 +573,7 @@ fun FeaturevisorInstance.evaluateVariable( variableSchema = variableSchema ) - logger.debug("allocated variable", evaluation.toDictionary()) - + logger?.debug("allocated variable", evaluation.toDictionary()) return evaluation } } @@ -531,17 +582,74 @@ fun FeaturevisorInstance.evaluateVariable( // fall back to default evaluation = Evaluation( featureKey = feature.key, - reason = EvaluationReason.DEFAULTED, + reason = DEFAULTED, bucketValue = bucketValue, variableKey = variableKey, variableValue = variableSchema.defaultValue, variableSchema = variableSchema ) - logger.debug("using default value", evaluation.toDictionary()) - + logger?.debug("using default value", evaluation.toDictionary()) return evaluation } } -// ... The rest of your Kotlin code +private fun FeaturevisorInstance.getBucketKey(feature: Feature, context: Context): BucketKey { + val featureKey = feature.key + var type: String + var attributeKeys: List + + when (val bucketBy = feature.bucketBy) { + is BucketBy.Single -> { + type = "plain" + attributeKeys = listOf(bucketBy.bucketBy) + } + + is BucketBy.And -> { + type = "and" + attributeKeys = bucketBy.bucketBy + } + + is BucketBy.Or -> { + type = "or" + attributeKeys = bucketBy.bucketBy.or + } + } + + val bucketKey: MutableList = mutableListOf() + attributeKeys.forEach { attributeKey -> + val attributeValue = context[attributeKey] + if (attributeValue != null) { + if (type == "plain" || type == "and") { + bucketKey.add(attributeValue) + } else { // or + if (bucketKey.isEmpty()) { + bucketKey.add(attributeValue) + } + } + } + } + + bucketKey.add(AttributeValue.StringValue(featureKey)) + + val result = bucketKey.map { + it.toString() + }.joinToString(separator = bucketKeySeparator) + + configureBucketKey?.let { configureBucketKey -> + return configureBucketKey(feature, context, result) + } + + return result +} + +private fun FeaturevisorInstance.getBucketValue(feature: Feature, context: Context): BucketValue { + val bucketKey = getBucketKey(feature, context) + val value = Bucket.getBucketedNumber(bucketKey) + + configureBucketValue?.let { configureBucketValue -> + return configureBucketValue(feature, context, value) + } + + return value +} diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Feature.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Feature.kt index d1b8fdc..e2c23d3 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Feature.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Feature.kt @@ -8,7 +8,7 @@ import com.featurevisor.types.Force import com.featurevisor.types.Traffic fun FeaturevisorInstance.getFeatureByKey(featureKey: String): Feature? { - return datafileReader?.getFeature(featureKey) + return datafileReader.getFeature(featureKey) } fun FeaturevisorInstance.findForceFromFeature( @@ -59,7 +59,7 @@ fun FeaturevisorInstance.getMatchedTrafficAndAllocation( context: Context, bucketValue: Int, datafileReader: DatafileReader, - logger: Logger, + 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 new file mode 100644 index 0000000..23db3b2 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Fetch.kt @@ -0,0 +1,64 @@ +package com.featurevisor.sdk + +import com.featurevisor.types.DatafileContent +import java.io.IOException +import okhttp3.* +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl +import java.lang.IllegalArgumentException + +// MARK: - Fetch datafile content +@Throws(IOException::class) +fun FeaturevisorInstance.fetchDatafileContent( + url: String, + handleDatafileFetch: DatafileFetchHandler? = null, + completion: (Result) -> Unit, +) { + handleDatafileFetch?.let { handleFetch -> + val result = handleFetch(url) + completion(result) + } ?: run { + fetchDatafileContentFromUrl(url, completion) + } +} + +private fun fetchDatafileContentFromUrl( + url: String, + completion: (Result) -> Unit, +) { + try { + val httpUrl = url.toHttpUrl() + val request = Request.Builder() + .url(httpUrl) + .addHeader("Content-Type", "application/json") + .build() + + fetch(request, completion) + } catch (throwable: IllegalArgumentException) { + completion(Result.failure(FeaturevisorError.InvalidUrl(url))) + } +} + +private inline fun fetch( + request: Request, + 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.body + if (response.isSuccessful && responseBody != null) { + val json = Json { ignoreUnknownKeys = true } + val content = json.decodeFromString(responseBody.string()) + completion(Result.success(content)) + } else { + completion(Result.failure(FeaturevisorError.UnparsableJson(responseBody?.string(), response.message))) + } + } + + override fun onFailure(call: Call, e: IOException) { + completion(Result.failure(e)) + } + }) +} diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Refresh.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Refresh.kt new file mode 100644 index 0000000..3a577d2 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Refresh.kt @@ -0,0 +1,87 @@ +package com.featurevisor.sdk + +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 + +fun FeaturevisorInstance.refresh() { + logger?.debug("refreshing datafile") + + if (statuses.refreshInProgress) { + logger?.warn("refresh in progress, skipping") + return + } + + if (datafileUrl == null) { + logger?.error("cannot refresh since `datafileUrl` is not provided") + return + } + + statuses.refreshInProgress = true + + fetchDatafileContent( + datafileUrl, + handleDatafileFetch, + ) { result -> + val self = this // To capture the instance in a closure + if (self == null) { + return@fetchDatafileContent + } + + if (result.isSuccess) { + val datafileContent = result.getOrThrow() + val currentRevision = self.getRevision() + val newRevision = datafileContent.revision + val isNotSameRevision = currentRevision != newRevision + + self.datafileReader = DatafileReader(datafileContent) + logger?.info("refreshed datafile") + + self.emitter.emit(EventName.REFRESH) + + if (isNotSameRevision) { + self.emitter.emit(EventName.UPDATE) + } + + self.statuses.refreshInProgress = false + } else { + self.logger?.error("failed to refresh datafile", mapOf("error" to result)) + self.statuses.refreshInProgress = false + } + } +} + +fun FeaturevisorInstance.startRefreshing() { + val datafileUrl = datafileUrl + if (datafileUrl == null) { + logger?.error("cannot start refreshing since `datafileUrl` is not provided") + return + } + + if (refreshJob != null) { + logger?.warn("refreshing has already started") + return + } + + if (refreshInterval == null) { + logger?.warn("no `refreshInterval` option provided") + return + } + + refreshJob = CoroutineScope(Dispatchers.Unconfined).launch { + while (isActive) { + refresh() + delay(refreshInterval.toLong()) + } + } +} + +fun FeaturevisorInstance.stopRefreshing() { + refreshJob?.cancel() + refreshJob = null + + logger?.warn("refreshing has stopped") +} diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Status.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Status.kt new file mode 100644 index 0000000..14d95ce --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Status.kt @@ -0,0 +1,5 @@ +package com.featurevisor.sdk + +data class Statuses(var ready: Boolean, var refreshInProgress: Boolean) + +fun FeaturevisorInstance.isReady(): Boolean = statuses.ready diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Variable.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Variable.kt new file mode 100644 index 0000000..e486d3b --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Variable.kt @@ -0,0 +1,91 @@ +package com.featurevisor.sdk + +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.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement + +fun FeaturevisorInstance.getVariable( + featureKey: FeatureKey, + variableKey: VariableKey, + context: Context = emptyMap() +): VariableValue? { + val evaluation = evaluateVariable( + featureKey = featureKey, + variableKey = variableKey, + context = context + ) + + return evaluation.variableValue +} + +fun FeaturevisorInstance.getVariableBoolean( + featureKey: FeatureKey, + variableKey: VariableKey, + context: Context +): Boolean? { + return (getVariable(featureKey, variableKey, context) as? BooleanValue)?.value +} + +fun FeaturevisorInstance.getVariableString( + featureKey: FeatureKey, + variableKey: VariableKey, + context: Context +): String? { + return (getVariable(featureKey, variableKey, context) as? StringValue)?.value +} + +fun FeaturevisorInstance.getVariableInteger( + featureKey: FeatureKey, + variableKey: VariableKey, + context: Context +): Int? { + return (getVariable(featureKey, variableKey, context) as? IntValue)?.value +} + +fun FeaturevisorInstance.getVariableDouble( + featureKey: FeatureKey, + variableKey: VariableKey, + context: Context +): Double? { + return (getVariable(featureKey, variableKey, context) as? DoubleValue)?.value +} + +fun FeaturevisorInstance.getVariableArray( + featureKey: FeatureKey, + variableKey: VariableKey, + context: Context +): List? { + return (getVariable(featureKey, variableKey, context) as? ArrayValue)?.values +} + +inline fun FeaturevisorInstance.getVariableObject( + featureKey: FeatureKey, + variableKey: VariableKey, + context: Context +): T? { + val objectValue = getVariable(featureKey, variableKey, context) as? ObjectValue + return try { + val encoded = Json.encodeToJsonElement(objectValue?.value) + return Json.decodeFromJsonElement(encoded) + } catch (e: Exception) { + null + } +} + +inline fun FeaturevisorInstance.getVariableJSON( + featureKey: FeatureKey, + variableKey: VariableKey, + context: Context +): T? { + val json = getVariable(featureKey, variableKey, context) as? JsonValue + return try { + Json.decodeFromString(json!!.value) + } catch (e: Exception) { + null + } +} diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Variation.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Variation.kt new file mode 100644 index 0000000..a340c1d --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Variation.kt @@ -0,0 +1,14 @@ +package com.featurevisor.sdk + +import com.featurevisor.types.Context +import com.featurevisor.types.FeatureKey +import com.featurevisor.types.VariationValue + +fun FeaturevisorInstance.getVariation(featureKey: FeatureKey, context: Context): VariationValue? { + val evaluation = evaluateVariation(featureKey, context) + return when { + evaluation.variationValue != null -> evaluation.variationValue + evaluation.variation != null -> evaluation.variation.value + else -> null + } +} diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance.kt b/src/main/kotlin/com/featurevisor/sdk/Instance.kt index 4d28f6a..0c7bb8b 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance.kt @@ -3,80 +3,27 @@ */ package com.featurevisor.sdk -import com.featurevisor.sdk.FeaturevisorError.* +import com.featurevisor.sdk.FeaturevisorError.FetchingDataFileFailed +import com.featurevisor.sdk.FeaturevisorError.MissingDatafileOptions import com.featurevisor.types.BucketKey import com.featurevisor.types.BucketValue import com.featurevisor.types.Context import com.featurevisor.types.DatafileContent import com.featurevisor.types.EventName -import com.featurevisor.types.EventName.* +import com.featurevisor.types.EventName.ACTIVATION +import com.featurevisor.types.EventName.READY +import com.featurevisor.types.EventName.REFRESH +import com.featurevisor.types.EventName.UPDATE import com.featurevisor.types.Feature -import com.featurevisor.types.FeatureKey -import com.featurevisor.types.OverrideFeature -import com.featurevisor.types.RuleKey import com.featurevisor.types.StickyFeatures -import com.featurevisor.types.Traffic -import com.featurevisor.types.VariableKey -import com.featurevisor.types.VariableSchema -import com.featurevisor.types.VariableValue -import com.featurevisor.types.Variation -import com.featurevisor.types.VariationValue -import kotlinx.serialization.Serializable +import kotlinx.coroutines.Job import kotlinx.serialization.json.Json -import kotlinx.serialization.json.decodeFromJsonElement -import kotlinx.serialization.json.encodeToJsonElement -import java.util.Timer typealias ConfigureBucketKey = (Feature, Context, BucketKey) -> BucketKey typealias ConfigureBucketValue = (Feature, Context, BucketValue) -> BucketValue typealias InterceptContext = (Context) -> Context typealias DatafileFetchHandler = (datafileUrl: String) -> Result -data class Statuses(var ready: Boolean, var refreshInProgress: Boolean) - -enum class EvaluationReason(val value: String) { - NOT_FOUND("not_found"), - NO_VARIATIONS("no_variations"), - DISABLED("disabled"), - REQUIRED("required"), - OUT_OF_RANGE("out_of_range"), - FORCED("forced"), - INITIAL("initial"), - STICKY("sticky"), - RULE("rule"), - ALLOCATED("allocated"), - DEFAULTED("defaulted"), - OVERRIDE("override"), - ERROR("error") -} - -@Serializable -data class Evaluation( - val featureKey: FeatureKey, - val reason: EvaluationReason, - val bucketValue: BucketValue? = null, - val ruleKey: RuleKey? = null, - val enabled: Boolean? = null, - val traffic: Traffic? = null, - val sticky: OverrideFeature? = null, - val initial: OverrideFeature? = null, - val variation: Variation? = null, - val variationValue: VariationValue? = null, - val variableKey: VariableKey? = null, - val variableValue: VariableValue? = null, - val variableSchema: VariableSchema? = null, -) { - fun toDictionary(): Map { - val data = try { - val json = Json.encodeToJsonElement(this) - Json.decodeFromJsonElement>(json) - } catch (e: Exception) { - emptyMap() - } - return data - } -} - val emptyDatafile = DatafileContent( schemaVersion = "1", revision = "unknown", @@ -93,20 +40,26 @@ class FeaturevisorInstance private constructor(options: InstanceOptions) { } } - private val emitter: Emitter = Emitter() // private val on: (EventName, Listener) -> Unit - private val addListener: (EventName, Listener) -> Unit // private val off: (EventName) -> Unit + private val addListener: (EventName, Listener) -> Unit private val removeListener: (EventName) -> Unit private val removeAllListeners: () -> Unit - private var timer: Timer? = null internal val statuses = Statuses(ready = false, refreshInProgress = false) internal val logger = options.logger internal val initialFeatures = options.initialFeatures + internal val interceptContext = options.interceptContext + internal val emitter: Emitter = Emitter() + internal val datafileUrl = options.datafileUrl + internal val handleDatafileFetch = options.handleDatafileFetch + internal val refreshInterval = options.refreshInterval + internal lateinit var datafileReader: DatafileReader internal var stickyFeatures = options.stickyFeatures - internal var datafileReader: DatafileReader? = null -// var urlSession: URLSession = URLSession(configuration = options.sessionConfiguration) + internal var bucketKeySeparator = options.bucketKeySeparator + internal var configureBucketKey = options.configureBucketKey + internal var configureBucketValue = options.configureBucketValue + internal var refreshJob: Job? = null init { with(options) { @@ -178,13 +131,6 @@ class FeaturevisorInstance private constructor(options: InstanceOptions) { } fun getRevision(): String { - return datafileReader!!.getRevision() + return datafileReader.getRevision() } } - -//TODO: All of the below move to another place -private fun fetchDatafileContent(dataFileUrl: String?, lambda: (Result) -> Unit) { - -} - -private fun startRefreshing() {} diff --git a/src/main/kotlin/com/featurevisor/sdk/InstanceOptions.kt b/src/main/kotlin/com/featurevisor/sdk/InstanceOptions.kt index e8b6d5e..5fb23c9 100644 --- a/src/main/kotlin/com/featurevisor/sdk/InstanceOptions.kt +++ b/src/main/kotlin/com/featurevisor/sdk/InstanceOptions.kt @@ -4,7 +4,7 @@ import com.featurevisor.types.DatafileContent import com.featurevisor.types.InitialFeatures import com.featurevisor.types.StickyFeatures -typealias Listener = () -> Unit +typealias Listener = (Array) -> Unit data class InstanceOptions( val bucketKeySeparator: String = defaultBucketKeySeparator, diff --git a/src/main/kotlin/com/featurevisor/types/DataFile.kt b/src/main/kotlin/com/featurevisor/types/DataFile.kt index 67367a6..eca582a 100644 --- a/src/main/kotlin/com/featurevisor/types/DataFile.kt +++ b/src/main/kotlin/com/featurevisor/types/DataFile.kt @@ -30,7 +30,7 @@ data class Traffic( typealias PlainBucketBy = String -typealias AndBucketBy = List +typealias AndBucketBy = List data class OrBucketBy( val or: List, @@ -48,7 +48,7 @@ data class RequiredWithVariation( ) sealed class Required { - data class FeatureKey(val required: FeatureKey) : Required() + data class FeatureKey(val required: com.featurevisor.types.FeatureKey) : Required() data class WithVariation(val required: RequiredWithVariation) : Required() }