diff --git a/README.md b/README.md index ea284d7..189d39d 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,10 @@ We are breaking down the various parts that we need to migrate to Swift in the s | | SDK's `conditions.ts` ➡️ `Conditions.kt` | ✅ | | | SDK's `datafileReader.ts` ➡️ `DatafileReader.kt` | ✅ | | | SDK's `emitter.ts` ➡️ `Emitter.kt` | ✅ | -| | SDK's `feature.ts` ➡️ `Feature.kt` | | -| | SDK's `instance.ts` ➡️ `Instance.kt` | 🟠 | -| | SDK's `logger.ts` ➡️ `Logger.kt` | | -| | SDK's `segments.ts` ➡️ `Segments.kt` | | +| | SDK's `feature.ts` ➡️ `Instance+Feature.kt` | ✅ | +| | SDK's `instance.ts` ➡️ `Instance.kt` | ✅ | +| | SDK's `logger.ts` ➡️ `Logger.kt` | ✅ | +| | SDK's `segments.ts` ➡️ `Instance+Segments.kt` | ✅ | | | | | | Constructor options | `bucketKeySeparator` | | | | `configureBucketKey` | | diff --git a/build.gradle.kts b/build.gradle.kts index 60b3dc3..4585924 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,18 +82,3 @@ tasks.named("test") { showStandardStreams = true } } - -// TODO: Remove excludes when Instance.kt is ready -sourceSets { - main { - kotlin { - exclude("com/featurevisor/sdk/Instance.kt") - exclude("com/featurevisor/sdk/InstanceOptions.kt") - } - } - test { - kotlin { - exclude("com/featurevisor/sdk/InstanceTest.kt") - } - } -} diff --git a/src/main/kotlin/com/featurevisor/sdk/Bucket.kt b/src/main/kotlin/com/featurevisor/sdk/Bucket.kt index d99e73d..49ebabf 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Bucket.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Bucket.kt @@ -1,11 +1,12 @@ package com.featurevisor.sdk import com.goncalossilva.murmurhash.MurmurHash3 +import kotlin.math.floor object Bucket { + private const val HASH_SEED = 1u private const val MAX_HASH_VALUE = 4294967296 // 2^32 - // 100% * 1000 to include three decimal places in the same integer value private const val MAX_BUCKETED_NUMBER = 100000 @@ -13,6 +14,6 @@ object Bucket { val hashValue = MurmurHash3(HASH_SEED).hash32x86(bucketKey.toByteArray()) val ratio = hashValue.toDouble() / MAX_HASH_VALUE - return kotlin.math.floor(ratio * MAX_BUCKETED_NUMBER).toInt() + return floor(ratio * MAX_BUCKETED_NUMBER).toInt() } } diff --git a/src/main/kotlin/com/featurevisor/sdk/Conditions.kt b/src/main/kotlin/com/featurevisor/sdk/Conditions.kt index b85e963..7b12337 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Conditions.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Conditions.kt @@ -31,6 +31,7 @@ import com.featurevisor.types.Operator.STARTS_WITH import net.swiftzer.semver.SemVer object Conditions { + fun conditionIsMatched(condition: Plain, context: Context): Boolean { val (attributeKey, operator, conditionValue) = condition val attributeValue = context.getOrDefault(attributeKey, null) ?: return false @@ -44,12 +45,36 @@ object Conditions { NOT_CONTAINS -> attributeValue.value.contains(conditionValue.value).not() STARTS_WITH -> attributeValue.value.startsWith(conditionValue.value) ENDS_WITH -> attributeValue.value.endsWith(conditionValue.value) - SEMVER_EQUALS -> compareVersions(attributeValue.value, conditionValue.value) == 0 - SEMVER_NOT_EQUALS -> compareVersions(attributeValue.value, conditionValue.value) != 0 - SEMVER_GREATER_THAN -> compareVersions(attributeValue.value, conditionValue.value) == 1 - SEMVER_GREATER_THAN_OR_EQUAL -> compareVersions(attributeValue.value, conditionValue.value) >= 0 - SEMVER_LESS_THAN -> compareVersions(attributeValue.value, conditionValue.value) == -1 - SEMVER_LESS_THAN_OR_EQUAL -> compareVersions(attributeValue.value, conditionValue.value) <= 0 + SEMVER_EQUALS -> compareVersions( + attributeValue.value, + conditionValue.value, + ) == 0 + + SEMVER_NOT_EQUALS -> compareVersions( + attributeValue.value, + conditionValue.value, + ) != 0 + + SEMVER_GREATER_THAN -> compareVersions( + attributeValue.value, + conditionValue.value + ) == 1 + + SEMVER_GREATER_THAN_OR_EQUAL -> compareVersions( + attributeValue.value, + conditionValue.value + ) >= 0 + + SEMVER_LESS_THAN -> compareVersions( + attributeValue.value, + conditionValue.value + ) == -1 + + SEMVER_LESS_THAN_OR_EQUAL -> compareVersions( + attributeValue.value, + conditionValue.value + ) <= 0 + else -> false } } @@ -107,23 +132,16 @@ object Conditions { } } - fun allConditionsAreMatched(condition: Condition, context: Context): Boolean = - when (condition) { + fun allConditionsAreMatched(condition: Condition, context: Context): Boolean { + return when (condition) { is Plain -> conditionIsMatched(condition, context) - - is And -> condition.and.all { - allConditionsAreMatched(it, context) - } - - is Or -> condition.or.any { - allConditionsAreMatched(it, context) - } - - is Not -> condition.not.all { - allConditionsAreMatched(it, context) - }.not() + is And -> condition.and.all { allConditionsAreMatched(it, context) } + is Or -> condition.or.any { allConditionsAreMatched(it, context) } + is Not -> condition.not.all { allConditionsAreMatched(it, context) }.not() } + } - private fun compareVersions(actual: String, condition: String): Int = - SemVer.parse(actual).compareTo(SemVer.parse(condition)) + private fun compareVersions(actual: String, condition: String): Int { + return SemVer.parse(actual).compareTo(SemVer.parse(condition)) + } } diff --git a/src/main/kotlin/com/featurevisor/sdk/DataFileReader.kt b/src/main/kotlin/com/featurevisor/sdk/DatafileReader.kt similarity index 51% rename from src/main/kotlin/com/featurevisor/sdk/DataFileReader.kt rename to src/main/kotlin/com/featurevisor/sdk/DatafileReader.kt index 0cfc697..eef4c77 100644 --- a/src/main/kotlin/com/featurevisor/sdk/DataFileReader.kt +++ b/src/main/kotlin/com/featurevisor/sdk/DatafileReader.kt @@ -8,27 +8,37 @@ import com.featurevisor.types.FeatureKey import com.featurevisor.types.Segment import com.featurevisor.types.SegmentKey -class DataFileReader constructor( +class DatafileReader constructor( datafileJson: DatafileContent, ) { + private val schemaVersion: String = datafileJson.schemaVersion private val revision: String = datafileJson.revision private val attributes: List = datafileJson.attributes private val segments: List = datafileJson.segments private val features: List = datafileJson.features - fun getRevision(): String = revision + fun getRevision(): String { + return revision + } - fun getSchemaVersion(): String = schemaVersion + fun getSchemaVersion(): String { + return schemaVersion + } - fun getAllAttributes(): List = attributes + fun getAllAttributes(): List { + return attributes + } - fun getAttribute(attributeKey: AttributeKey): Attribute? = - attributes.find { attribute -> attribute.key == attributeKey } + fun getAttribute(attributeKey: AttributeKey): Attribute? { + return attributes.find { attribute -> attribute.key == attributeKey } + } - fun getSegment(segmentKey: SegmentKey): Segment? = - segments.find { segment -> segment.key == segmentKey } + fun getSegment(segmentKey: SegmentKey): Segment? { + return segments.find { segment -> segment.key == segmentKey } + } - fun getFeature(featureKey: FeatureKey): Feature? = - features.find { feature -> feature.key == featureKey } + fun getFeature(featureKey: FeatureKey): Feature? { + return features.find { feature -> feature.key == featureKey } + } } diff --git a/src/main/kotlin/com/featurevisor/sdk/Emitter.kt b/src/main/kotlin/com/featurevisor/sdk/Emitter.kt index 8affa92..b5cafdf 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Emitter.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Emitter.kt @@ -3,9 +3,10 @@ package com.featurevisor.sdk import com.featurevisor.types.EventName class Emitter { - private val listeners = mutableMapOf Unit>() - fun addListener(event: EventName, listener: () -> Unit) { + private val listeners = mutableMapOf) -> Unit>() + + fun addListener(event: EventName, listener: (Array) -> Unit) { listeners.putIfAbsent(event, listener) } @@ -17,7 +18,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 new file mode 100644 index 0000000..064c5ff --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/FeaturevisorError.kt @@ -0,0 +1,22 @@ +package com.featurevisor.sdk + +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 + object MissingDatafileOptions : FeaturevisorError("Missing data file options") + + 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") + + object MissingDatafileUrlWhileRefreshing : FeaturevisorError("Missing datafile url need to refresh") +} 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..3454b0b --- /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.ACTIVATION +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 -> + finalContext[attribute.key]?.let { + captureContext[attribute.key] = it + } + } + + emitter.emit( + 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 new file mode 100644 index 0000000..cb87154 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt @@ -0,0 +1,655 @@ +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) + return evaluation.enabled == true +} + +fun FeaturevisorInstance.evaluateVariation(featureKey: FeatureKey, context: Context = emptyMap()): Evaluation { + var evaluation: Evaluation + val flag = evaluateFlag(featureKey, context) + if (flag.enabled == false) { + evaluation = Evaluation( + featureKey = featureKey, + reason = DISABLED, + ) + + logger?.debug("feature is disabled", evaluation.toDictionary()) + return evaluation + } + + // sticky + stickyFeatures?.get(featureKey)?.variation?.let { variationValue -> + evaluation = Evaluation( + featureKey = featureKey, + reason = STICKY, + variationValue = variationValue, + ) + + logger?.debug("using sticky variation", evaluation.toDictionary()) + return evaluation + } + + // initial + if (statuses.ready.not() && initialFeatures?.get(featureKey)?.variation != null) { + val variationValue = initialFeatures[featureKey]?.variation + evaluation = Evaluation( + featureKey = featureKey, + reason = INITIAL, + variationValue = variationValue + ) + + logger?.debug("using initial variation", evaluation.toDictionary()) + return evaluation + } + + val feature = getFeatureByKey(featureKey) + if (feature == null) { + // not found + evaluation = Evaluation( + featureKey = featureKey, + reason = NOT_FOUND + ) + + logger?.warn("feature not found", evaluation.toDictionary()) + return evaluation + } + + if (feature.variations.isNullOrEmpty()) { + // no variations + evaluation = Evaluation( + featureKey = featureKey, + reason = NO_VARIATIONS + ) + + logger?.warn("no variations", evaluation.toDictionary()) + return evaluation + } + + val finalContext = interceptContext?.invoke(context) ?: context + + // forced + val force = findForceFromFeature(feature, context, datafileReader) + if (force != null) { + val variation = feature.variations.firstOrNull { it.value == force.variation } + + if (variation != null) { + evaluation = Evaluation( + featureKey = feature.key, + reason = FORCED, + variation = variation + ) + + logger?.debug("forced variation found", evaluation.toDictionary()) + + return evaluation + } + } + + // bucketing + val bucketValue = getBucketValue(feature, finalContext) + + val matchedTrafficAndAllocation = getMatchedTrafficAndAllocation( + feature.traffic, + finalContext, + bucketValue, + datafileReader, + logger + ) + + val matchedTraffic = matchedTrafficAndAllocation.matchedTraffic + + // 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 + ) + + logger?.debug("override from rule", evaluation.toDictionary()) + + return evaluation + } + } + + val matchedAllocation = matchedTrafficAndAllocation.matchedAllocation + + // 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 = ERROR, + bucketValue = bucketValue + ) + + logger?.debug("no matched variation", evaluation.toDictionary()) + + return evaluation +} + +fun FeaturevisorInstance.evaluateFlag(featureKey: FeatureKey, context: Context = emptyMap()): Evaluation { + val evaluation: Evaluation + + // sticky + stickyFeatures?.get(featureKey)?.let { stickyFeature -> + evaluation = Evaluation( + featureKey = featureKey, + reason = STICKY, + enabled = stickyFeature.enabled, + sticky = stickyFeature + ) + + logger?.debug("using sticky enabled", evaluation.toDictionary()) + + return evaluation + } + + // initial + if (statuses.ready && initialFeatures?.get(featureKey) != null) { + val initialFeature = initialFeatures[featureKey] + evaluation = Evaluation( + featureKey = featureKey, + reason = INITIAL, + enabled = initialFeature?.enabled, + initial = initialFeature + ) + + logger?.debug("using initial enabled", evaluation.toDictionary()) + + return evaluation + } + + val feature = getFeatureByKey(featureKey) + if (feature == null) { + // not found + evaluation = Evaluation( + featureKey = featureKey, + reason = NOT_FOUND + ) + + logger?.warn("feature not found", evaluation.toDictionary()) + return evaluation + } + + // deprecated + if (feature.deprecated == true) { + logger?.warn("feature is deprecated", mapOf("featureKey" to feature.key)) + } + + val finalContext = interceptContext?.invoke(context) ?: context + + // forced + findForceFromFeature(feature, context, datafileReader)?.let { force -> + if (force.enabled != null) { + evaluation = Evaluation( + featureKey = featureKey, + reason = FORCED, + enabled = force.enabled + ) + + logger?.debug("forced enabled found", evaluation.toDictionary()) + + return evaluation + } + } + + // required + if (feature.required.isNullOrEmpty().not()) { + val requiredFeaturesAreEnabled = feature.required!!.all { item -> + var requiredKey: FeatureKey + var requiredVariation: VariationValue? + when (item) { + is Required.FeatureKey -> { + requiredKey = item.required + requiredVariation = null + } + + is Required.WithVariation -> { + requiredKey = item.required.key + requiredVariation = item.required.variation + } + } + + val requiredIsEnabled = isEnabled(requiredKey, finalContext) + + if (requiredIsEnabled.not()) { + return@all false + } + + val requiredVariationValue = getVariation(requiredKey, finalContext) + + return@all requiredVariationValue == requiredVariation + } + + if (requiredFeaturesAreEnabled.not()) { + evaluation = Evaluation( + featureKey = feature.key, + reason = REQUIRED, + enabled = requiredFeaturesAreEnabled + ) + + return evaluation + } + } + + // bucketing + val bucketValue = getBucketValue(feature = feature, context = finalContext) + + val matchedTraffic = getMatchedTraffic( + traffic = feature.traffic, + context = finalContext, + datafileReader = datafileReader, + ) + + if (matchedTraffic != null) { + + if (feature.ranges.isNullOrEmpty().not()) { + + val matchedRange = feature.ranges!!.firstOrNull { range -> + bucketValue >= range.start && bucketValue < range.end + } + + // matched + if (matchedRange != null) { + evaluation = Evaluation( + featureKey = feature.key, + reason = ALLOCATED, + bucketValue = bucketValue, + enabled = matchedTraffic.enabled ?: true + ) + + return evaluation + } + + // no match + evaluation = Evaluation( + featureKey = feature.key, + reason = OUT_OF_RANGE, + bucketValue = bucketValue, + enabled = false + ) + + logger?.debug("not matched", evaluation.toDictionary()) + + return evaluation + } + + // 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 + ) + + logger?.debug("override from rule", evaluation.toDictionary()) + + return evaluation + } + + // 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 = 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(), +): Evaluation { + + val evaluation: Evaluation + val flag = evaluateFlag(featureKey, context) + if (flag.enabled == false) { + evaluation = Evaluation(featureKey = featureKey, reason = DISABLED) + logger?.debug("feature is disabled", evaluation.toDictionary()) + return evaluation + } + + // sticky + stickyFeatures?.get(featureKey)?.variables?.get(variableKey)?.let { variableValue -> + evaluation = Evaluation( + featureKey = featureKey, + reason = STICKY, + variableKey = variableKey, + variableValue = variableValue + ) + + 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 = INITIAL, + variableKey = variableKey, + variableValue = variableValue + ) + + logger?.debug("using initial variable", evaluation.toDictionary()) + return evaluation + } + + getFeatureByKey(featureKey).let { feature -> + if (feature == null) { + evaluation = Evaluation( + featureKey = featureKey, + reason = NOT_FOUND, + variableKey = variableKey + ) + + logger?.warn("feature not found in datafile", evaluation.toDictionary()) + return evaluation + } + + val variableSchema = feature.variablesSchema?.firstOrNull { variableSchema -> + variableSchema.key == variableKey + } + + if (variableSchema == null) { + evaluation = Evaluation( + featureKey = featureKey, + reason = NOT_FOUND, + variableKey = variableKey + ) + + logger?.warn("variable schema not found", evaluation.toDictionary()) + return evaluation + } + + val finalContext = interceptContext?.invoke(context) ?: context + + // forced + findForceFromFeature(feature, context, datafileReader)?.let { force -> + if (force.variables?.containsKey(variableKey) == true) { + val variableValue = force.variables[variableKey] + evaluation = Evaluation( + featureKey = feature.key, + reason = FORCED, + variableKey = variableKey, + variableValue = variableValue, + variableSchema = variableSchema + ) + + logger?.debug("forced variable", evaluation.toDictionary()) + return evaluation + } + } + + // bucketing + val bucketValue = getBucketValue(feature, finalContext) + val matchedTrafficAndAllocation = getMatchedTrafficAndAllocation( + traffic = feature.traffic, + context = finalContext, + bucketValue = bucketValue, + datafileReader = datafileReader, + logger = logger + ) + + matchedTrafficAndAllocation.matchedTraffic?.let { matchedTraffic -> + // override from rule + matchedTraffic.variables?.get(variableKey)?.let { variableValue -> + evaluation = Evaluation( + featureKey = feature.key, + reason = RULE, + bucketValue = bucketValue, + ruleKey = matchedTraffic.key, + variableKey = variableKey, + variableValue = variableValue, + variableSchema = variableSchema + ) + + logger?.debug("override from rule", evaluation.toDictionary()) + + return evaluation + } + + // regular allocation + matchedTrafficAndAllocation.matchedAllocation?.let { matchedAllocation -> + val variation = feature.variations?.firstOrNull { variation -> + variation.value == matchedAllocation.variation + } + + val variableFromVariation = variation?.variables?.firstOrNull { variable -> + variable.key == variableKey + } + + variableFromVariation?.overrides?.firstOrNull { override -> + if (override.conditions != null) { + return@firstOrNull allConditionsAreMatched(override.conditions, finalContext) + } + + if (override.segments != null) { + return@firstOrNull allGroupSegmentsAreMatched( + override.segments, + finalContext, + datafileReader + ) + } + + false + }?.let { override -> + evaluation = Evaluation( + featureKey = feature.key, + reason = OVERRIDE, + bucketValue = bucketValue, + ruleKey = matchedTraffic.key, + variableKey = variableKey, + variableValue = override.value, + variableSchema = variableSchema + ) + + logger?.debug("variable override", evaluation.toDictionary()) + return evaluation + } + + if (variableFromVariation?.value != null) { + evaluation = Evaluation( + featureKey = feature.key, + reason = ALLOCATED, + bucketValue = bucketValue, + ruleKey = matchedTraffic.key, + variableKey = variableKey, + variableValue = variableFromVariation.value, + variableSchema = variableSchema + ) + + logger?.debug("allocated variable", evaluation.toDictionary()) + return evaluation + } + } + } + + // fall back to default + evaluation = Evaluation( + featureKey = feature.key, + reason = DEFAULTED, + bucketValue = bucketValue, + variableKey = variableKey, + variableValue = variableSchema.defaultValue, + variableSchema = variableSchema + ) + + logger?.debug("using default value", evaluation.toDictionary()) + return evaluation + } +} + +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 new file mode 100644 index 0000000..a3f04d3 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Feature.kt @@ -0,0 +1,81 @@ +package com.featurevisor.sdk + +import com.featurevisor.sdk.Conditions.allConditionsAreMatched +import com.featurevisor.types.Allocation +import com.featurevisor.types.Context +import com.featurevisor.types.Feature +import com.featurevisor.types.Force +import com.featurevisor.types.Traffic + +internal fun FeaturevisorInstance.getFeatureByKey(featureKey: String): Feature? { + return datafileReader.getFeature(featureKey) +} + +internal fun FeaturevisorInstance.findForceFromFeature( + feature: Feature, + context: Context, + datafileReader: DatafileReader, +): Force? { + + return feature.force?.firstOrNull { force -> + when { + force.conditions != null -> allConditionsAreMatched(force.conditions, context) + force.segments != null -> allGroupSegmentsAreMatched( + force.segments, + context, + datafileReader + ) + + else -> false + } + } +} + +internal fun FeaturevisorInstance.getMatchedTraffic( + traffic: List, + context: Context, + datafileReader: DatafileReader, +): Traffic? { + + return traffic.firstOrNull { trafficItem -> + allGroupSegmentsAreMatched(trafficItem.segments, context, datafileReader) + } +} + +internal fun FeaturevisorInstance.getMatchedAllocation( + traffic: Traffic, + bucketValue: Int, +): Allocation? { + + return traffic.allocation.firstOrNull { allocation -> + with(allocation.range) { + bucketValue in start..end + } + } +} + +data class MatchedTrafficAndAllocation( + val matchedTraffic: Traffic?, + val matchedAllocation: Allocation?, +) + +internal fun FeaturevisorInstance.getMatchedTrafficAndAllocation( + traffic: List, + context: Context, + bucketValue: Int, + datafileReader: DatafileReader, + logger: Logger?, +): MatchedTrafficAndAllocation { + + var matchedAllocation: Allocation? = null + val matchedTraffic = traffic.firstOrNull { trafficItem -> + if (allGroupSegmentsAreMatched(trafficItem.segments, context, datafileReader).not()) { + false + } else { + matchedAllocation = getMatchedAllocation(trafficItem, bucketValue) + matchedAllocation != null + } + } + + return MatchedTrafficAndAllocation(matchedTraffic, matchedAllocation) +} 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..6a45ee4 --- /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) +internal 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..f3094e6 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Refresh.kt @@ -0,0 +1,72 @@ +package com.featurevisor.sdk + +import com.featurevisor.sdk.FeaturevisorError.* +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.startRefreshing() = when { + datafileUrl == null -> { + logger?.error("cannot start refreshing since `datafileUrl` is not provided") + throw MissingDatafileUrlWhileRefreshing + } + + refreshJob != null -> logger?.warn("refreshing has already started") + refreshInterval == null -> logger?.warn("no `refreshInterval` option provided") + else -> { + refreshJob = CoroutineScope(Dispatchers.Unconfined).launch { + while (isActive) { + refresh() + delay(refreshInterval) + } + } + } +} + +fun FeaturevisorInstance.stopRefreshing() { + refreshJob?.cancel() + refreshJob = null + logger?.warn("refreshing has stopped") +} + +private fun FeaturevisorInstance.refresh() { + logger?.debug("refreshing datafile") + when { + statuses.refreshInProgress -> logger?.warn("refresh in progress, skipping") + datafileUrl.isNullOrBlank() -> logger?.error("cannot refresh since `datafileUrl` is not provided") + else -> { + statuses.refreshInProgress = true + fetchDatafileContent( + datafileUrl, + handleDatafileFetch, + ) { result -> + + if (result.isSuccess) { + val datafileContent = result.getOrThrow() + val currentRevision = getRevision() + val newRevision = datafileContent.revision + val isNotSameRevision = currentRevision != newRevision + + datafileReader = DatafileReader(datafileContent) + logger?.info("refreshed datafile") + + emitter.emit(EventName.REFRESH) + if (isNotSameRevision) { + emitter.emit(EventName.UPDATE) + } + + statuses.refreshInProgress = false + } else { + logger?.error( + "failed to refresh datafile", + mapOf("error" to result) + ) + statuses.refreshInProgress = false + } + } + } + } +} diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Segments.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Segments.kt new file mode 100644 index 0000000..06a4c10 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Segments.kt @@ -0,0 +1,73 @@ +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 FeaturevisorInstance.segmentIsMatched(segment: Segment, context: Context): Boolean { + return allConditionsAreMatched(segment.conditions, context) +} + +internal fun FeaturevisorInstance.allGroupSegmentsAreMatched( + groupSegments: GroupSegment, + context: Context, + datafileReader: DatafileReader, +): Boolean { + return when (groupSegments) { + is Plain -> { + val segmentKey = groupSegments.segment + if (segmentKey == "*") { + true + } else { + datafileReader.getSegment(segmentKey)?.let { + segmentIsMatched(it, context) + } ?: false + } + } + + is Multiple -> { + groupSegments.segments.all { + allGroupSegmentsAreMatched(it, context, datafileReader) + } + } + + is And -> { + groupSegments.segment.and.all { + allGroupSegmentsAreMatched(it, context, datafileReader) + } + } + + is Or -> { + groupSegments.segment.or.any { + allGroupSegmentsAreMatched(it, context, datafileReader) + } + } + + is Not -> { + groupSegments.segment.not.all { + allGroupSegmentsAreMatched(it, context, datafileReader).not() + } + } + } +} 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..9cb74d6 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Status.kt @@ -0,0 +1,7 @@ +package com.featurevisor.sdk + +data class Statuses(var ready: Boolean, var refreshInProgress: Boolean) + +internal 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 new file mode 100644 index 0000000..50d000a --- /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 + +internal fun FeaturevisorInstance.getVariable( + featureKey: FeatureKey, + variableKey: VariableKey, + context: Context = emptyMap(), +): VariableValue? { + val evaluation = evaluateVariable( + featureKey = featureKey, + variableKey = variableKey, + context = context + ) + + return evaluation.variableValue +} + +internal fun FeaturevisorInstance.getVariableBoolean( + featureKey: FeatureKey, + variableKey: VariableKey, + context: Context, +): Boolean? { + return (getVariable(featureKey, variableKey, context) as? BooleanValue)?.value +} + +internal fun FeaturevisorInstance.getVariableString( + featureKey: FeatureKey, + variableKey: VariableKey, + context: Context, +): String? { + return (getVariable(featureKey, variableKey, context) as? StringValue)?.value +} + +internal fun FeaturevisorInstance.getVariableInteger( + featureKey: FeatureKey, + variableKey: VariableKey, + context: Context, +): Int? { + return (getVariable(featureKey, variableKey, context) as? IntValue)?.value +} + +internal fun FeaturevisorInstance.getVariableDouble( + featureKey: FeatureKey, + variableKey: VariableKey, + context: Context, +): Double? { + return (getVariable(featureKey, variableKey, context) as? DoubleValue)?.value +} + +internal fun FeaturevisorInstance.getVariableArray( + featureKey: FeatureKey, + variableKey: VariableKey, + context: Context, +): List? { + return (getVariable(featureKey, variableKey, context) as? ArrayValue)?.values +} + +internal 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 + } +} + +internal 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..5a9eb2a --- /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 + +internal 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 new file mode 100644 index 0000000..f6a0579 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/Instance.kt @@ -0,0 +1,134 @@ +/* + * This Kotlin source file was generated by the Gradle 'init' task. + */ +package com.featurevisor.sdk + +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.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.StickyFeatures +import kotlinx.coroutines.Job +import kotlinx.serialization.json.Json + +typealias ConfigureBucketKey = (Feature, Context, BucketKey) -> BucketKey +typealias ConfigureBucketValue = (Feature, Context, BucketValue) -> BucketValue +typealias InterceptContext = (Context) -> Context +typealias DatafileFetchHandler = (datafileUrl: String) -> Result + +class FeaturevisorInstance private constructor(options: InstanceOptions) { + + companion object { + fun createInstance(options: InstanceOptions): FeaturevisorInstance { + return FeaturevisorInstance(options) + } + } + + private val on: (EventName, Listener) -> Unit + private val off: (EventName) -> Unit + private val addListener: (EventName, Listener) -> Unit + private val removeListener: (EventName) -> Unit + private val removeAllListeners: () -> Unit + + 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 bucketKeySeparator = options.bucketKeySeparator + internal var configureBucketKey = options.configureBucketKey + internal var configureBucketValue = options.configureBucketValue + internal var refreshJob: Job? = null + + init { + with(options) { + if (onReady != null) { + emitter.addListener(event = READY, listener = onReady) + } + + if (onRefresh != null) { + emitter.addListener( + REFRESH, onRefresh + ) + } + if (onUpdate != null) { + emitter.addListener( + UPDATE, onUpdate + ) + } + if (onActivation != null) { + emitter.addListener( + ACTIVATION, onActivation + ) + } + + on = emitter::addListener + off = emitter::removeListener + addListener = emitter::addListener + removeListener = emitter::removeListener + removeAllListeners = emitter::removeAllListeners + + when { + datafile != null -> { + datafileReader = DatafileReader(datafile) + statuses.ready = true + emitter.emit(READY) + } + + datafileUrl != null -> { + fetchDatafileContent(datafileUrl) { result -> + if (result.isSuccess) { + datafileReader = DatafileReader(result.getOrThrow()) + statuses.ready = true + emitter.emit(READY) + if (refreshInterval != null) startRefreshing() + } else { + logger?.error("Failed to fetch datafile: $result") + throw FetchingDataFileFailed(result.toString()) + } + } + } + + else -> throw MissingDatafileOptions + } + } + } + + fun setDatafile(datafileJSON: String) { + val data = datafileJSON.toByteArray(Charsets.UTF_8) + try { + val datafileContent = Json.decodeFromString(String(data)) + datafileReader = DatafileReader(datafileJson = datafileContent) + } catch (e: Exception) { + logger?.error("could not parse datafile", mapOf("error" to e)) + } + } + + fun setDatafile(datafileContent: DatafileContent) { + datafileReader = DatafileReader(datafileJson = datafileContent) + } + + fun setStickyFeatures(stickyFeatures: StickyFeatures?) { + this.stickyFeatures = stickyFeatures + } + + fun getRevision(): String { + return datafileReader.getRevision() + } +} diff --git a/src/main/kotlin/com/featurevisor/sdk/InstanceOptions.kt b/src/main/kotlin/com/featurevisor/sdk/InstanceOptions.kt new file mode 100644 index 0000000..86e72bd --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/InstanceOptions.kt @@ -0,0 +1,29 @@ +package com.featurevisor.sdk + +import com.featurevisor.types.DatafileContent +import com.featurevisor.types.InitialFeatures +import com.featurevisor.types.StickyFeatures + +typealias Listener = (Array) -> Unit + +data class InstanceOptions( + val bucketKeySeparator: String = defaultBucketKeySeparator, + val configureBucketKey: ConfigureBucketKey? = null, + val configureBucketValue: ConfigureBucketValue? = null, + val datafile: DatafileContent? = null, + val datafileUrl: String? = null, + val handleDatafileFetch: DatafileFetchHandler? = null, + val initialFeatures: InitialFeatures? = null, + val interceptContext: InterceptContext? = null, + val logger: Logger? = null, + val onActivation: Listener? = null, + val onReady: Listener? = null, + val onRefresh: Listener? = null, + val onUpdate: Listener? = null, + val refreshInterval: Long? = null, // seconds + val stickyFeatures: StickyFeatures? = null, +) { + companion object { + private const val defaultBucketKeySeparator = "." + } +} diff --git a/src/main/kotlin/com/featurevisor/sdk/Library.kt b/src/main/kotlin/com/featurevisor/sdk/Library.kt deleted file mode 100644 index 7044ed2..0000000 --- a/src/main/kotlin/com/featurevisor/sdk/Library.kt +++ /dev/null @@ -1,10 +0,0 @@ -/* - * This Kotlin source file was generated by the Gradle 'init' task. - */ -package com.featurevisor.sdk - -class Library { - fun someLibraryMethod(): Boolean { - return true - } -} diff --git a/src/main/kotlin/com/featurevisor/sdk/Logger.kt b/src/main/kotlin/com/featurevisor/sdk/Logger.kt index 3e2975c..df3c183 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Logger.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Logger.kt @@ -1,52 +1,61 @@ package com.featurevisor.sdk -import com.featurevisor.sdk.LogLevel.* +import com.featurevisor.sdk.Logger.LogLevel.DEBUG +import com.featurevisor.sdk.Logger.LogLevel.ERROR +import com.featurevisor.sdk.Logger.LogLevel.INFO +import com.featurevisor.sdk.Logger.LogLevel.WARN -typealias LogMessage = String typealias LogDetails = Map -typealias LogHandler = (level: LogLevel, message: LogMessage, details: LogDetails?) -> Unit - -enum class LogLevel(val value: String) { - ERROR("error"), - WARN("warn"), - INFO("info"), - DEBUG("debug") -} +typealias LogHandler = (level: Logger.LogLevel, message: String, details: LogDetails?) -> Unit class Logger( private var levels: List, private val handle: LogHandler, ) { - companion object { private val defaultLogLevels: List = listOf(ERROR, WARN) private val defaultLogHandler: LogHandler = { level, message, _ -> println("[${level.value}] $message") } - fun createLogger(levels: List = defaultLogLevels, handle: LogHandler = defaultLogHandler): Logger = - Logger(levels, handle) + fun createLogger( + levels: List = defaultLogLevels, + handle: LogHandler = defaultLogHandler, + ): Logger { + return Logger(levels, handle) + } } fun setLevels(levels: List) { this.levels = levels } - fun debug(message: LogMessage, details: LogDetails? = null) = + fun debug(message: String, details: LogDetails? = null) { log(DEBUG, message, details) + } - fun info(message: LogMessage, details: LogDetails? = null) = + fun info(message: String, details: LogDetails? = null) { log(INFO, message, details) + } - fun warn(message: LogMessage, details: LogDetails? = null) = + fun warn(message: String, details: LogDetails? = null) { log(WARN, message, details) + } - fun error(message: LogMessage, details: LogDetails? = null) = + fun error(message: String, details: LogDetails? = null) { log(ERROR, message, details) + } - private fun log(level: LogLevel, message: LogMessage, details: LogDetails? = null) { + private fun log(level: LogLevel, message: String, details: LogDetails? = null) { if (level in levels) { handle(level, message, details) } } + + enum class LogLevel(val value: String) { + ERROR("error"), + WARN("warn"), + INFO("info"), + DEBUG("debug"), + } } diff --git a/src/main/kotlin/com/featurevisor/types/Attribute.kt b/src/main/kotlin/com/featurevisor/types/Attribute.kt deleted file mode 100644 index 0910aa8..0000000 --- a/src/main/kotlin/com/featurevisor/types/Attribute.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.featurevisor.types - -import java.time.LocalDate - -typealias AttributeKey = String - -data class Attribute( - val key: AttributeKey, - val type: String, - val archived: Boolean?, - val capture: Boolean?, -) - -sealed class AttributeValue { - data class StringValue(val value: String) : AttributeValue() - data class IntValue(val value: Int) : AttributeValue() - data class DoubleValue(val value: Double) : AttributeValue() - data class BooleanValue(val value: Boolean) : AttributeValue() - data class DateValue(val value: LocalDate) : AttributeValue() - object NullValue : AttributeValue() -} diff --git a/src/main/kotlin/com/featurevisor/types/Condition.kt b/src/main/kotlin/com/featurevisor/types/Condition.kt deleted file mode 100644 index bee7b7a..0000000 --- a/src/main/kotlin/com/featurevisor/types/Condition.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.featurevisor.types - -import java.time.LocalDate - -sealed class Condition { - data class Plain( - val attributeKey: AttributeKey, - val operator: Operator, - val value: ConditionValue, - ) : Condition() - - data class And(val and: List) : Condition() - data class Or(val or: List) : Condition() - data class Not(val not: List) : Condition() -} - -sealed class ConditionValue { - data class StringValue(val value: String) : ConditionValue() - data class IntValue(val value: Int) : ConditionValue() - data class DoubleValue(val value: Double) : ConditionValue() - data class BooleanValue(val value: Boolean) : ConditionValue() - data class ArrayValue(val values: List) : ConditionValue() - data class DateTimeValue(val value: LocalDate) : ConditionValue() - object NullValue : ConditionValue() -} diff --git a/src/main/kotlin/com/featurevisor/types/DataFile.kt b/src/main/kotlin/com/featurevisor/types/DataFile.kt deleted file mode 100644 index 67367a6..0000000 --- a/src/main/kotlin/com/featurevisor/types/DataFile.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.featurevisor.types - -/** - * Datafile-only types - */ -// 0 to 100,000 -typealias Percentage = Int - -data class Range( - val start: Percentage, - val end: Percentage, -) - -data class Allocation( - val variation: VariationValue, - val range: Range, -) - -data class Traffic( - val key: RuleKey, - val segments: GroupSegment, - val percentage: Percentage, - - val enabled: Boolean?, - val variation: VariationValue?, - val variables: VariableValues?, - - val allocation: List, -) - -typealias PlainBucketBy = String - -typealias AndBucketBy = List - -data class OrBucketBy( - val or: List, -) - -sealed class BucketBy { - data class Single(val bucketBy: PlainBucketBy) : BucketBy() - data class And(val bucketBy: AndBucketBy) : BucketBy() - data class Or(val bucketBy: OrBucketBy) : BucketBy() -} - -data class RequiredWithVariation( - val key: FeatureKey, - val variation: VariationValue, -) - -sealed class Required { - data class FeatureKey(val required: FeatureKey) : Required() - data class WithVariation(val required: RequiredWithVariation) : Required() -} - -data class Feature( - val key: FeatureKey, - val deprecated: Boolean?, - val variablesSchema: List?, - val variations: List?, - val bucketBy: BucketBy, - val required: List?, - val traffic: List, - val force: List?, - - // if in a Group (mutex), these are available slot ranges - val ranges: List?, -) - -data class DatafileContent( - val schemaVersion: String, - val revision: String, - val attributes: List, - val segments: List, - val features: List, -) - -data class OverrideFeature( - val enabled: Boolean, - val variation: VariationValue?, - val variables: VariableValues?, -) diff --git a/src/main/kotlin/com/featurevisor/types/EventName.kt b/src/main/kotlin/com/featurevisor/types/EventName.kt deleted file mode 100644 index a868e05..0000000 --- a/src/main/kotlin/com/featurevisor/types/EventName.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.featurevisor.types - -enum class EventName { - READY, - REFRESH, - UPDATE, - ACTIVATION, -} diff --git a/src/main/kotlin/com/featurevisor/types/Operator.kt b/src/main/kotlin/com/featurevisor/types/Operator.kt deleted file mode 100644 index 18b9699..0000000 --- a/src/main/kotlin/com/featurevisor/types/Operator.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.featurevisor.types - -enum class Operator(val value: String) { - EQUALS("equals"), - NOT_EQUALS("notEquals"), - - // numeric - GREATER_THAN("greaterThan"), - GREATER_THAN_OR_EQUAL("greaterThanOrEqual"), - LESS_THAN("lessThan"), - LESS_THAN_OR_EQUAL("lessThanOrEqual"), - - // string - CONTAINS("contains"), - NOT_CONTAINS("notContains"), - STARTS_WITH("startsWith"), - ENDS_WITH("endsWith"), - - // semver (string) - SEMVER_EQUALS("semverEquals"), - SEMVER_NOT_EQUALS("semverNotEquals"), - SEMVER_GREATER_THAN("semverGreaterThan"), - SEMVER_GREATER_THAN_OR_EQUAL("semverGreaterThanOrEqual"), - SEMVER_LESS_THAN("semverLessThan"), - SEMVER_LESS_THAN_OR_EQUAL("semverLessThanOrEqual"), - - // date comparisons - BEFORE("before"), - AFTER("after"), - - // array of strings - IN_ARRAY("inArray"), - NOT_IN_ARRAY("notInArray"); -} diff --git a/src/main/kotlin/com/featurevisor/types/Segment.kt b/src/main/kotlin/com/featurevisor/types/Segment.kt deleted file mode 100644 index 695f39b..0000000 --- a/src/main/kotlin/com/featurevisor/types/Segment.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.featurevisor.types - -typealias SegmentKey = String - -data class Segment( - val archived: Boolean?, - val key: SegmentKey, - val conditions: Condition, -) - -typealias PlainGroupSegment = SegmentKey - -data class AndGroupSegment( - val and: List, -) - -data class OrGroupSegment( - val or: List, -) - -data class NotGroupSegment( - val not: List, -) - -sealed class GroupSegment { - data class Plain(val segment: PlainGroupSegment) : GroupSegment() - data class Multiple(val segments: List) : GroupSegment() - - data class And(val segment: AndGroupSegment) : GroupSegment() - data class Or(val segment: OrGroupSegment) : GroupSegment() - data class Not(val segment: NotGroupSegment) : GroupSegment() -} diff --git a/src/main/kotlin/com/featurevisor/types/Types.kt b/src/main/kotlin/com/featurevisor/types/Types.kt index 86ca3f4..c2150d9 100644 --- a/src/main/kotlin/com/featurevisor/types/Types.kt +++ b/src/main/kotlin/com/featurevisor/types/Types.kt @@ -1,19 +1,19 @@ package com.featurevisor.types -typealias Context = Map +import java.time.LocalDate +typealias Context = Map typealias VariationValue = String - typealias VariableKey = String enum class VariableType(val value: String) { - boolean("boolean"), - string("string"), - integer("integer"), - double("double"), - array("array"), - object_("object"), - json("json"); + BOOLEAN("boolean"), + STRING("string"), + INTEGER("integer"), + DOUBLE("double"), + ARRAY("array"), + OBJECT("object"), + JSON("json"); } typealias VariableObjectValue = Map @@ -89,22 +89,17 @@ data class Group( ) typealias BucketKey = String - // 0 to 100,000 typealias BucketValue = Int - typealias StickyFeatures = Map - -typealias InitialFeatures = Map +typealias InitialFeatures = Map /** * YAML-only type */ // 0 to 100 typealias Weight = Double - typealias EnvironmentKey = String - typealias RuleKey = String data class Rule( @@ -178,3 +173,193 @@ sealed class Test { data class Feature(val value: TestFeature) : Test() data class Segment(val value: TestSegment) : Test() } + +typealias AttributeKey = String + +data class Attribute( + val key: AttributeKey, + val type: String, + val archived: Boolean?, + val capture: Boolean?, +) + +sealed class AttributeValue { + data class StringValue(val value: String) : AttributeValue() + data class IntValue(val value: Int) : AttributeValue() + data class DoubleValue(val value: Double) : AttributeValue() + data class BooleanValue(val value: Boolean) : AttributeValue() + data class DateValue(val value: LocalDate) : AttributeValue() +} + +sealed class Condition { + data class Plain( + val attributeKey: AttributeKey, + val operator: Operator, + val value: ConditionValue, + ) : Condition() + + data class And(val and: List) : Condition() + data class Or(val or: List) : Condition() + data class Not(val not: List) : Condition() +} + +sealed class ConditionValue { + data class StringValue(val value: String) : ConditionValue() + data class IntValue(val value: Int) : ConditionValue() + data class DoubleValue(val value: Double) : ConditionValue() + data class BooleanValue(val value: Boolean) : ConditionValue() + data class ArrayValue(val values: List) : ConditionValue() + data class DateTimeValue(val value: LocalDate) : ConditionValue() +} + +typealias SegmentKey = String + +data class Segment( + val archived: Boolean?, + val key: SegmentKey, + val conditions: Condition, +) + +typealias PlainGroupSegment = SegmentKey + +data class AndGroupSegment( + val and: List, +) + +data class OrGroupSegment( + val or: List, +) + +data class NotGroupSegment( + val not: List, +) + +sealed class GroupSegment { + data class Plain(val segment: PlainGroupSegment) : GroupSegment() + data class Multiple(val segments: List) : GroupSegment() + + data class And(val segment: AndGroupSegment) : GroupSegment() + data class Or(val segment: OrGroupSegment) : GroupSegment() + data class Not(val segment: NotGroupSegment) : GroupSegment() +} + +enum class Operator(val value: String) { + EQUALS("equals"), + NOT_EQUALS("notEquals"), + + // numeric + GREATER_THAN("greaterThan"), + GREATER_THAN_OR_EQUAL("greaterThanOrEqual"), + LESS_THAN("lessThan"), + LESS_THAN_OR_EQUAL("lessThanOrEqual"), + + // string + CONTAINS("contains"), + NOT_CONTAINS("notContains"), + STARTS_WITH("startsWith"), + ENDS_WITH("endsWith"), + + // semver (string) + SEMVER_EQUALS("semverEquals"), + SEMVER_NOT_EQUALS("semverNotEquals"), + SEMVER_GREATER_THAN("semverGreaterThan"), + SEMVER_GREATER_THAN_OR_EQUAL("semverGreaterThanOrEqual"), + SEMVER_LESS_THAN("semverLessThan"), + SEMVER_LESS_THAN_OR_EQUAL("semverLessThanOrEqual"), + + // date comparisons + BEFORE("before"), + AFTER("after"), + + // array of strings + IN_ARRAY("inArray"), + NOT_IN_ARRAY("notInArray"); +} + +enum class EventName { + READY, + REFRESH, + UPDATE, + ACTIVATION, +} + + +/** + * Datafile-only types + */ +// 0 to 100,000 +typealias Percentage = Int + +data class Range( + val start: Percentage, + val end: Percentage, +) + +data class Allocation( + val variation: VariationValue, + val range: Range, +) + +data class Traffic( + val key: RuleKey, + val segments: GroupSegment, + val percentage: Percentage, + + val enabled: Boolean?, + val variation: VariationValue?, + val variables: VariableValues?, + + val allocation: List, +) + +typealias PlainBucketBy = String + +typealias AndBucketBy = List + +data class OrBucketBy( + val or: List, +) + +sealed class BucketBy { + data class Single(val bucketBy: PlainBucketBy) : BucketBy() + data class And(val bucketBy: AndBucketBy) : BucketBy() + data class Or(val bucketBy: OrBucketBy) : BucketBy() +} + +data class RequiredWithVariation( + val key: FeatureKey, + val variation: VariationValue, +) + +sealed class Required { + data class FeatureKey(val required: com.featurevisor.types.FeatureKey) : Required() + data class WithVariation(val required: RequiredWithVariation) : Required() +} + +data class Feature( + val key: FeatureKey, + val deprecated: Boolean?, + val variablesSchema: List?, + val variations: List?, + val bucketBy: BucketBy, + val required: List?, + val traffic: List, + val force: List?, + + // if in a Group (mutex), these are available slot ranges + val ranges: List?, +) + +data class DatafileContent( + val schemaVersion: String, + val revision: String, + val attributes: List, + val segments: List, + val features: List, +) + +data class OverrideFeature( + val enabled: Boolean, + val variation: VariationValue?, + val variables: VariableValues?, +) diff --git a/src/test/kotlin/com/featurevisor/sdk/DataFileReaderTest.kt b/src/test/kotlin/com/featurevisor/sdk/DatafileReaderTest.kt similarity index 94% rename from src/test/kotlin/com/featurevisor/sdk/DataFileReaderTest.kt rename to src/test/kotlin/com/featurevisor/sdk/DatafileReaderTest.kt index dd3383e..9a146e0 100644 --- a/src/test/kotlin/com/featurevisor/sdk/DataFileReaderTest.kt +++ b/src/test/kotlin/com/featurevisor/sdk/DatafileReaderTest.kt @@ -4,9 +4,9 @@ import com.featurevisor.sdk.factory.DatafileContentFactory import io.kotest.matchers.shouldBe import org.junit.jupiter.api.Test -class DataFileReaderTest { +class DatafileReaderTest { - private val systemUnderTest = DataFileReader( + private val systemUnderTest = DatafileReader( datafileJson = DatafileContentFactory.get() ) diff --git a/src/test/kotlin/com/featurevisor/sdk/EmitterTest.kt b/src/test/kotlin/com/featurevisor/sdk/EmitterTest.kt index 93c05f7..18dd185 100644 --- a/src/test/kotlin/com/featurevisor/sdk/EmitterTest.kt +++ b/src/test/kotlin/com/featurevisor/sdk/EmitterTest.kt @@ -12,17 +12,17 @@ import org.junit.jupiter.api.Test class EmitterTest { - private val readyCallback: () -> Unit = mockk { - every { this@mockk() } answers { nothing } + private val readyCallback: Listener = mockk { + every { this@mockk(emptyArray()) } answers { nothing } } - private val refreshCallback: () -> Unit = mockk { - every { this@mockk() } answers { nothing } + private val refreshCallback: Listener = mockk { + every { this@mockk(emptyArray()) } answers { nothing } } - private val updateCallback: () -> Unit = mockk { - every { this@mockk() } answers { nothing } + private val updateCallback: Listener = mockk { + every { this@mockk(emptyArray()) } answers { nothing } } - private val activationCallback: () -> Unit = mockk { - every { this@mockk() } answers { nothing } + private val activationCallback: Listener = mockk { + every { this@mockk(emptyArray()) } answers { nothing } } private val systemUnderTest = Emitter() @@ -39,10 +39,10 @@ class EmitterTest { } verify(exactly = 1) { - readyCallback() - refreshCallback() - updateCallback() - activationCallback() + readyCallback(any()) + refreshCallback(any()) + updateCallback(any()) + activationCallback(any()) } } @@ -58,11 +58,11 @@ class EmitterTest { } verify(exactly = 1) { - readyCallback() - updateCallback() + readyCallback(any()) + updateCallback(any()) } verify(exactly = 0) { - refreshCallback() + refreshCallback(any()) } } @@ -79,10 +79,10 @@ class EmitterTest { } verify(exactly = 0) { - readyCallback() - refreshCallback() - updateCallback() - activationCallback() + readyCallback(any()) + refreshCallback(any()) + updateCallback(any()) + activationCallback(any()) } } } diff --git a/src/test/kotlin/com/featurevisor/sdk/InstanceTest.kt b/src/test/kotlin/com/featurevisor/sdk/InstanceTest.kt new file mode 100644 index 0000000..c7590af --- /dev/null +++ b/src/test/kotlin/com/featurevisor/sdk/InstanceTest.kt @@ -0,0 +1,33 @@ +/* + * This Kotlin source file was generated by the Gradle 'init' task. + */ +package com.featurevisor.sdk + +//TODO: Add unit tests for Instance.kt later +//class InstanceTest { +// +// private val systemUnderTest = FeaturevisorInstance.createInstance( +// options = InstanceOptions( +// bucketKeySeparator = "", +// configureBucketKey = { feature: Feature, map: Map, s: String -> }, +// configureBucketValue = { feature: Feature, map: Map, i: Int -> }, +// datafile = null, +// datafileUrl = null, +// handleDatafileFetch = {}, +// initialFeatures = mapOf(), +// interceptContext = {}, +// logger = null, +// onActivation = {}, +// onReady = {}, +// onRefresh = {}, +// onUpdate = {}, +// refreshInterval = null, +// stickyFeatures = mapOf() +// ) +// ) +// +// @Test +// fun `instance initialised properly`() { +// +// } +//} diff --git a/src/test/kotlin/com/featurevisor/sdk/LibraryTest.kt b/src/test/kotlin/com/featurevisor/sdk/LibraryTest.kt deleted file mode 100644 index 1424f81..0000000 --- a/src/test/kotlin/com/featurevisor/sdk/LibraryTest.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * This Kotlin source file was generated by the Gradle 'init' task. - */ -package com.featurevisor.sdk - -import kotlin.test.Test -import kotlin.test.assertTrue - -class LibraryTest { - @Test - fun someLibraryMethodReturnsTrue() { - val classUnderTest = Library() - assertTrue(classUnderTest.someLibraryMethod(), "someLibraryMethod should return 'true'") - } -} diff --git a/src/test/kotlin/com/featurevisor/sdk/LoggerTest.kt b/src/test/kotlin/com/featurevisor/sdk/LoggerTest.kt index 75259d4..8cdcdf7 100644 --- a/src/test/kotlin/com/featurevisor/sdk/LoggerTest.kt +++ b/src/test/kotlin/com/featurevisor/sdk/LoggerTest.kt @@ -1,9 +1,9 @@ package com.featurevisor.sdk -import com.featurevisor.sdk.LogLevel.DEBUG -import com.featurevisor.sdk.LogLevel.ERROR -import com.featurevisor.sdk.LogLevel.INFO -import com.featurevisor.sdk.LogLevel.WARN +import com.featurevisor.sdk.Logger.LogLevel.DEBUG +import com.featurevisor.sdk.Logger.LogLevel.ERROR +import com.featurevisor.sdk.Logger.LogLevel.INFO +import com.featurevisor.sdk.Logger.LogLevel.WARN import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -17,7 +17,7 @@ class LoggerTest { every { this@mockk(any(), any(), any()) } answers { nothing } } - private val systemUnderTest = Logger.createLogger( + private val systemUnderTest: Logger = Logger.createLogger( handle = mockLogHandler, )