diff --git a/build.gradle.kts b/build.gradle.kts index 4585924..ccbdad5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,8 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent plugins { // Apply the org.jetbrains.kotlin.jvm Plugin to add support for Kotlin. - id("org.jetbrains.kotlin.jvm") version "1.8.20" + kotlin("jvm") version "1.8.0" + kotlin("plugin.serialization") version "1.8.0" // Apply the java-library plugin for API and implementation separation. `java-library` @@ -60,7 +61,7 @@ dependencies { // This dependency is used internally, and not exposed to consumers on their own compile classpath. 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("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0") implementation("com.squareup.okhttp3:okhttp:4.11.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") } diff --git a/src/main/kotlin/com/featurevisor/sdk/Conditions.kt b/src/main/kotlin/com/featurevisor/sdk/Conditions.kt index 7b12337..895e60c 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Conditions.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Conditions.kt @@ -137,7 +137,7 @@ object Conditions { 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 Not -> condition.not.all { allConditionsAreMatched(it, context).not() } } } diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt index cb87154..600af47 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Evaluation.kt @@ -169,7 +169,7 @@ fun FeaturevisorInstance.evaluateVariation(featureKey: FeatureKey, context: Cont // override from rule if (matchedTraffic?.variation != null) { - val variation = feature.variations?.firstOrNull { it.value == matchedTraffic.variation } + val variation = feature.variations.firstOrNull { it.value == matchedTraffic.variation } if (variation != null) { evaluation = Evaluation( featureKey = feature.key, @@ -217,6 +217,8 @@ fun FeaturevisorInstance.evaluateVariation(featureKey: FeatureKey, context: Cont } fun FeaturevisorInstance.evaluateFlag(featureKey: FeatureKey, context: Context = emptyMap()): Evaluation { + logger?.debug("evaluate flag: $featureKey") + val evaluation: Evaluation // sticky @@ -414,6 +416,7 @@ fun FeaturevisorInstance.evaluateVariable( context: Context = emptyMap(), ): Evaluation { + FeaturevisorInstance.companionLogger?.debug("evaluateVariable, featureKey: $featureKey, variableKey: $variableKey") val evaluation: Evaluation val flag = evaluateFlag(featureKey, context) if (flag.enabled == false) { @@ -612,7 +615,7 @@ private fun FeaturevisorInstance.getBucketKey(feature: Feature, context: Context is BucketBy.Or -> { type = "or" - attributeKeys = bucketBy.bucketBy.or + attributeKeys = bucketBy.bucketBy } } @@ -632,9 +635,9 @@ private fun FeaturevisorInstance.getBucketKey(feature: Feature, context: Context bucketKey.add(AttributeValue.StringValue(featureKey)) - val result = bucketKey.map { + val result = bucketKey.joinToString(separator = bucketKeySeparator) { it.toString() - }.joinToString(separator = bucketKeySeparator) + } configureBucketKey?.let { configureBucketKey -> return configureBucketKey(feature, context, result) diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Feature.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Feature.kt index a3f04d3..e9fbe20 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Feature.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Feature.kt @@ -7,7 +7,7 @@ import com.featurevisor.types.Feature import com.featurevisor.types.Force import com.featurevisor.types.Traffic -internal fun FeaturevisorInstance.getFeatureByKey(featureKey: String): Feature? { +fun FeaturevisorInstance.getFeatureByKey(featureKey: String): Feature? { return datafileReader.getFeature(featureKey) } @@ -69,11 +69,11 @@ internal fun FeaturevisorInstance.getMatchedTrafficAndAllocation( var matchedAllocation: Allocation? = null val matchedTraffic = traffic.firstOrNull { trafficItem -> - if (allGroupSegmentsAreMatched(trafficItem.segments, context, datafileReader).not()) { - false - } else { + if (allGroupSegmentsAreMatched(trafficItem.segments, context, datafileReader)) { matchedAllocation = getMatchedAllocation(trafficItem, bucketValue) - matchedAllocation != null + true + } else { + false } } diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Fetch.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Fetch.kt index 6a45ee4..ea950c8 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Fetch.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Fetch.kt @@ -1,6 +1,7 @@ package com.featurevisor.sdk import com.featurevisor.types.DatafileContent +import kotlinx.serialization.decodeFromString import java.io.IOException import okhttp3.* import kotlinx.serialization.json.Json @@ -39,21 +40,44 @@ private fun fetchDatafileContentFromUrl( } } -private inline fun fetch( +const val BODY_BYTE_COUNT = 1000000L +private inline fun fetch( request: Request, - crossinline completion: (Result) -> Unit, + crossinline completion: (Result) -> Unit, ) { val client = OkHttpClient() val call = client.newCall(request) call.enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { - val responseBody = response.body - if (response.isSuccessful && responseBody != null) { - val json = Json { ignoreUnknownKeys = true } - val content = json.decodeFromString(responseBody.string()) - completion(Result.success(content)) + val responseBody = response.peekBody(BODY_BYTE_COUNT) + if (response.isSuccessful) { + val json = Json { + ignoreUnknownKeys = true + } + val responseBodyString = responseBody.string() + FeaturevisorInstance.companionLogger?.debug(responseBodyString) + try { + val content = json.decodeFromString(responseBodyString) + completion(Result.success(content)) + } catch(throwable: Throwable) { + completion( + Result.failure( + FeaturevisorError.UnparsableJson( + responseBody.string(), + response.message + ) + ) + ) + } } else { - completion(Result.failure(FeaturevisorError.UnparsableJson(responseBody?.string(), response.message))) + completion( + Result.failure( + FeaturevisorError.UnparsableJson( + responseBody.string(), + response.message + ) + ) + ) } } diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance+Variable.kt b/src/main/kotlin/com/featurevisor/sdk/Instance+Variable.kt index 50d000a..925f20d 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance+Variable.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance+Variable.kt @@ -4,12 +4,19 @@ 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 com.featurevisor.types.VariableValue.ArrayValue +import com.featurevisor.types.VariableValue.BooleanValue +import com.featurevisor.types.VariableValue.DoubleValue +import com.featurevisor.types.VariableValue.IntValue +import com.featurevisor.types.VariableValue.JsonValue +import com.featurevisor.types.VariableValue.ObjectValue +import com.featurevisor.types.VariableValue.StringValue +import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement -internal fun FeaturevisorInstance.getVariable( +fun FeaturevisorInstance.getVariable( featureKey: FeatureKey, variableKey: VariableKey, context: Context = emptyMap(), @@ -23,7 +30,7 @@ internal fun FeaturevisorInstance.getVariable( return evaluation.variableValue } -internal fun FeaturevisorInstance.getVariableBoolean( +fun FeaturevisorInstance.getVariableBoolean( featureKey: FeatureKey, variableKey: VariableKey, context: Context, @@ -31,7 +38,7 @@ internal fun FeaturevisorInstance.getVariableBoolean( return (getVariable(featureKey, variableKey, context) as? BooleanValue)?.value } -internal fun FeaturevisorInstance.getVariableString( +fun FeaturevisorInstance.getVariableString( featureKey: FeatureKey, variableKey: VariableKey, context: Context, @@ -39,7 +46,7 @@ internal fun FeaturevisorInstance.getVariableString( return (getVariable(featureKey, variableKey, context) as? StringValue)?.value } -internal fun FeaturevisorInstance.getVariableInteger( +fun FeaturevisorInstance.getVariableInteger( featureKey: FeatureKey, variableKey: VariableKey, context: Context, @@ -47,7 +54,7 @@ internal fun FeaturevisorInstance.getVariableInteger( return (getVariable(featureKey, variableKey, context) as? IntValue)?.value } -internal fun FeaturevisorInstance.getVariableDouble( +fun FeaturevisorInstance.getVariableDouble( featureKey: FeatureKey, variableKey: VariableKey, context: Context, @@ -55,7 +62,7 @@ internal fun FeaturevisorInstance.getVariableDouble( return (getVariable(featureKey, variableKey, context) as? DoubleValue)?.value } -internal fun FeaturevisorInstance.getVariableArray( +fun FeaturevisorInstance.getVariableArray( featureKey: FeatureKey, variableKey: VariableKey, context: Context, @@ -63,7 +70,7 @@ internal fun FeaturevisorInstance.getVariableArray( return (getVariable(featureKey, variableKey, context) as? ArrayValue)?.values } -internal inline fun FeaturevisorInstance.getVariableObject( +inline fun FeaturevisorInstance.getVariableObject( featureKey: FeatureKey, variableKey: VariableKey, context: Context, @@ -77,7 +84,7 @@ internal inline fun FeaturevisorInstance.getVariableObject( } } -internal inline fun FeaturevisorInstance.getVariableJSON( +inline fun FeaturevisorInstance.getVariableJSON( featureKey: FeatureKey, variableKey: VariableKey, context: Context, @@ -89,3 +96,4 @@ internal inline fun FeaturevisorInstance.getVariableJSON( null } } + diff --git a/src/main/kotlin/com/featurevisor/sdk/Instance.kt b/src/main/kotlin/com/featurevisor/sdk/Instance.kt index f6a0579..8dcc271 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Instance.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Instance.kt @@ -10,13 +10,11 @@ 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.EventName.* import com.featurevisor.types.Feature import com.featurevisor.types.StickyFeatures import kotlinx.coroutines.Job +import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json typealias ConfigureBucketKey = (Feature, Context, BucketKey) -> BucketKey @@ -30,6 +28,8 @@ class FeaturevisorInstance private constructor(options: InstanceOptions) { fun createInstance(options: InstanceOptions): FeaturevisorInstance { return FeaturevisorInstance(options) } + + var companionLogger: Logger? = null } private val on: (EventName, Listener) -> Unit @@ -58,6 +58,7 @@ class FeaturevisorInstance private constructor(options: InstanceOptions) { init { with(options) { + companionLogger = logger if (onReady != null) { emitter.addListener(event = READY, listener = onReady) } @@ -77,6 +78,11 @@ class FeaturevisorInstance private constructor(options: InstanceOptions) { ACTIVATION, onActivation ) } + if (onError != null) { + emitter.addListener( + ERROR, onError + ) + } on = emitter::addListener off = emitter::removeListener @@ -96,11 +102,11 @@ class FeaturevisorInstance private constructor(options: InstanceOptions) { if (result.isSuccess) { datafileReader = DatafileReader(result.getOrThrow()) statuses.ready = true - emitter.emit(READY) + emitter.emit(READY, result.getOrThrow()) if (refreshInterval != null) startRefreshing() } else { logger?.error("Failed to fetch datafile: $result") - throw FetchingDataFileFailed(result.toString()) + emitter.emit(ERROR) } } } diff --git a/src/main/kotlin/com/featurevisor/sdk/InstanceOptions.kt b/src/main/kotlin/com/featurevisor/sdk/InstanceOptions.kt index 86e72bd..48254db 100644 --- a/src/main/kotlin/com/featurevisor/sdk/InstanceOptions.kt +++ b/src/main/kotlin/com/featurevisor/sdk/InstanceOptions.kt @@ -20,6 +20,7 @@ data class InstanceOptions( val onReady: Listener? = null, val onRefresh: Listener? = null, val onUpdate: Listener? = null, + val onError: Listener? = null, val refreshInterval: Long? = null, // seconds val stickyFeatures: StickyFeatures? = null, ) { diff --git a/src/main/kotlin/com/featurevisor/sdk/serializers/Serializers.kt b/src/main/kotlin/com/featurevisor/sdk/serializers/Serializers.kt new file mode 100644 index 0000000..04736d3 --- /dev/null +++ b/src/main/kotlin/com/featurevisor/sdk/serializers/Serializers.kt @@ -0,0 +1,361 @@ +package com.featurevisor.sdk.serializers + +import com.featurevisor.sdk.FeaturevisorInstance +import com.featurevisor.types.AndGroupSegment +import com.featurevisor.types.BucketBy +import com.featurevisor.types.Condition +import com.featurevisor.types.ConditionValue +import com.featurevisor.types.GroupSegment +import com.featurevisor.types.NotGroupSegment +import com.featurevisor.types.Operator +import com.featurevisor.types.OrGroupSegment +import com.featurevisor.types.VariableValue +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.Serializer +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.descriptors.PolymorphicKind +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.serializer + +@OptIn(InternalSerializationApi::class) +@Serializer(forClass = Condition::class) +object ConditionSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildSerialDescriptor("package.Condition", PolymorphicKind.SEALED) + + override fun deserialize(decoder: Decoder): Condition { + val input = decoder as? JsonDecoder + ?: throw SerializationException("This class can be decoded only by Json format") + return when (val tree = input.decodeJsonElement()) { + is JsonArray -> { + Condition.And(tree.map { jsonElement -> + input.json.decodeFromJsonElement( + Condition::class.serializer(), + jsonElement + ) + }) + } + + is JsonObject -> { + FeaturevisorInstance.companionLogger?.debug("Segment deserializing: ${tree["attribute"]?.jsonPrimitive?.content}, tree: $tree") + when { + tree.containsKey("and") -> Condition.And( + tree["and"]!!.jsonArray.map { + input.json.decodeFromJsonElement( + Condition::class.serializer(), + it + ) + } + ) + + tree.containsKey("or") -> Condition.Or( + tree["or"]!!.jsonArray.map { + input.json.decodeFromJsonElement( + Condition::class.serializer(), + it + ) + } + ) + + tree.containsKey("not") -> Condition.Not( + tree["not"]!!.jsonArray.map { + input.json.decodeFromJsonElement( + Condition::class.serializer(), + it + ) + } + ) + + else -> Condition.Plain( + attributeKey = tree["attribute"]?.jsonPrimitive?.content ?: "", + operator = mapOperator(tree["operator"]?.jsonPrimitive?.content ?: ""), + value = input.json.decodeFromJsonElement( + ConditionValue::class.serializer(), + tree["value"]!! + ), + ) + } + } + + is JsonPrimitive -> { + val jsonElement = input.json.parseToJsonElement(tree.content) + input.json.decodeFromJsonElement( + Condition::class.serializer(), + jsonElement + ) + } + } + } + + override fun serialize(encoder: Encoder, value: Condition) { + // TODO: Later if needed + } +} + +@OptIn(InternalSerializationApi::class) +@Serializer(forClass = GroupSegment::class) +object GroupSegmentSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildSerialDescriptor("package.GroupSegment", PolymorphicKind.SEALED) + + override fun deserialize(decoder: Decoder): GroupSegment { + val input = decoder as? JsonDecoder + ?: throw SerializationException("This class can be decoded only by Json format") + return when (val tree = input.decodeJsonElement()) { + is JsonArray -> GroupSegment.Multiple(tree.map { jsonElement -> + input.json.decodeFromJsonElement( + GroupSegment::class.serializer(), + jsonElement + ) + }) + + is JsonObject -> { + when { + tree.containsKey("and") -> GroupSegment.And( + AndGroupSegment( + tree["and"]!!.jsonArray.map { + input.json.decodeFromJsonElement( + GroupSegment::class.serializer(), + it + ) + } + ) + ) + + tree.containsKey("or") -> GroupSegment.Or( + OrGroupSegment( + tree["or"]!!.jsonArray.map { + input.json.decodeFromJsonElement( + GroupSegment::class.serializer(), + it + ) + } + ) + ) + + tree.containsKey("not") -> GroupSegment.Not( + NotGroupSegment( + tree["not"]!!.jsonArray.map { + input.json.decodeFromJsonElement( + GroupSegment::class.serializer(), + it + ) + } + ) + ) + + else -> throw Exception("Unexpected GroupSegment element content") + } + } + + is JsonPrimitive -> { + val isString = tree.content.none { it in setOf('{', '}', ':', '[', ']') } + if (isString) { + GroupSegment.Plain(tree.content) + } else { + val jsonElement = Json.parseToJsonElement(tree.content) + input.json.decodeFromJsonElement( + GroupSegment::class.serializer(), + jsonElement, + ) + } + } + } + } + + override fun serialize(encoder: Encoder, value: GroupSegment) { + // TODO: Later if needed + } +} + +@OptIn(InternalSerializationApi::class) +@Serializer(forClass = BucketBy::class) +object BucketBySerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildSerialDescriptor("package.BucketBy", PolymorphicKind.SEALED) + + override fun deserialize(decoder: Decoder): BucketBy { + val input = decoder as? JsonDecoder + ?: throw SerializationException("This class can be decoded only by Json format") + return when (val tree = input.decodeJsonElement()) { + is JsonArray -> { + BucketBy.And(tree.map { jsonElement -> + jsonElement.jsonPrimitive.content + }) + } + + is JsonObject -> { + when { + tree.containsKey("or") -> BucketBy.Or(tree["or"]!!.jsonArray.map { it.jsonPrimitive.content }) + tree.containsKey("and") -> BucketBy.And(tree["and"]!!.jsonArray.map { it.jsonPrimitive.content }) + else -> throw Exception("Unexpected BucketBy element content") + } + } + + is JsonPrimitive -> { + val isString = tree.content.none { it in setOf('{', '}', ':', '[', ']') } + if (isString) { + BucketBy.Single(tree.content) + } else { + val jsonElement = Json.parseToJsonElement(tree.content) + input.json.decodeFromJsonElement( + BucketBy::class.serializer(), + jsonElement + ) + } + } + } + } + + override fun serialize(encoder: Encoder, value: BucketBy) { + // TODO: Later if needed + } +} + +@OptIn(InternalSerializationApi::class) +@Serializer(forClass = ConditionValue::class) +object ConditionValueSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildSerialDescriptor("package.ConditionValue", PolymorphicKind.SEALED) + + override fun deserialize(decoder: Decoder): ConditionValue { + val input = decoder as? JsonDecoder + ?: throw SerializationException("This class can be decoded only by Json format") + return when (val tree = input.decodeJsonElement()) { + is JsonPrimitive -> { + tree.intOrNull?.let { + ConditionValue.IntValue(it) + } ?: tree.booleanOrNull?.let { + ConditionValue.BooleanValue(it) + } ?: tree.doubleOrNull?.let { + ConditionValue.DoubleValue(it) + } ?: tree.content.let { + ConditionValue.StringValue(it) + // TODO: +// ConditionValue.DateTimeValue + } + } + + is JsonArray -> { + ConditionValue.ArrayValue(tree.jsonArray.map { jsonElement -> jsonElement.jsonPrimitive.content }) + } + + is JsonObject -> { + throw NotImplementedError("ConditionValue does not support JsonObject") + } + } + } + + override fun serialize(encoder: Encoder, value: ConditionValue) { + // TODO: Later if needed + } +} + +@OptIn(InternalSerializationApi::class) +@Serializer(forClass = VariableValue::class) +object VariableValueSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildSerialDescriptor("package.VariableValue", PolymorphicKind.SEALED) + + override fun deserialize(decoder: Decoder): VariableValue { + val input = decoder as? JsonDecoder + ?: throw SerializationException("This class can be decoded only by Json format") + return when (val tree = input.decodeJsonElement()) { + is JsonPrimitive -> { + if (tree.isString) { + if (isValidJson(tree.content)) { + VariableValue.JsonValue(tree.content) + } else { + VariableValue.StringValue(tree.content) + } + } else { + tree.intOrNull?.let { + VariableValue.IntValue(it) + } ?: tree.booleanOrNull?.let { + VariableValue.BooleanValue(it) + } ?: tree.doubleOrNull?.let { + VariableValue.DoubleValue(it) + } ?: tree.content.let { + VariableValue.StringValue(it) + } + } + } + + is JsonArray -> { + VariableValue.ArrayValue(tree.jsonArray.map { jsonElement -> jsonElement.jsonPrimitive.content }) + } + + is JsonObject -> { + FeaturevisorInstance.companionLogger?.debug("VariableValueSerializer, JsonObject, tree.jsonObject: ${tree.jsonObject}, tree: $tree") + VariableValue.JsonValue(tree.jsonObject.toString()) + } + } + } + + override fun serialize(encoder: Encoder, value: VariableValue) { + // TODO: Later if needed + } +} + +fun isValidJson(jsonString: String): Boolean { + return try { + // Attempt to parse the string + Json.decodeFromString>(jsonString) + true + } catch (e: Exception) { + false + } +} + +private fun mapOperator(value: String): Operator { + return when (value.trim()) { + "equals" -> Operator.EQUALS + "notEquals" -> Operator.NOT_EQUALS + + // numeric + "greaterThan" -> Operator.GREATER_THAN + "greaterThanOrEqual" -> Operator.GREATER_THAN_OR_EQUAL + "lessThan" -> Operator.LESS_THAN + "lessThanOrEqual" -> Operator.LESS_THAN_OR_EQUAL + + // string + "contains" -> Operator.CONTAINS + "notContains" -> Operator.NOT_CONTAINS + "startsWith" -> Operator.STARTS_WITH + "endsWith" -> Operator.ENDS_WITH + + // semver (string) + "semverEquals" -> Operator.SEMVER_EQUALS + "semverNotEquals" -> Operator.SEMVER_NOT_EQUALS + "semverGreaterThan" -> Operator.SEMVER_GREATER_THAN + "semverGreaterThanOrEquals" -> Operator.SEMVER_GREATER_THAN_OR_EQUAL + "semverLessThan" -> Operator.SEMVER_LESS_THAN + "semverLessThanOrEquals" -> Operator.SEMVER_LESS_THAN_OR_EQUAL + + // date comparisons + "before" -> Operator.BEFORE + "after" -> Operator.AFTER + + // array of strings + "in" -> Operator.IN_ARRAY + "notIn" -> Operator.NOT_IN_ARRAY + else -> throw Exception("Unexpected value of operator: $value") + } +} diff --git a/src/main/kotlin/com/featurevisor/types/Types.kt b/src/main/kotlin/com/featurevisor/types/Types.kt index c2150d9..ea1d9f8 100644 --- a/src/main/kotlin/com/featurevisor/types/Types.kt +++ b/src/main/kotlin/com/featurevisor/types/Types.kt @@ -1,23 +1,39 @@ package com.featurevisor.types +import com.featurevisor.sdk.serializers.BucketBySerializer +import com.featurevisor.sdk.serializers.ConditionSerializer +import com.featurevisor.sdk.serializers.ConditionValueSerializer +import com.featurevisor.sdk.serializers.GroupSegmentSerializer +import com.featurevisor.sdk.serializers.VariableValueSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable 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"); +@Serializable +enum class VariableType { + @SerialName("boolean") + BOOLEAN, + @SerialName("string") + STRING, + @SerialName("integer") + INTEGER, + @SerialName("double") + DOUBLE, + @SerialName("array") + ARRAY, + @SerialName("object") + OBJECT, + @SerialName("json") + JSON } typealias VariableObjectValue = Map +@Serializable(with = VariableValueSerializer::class) sealed class VariableValue { data class BooleanValue(val value: Boolean) : VariableValue() data class StringValue(val value: String) : VariableValue() @@ -28,6 +44,7 @@ sealed class VariableValue { data class JsonValue(val value: String) : VariableValue() } +@Serializable data class VariableOverride( val value: VariableValue, @@ -36,12 +53,14 @@ data class VariableOverride( val segments: GroupSegment?, ) +@Serializable data class Variable( val key: VariableKey, val value: VariableValue, val overrides: List?, ) +@Serializable data class Variation( // only available in YAML val description: String?, @@ -54,6 +73,7 @@ data class Variation( val variables: List?, ) +@Serializable data class VariableSchema( val key: VariableKey, val type: VariableType, @@ -64,6 +84,7 @@ typealias FeatureKey = String typealias VariableValues = Map +@Serializable data class Force( // one of the below must be present in YAML val conditions: Condition?, @@ -176,11 +197,12 @@ sealed class Test { typealias AttributeKey = String +@Serializable data class Attribute( val key: AttributeKey, val type: String, - val archived: Boolean?, - val capture: Boolean?, + val archived: Boolean? = null, + val capture: Boolean? = null, ) sealed class AttributeValue { @@ -191,6 +213,7 @@ sealed class AttributeValue { data class DateValue(val value: LocalDate) : AttributeValue() } +@Serializable(with = ConditionSerializer::class) sealed class Condition { data class Plain( val attributeKey: AttributeKey, @@ -203,6 +226,9 @@ sealed class Condition { data class Not(val not: List) : Condition() } +const val TAG = "FeaturevisorService" + +@Serializable(with = ConditionValueSerializer::class) sealed class ConditionValue { data class StringValue(val value: String) : ConditionValue() data class IntValue(val value: Int) : ConditionValue() @@ -214,14 +240,13 @@ sealed class ConditionValue { typealias SegmentKey = String +@Serializable data class Segment( - val archived: Boolean?, + val archived: Boolean? = null, val key: SegmentKey, val conditions: Condition, ) -typealias PlainGroupSegment = SegmentKey - data class AndGroupSegment( val and: List, ) @@ -234,10 +259,10 @@ data class NotGroupSegment( val not: List, ) +@Serializable(with = GroupSegmentSerializer::class) sealed class GroupSegment { - data class Plain(val segment: PlainGroupSegment) : GroupSegment() + data class Plain(val segment: SegmentKey) : 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() @@ -272,8 +297,8 @@ enum class Operator(val value: String) { AFTER("after"), // array of strings - IN_ARRAY("inArray"), - NOT_IN_ARRAY("notInArray"); + IN_ARRAY("in"), + NOT_IN_ARRAY("notIn"); } enum class EventName { @@ -281,49 +306,45 @@ enum class EventName { REFRESH, UPDATE, ACTIVATION, + ERROR, } - /** * Datafile-only types */ // 0 to 100,000 typealias Percentage = Int +@Serializable data class Range( val start: Percentage, val end: Percentage, ) +@Serializable data class Allocation( val variation: VariationValue, val range: Range, ) +@Serializable data class Traffic( val key: RuleKey, val segments: GroupSegment, val percentage: Percentage, - val enabled: Boolean?, - val variation: VariationValue?, - val variables: VariableValues?, + val enabled: Boolean? = null, + val variation: VariationValue? = null, + val variables: VariableValues? = null, val allocation: List, ) -typealias PlainBucketBy = String - -typealias AndBucketBy = List - -data class OrBucketBy( - val or: List, -) - +@Serializable(with = BucketBySerializer::class) 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 Single(val bucketBy: String) : BucketBy() + data class And(val bucketBy: List) : BucketBy() + data class Or(val bucketBy: List) : BucketBy() } data class RequiredWithVariation( @@ -331,25 +352,28 @@ data class RequiredWithVariation( val variation: VariationValue, ) +@Serializable sealed class Required { data class FeatureKey(val required: com.featurevisor.types.FeatureKey) : Required() data class WithVariation(val required: RequiredWithVariation) : Required() } +@Serializable data class Feature( val key: FeatureKey, - val deprecated: Boolean?, - val variablesSchema: List?, - val variations: List?, + val deprecated: Boolean? = null, + val variablesSchema: List? = null, + val variations: List? = null, val bucketBy: BucketBy, - val required: List?, + val required: List? = null, val traffic: List, - val force: List?, + val force: List? = null, // if in a Group (mutex), these are available slot ranges - val ranges: List?, + val ranges: List? = null, ) +@Serializable data class DatafileContent( val schemaVersion: String, val revision: String, @@ -358,8 +382,9 @@ data class DatafileContent( val features: List, ) +@Serializable data class OverrideFeature( val enabled: Boolean, - val variation: VariationValue?, - val variables: VariableValues?, + val variation: VariationValue? = null, + val variables: VariableValues? = null, ) diff --git a/src/test/kotlin/com/featurevisor/sdk/InstanceTest.kt b/src/test/kotlin/com/featurevisor/sdk/InstanceTest.kt index c7590af..d3403e3 100644 --- a/src/test/kotlin/com/featurevisor/sdk/InstanceTest.kt +++ b/src/test/kotlin/com/featurevisor/sdk/InstanceTest.kt @@ -3,31 +3,41 @@ */ 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`() { -// -// } -//} +import com.featurevisor.types.DatafileContent +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test + +class InstanceTest { + + private val systemUnderTest = FeaturevisorInstance.createInstance( + options = InstanceOptions( + bucketKeySeparator = "", + configureBucketKey = null, + configureBucketValue = null, + datafile = DatafileContent( + schemaVersion = "0", + revision = "0", + attributes = listOf(), + segments = listOf(), + features = listOf() + ), + datafileUrl = null, + handleDatafileFetch = null, + initialFeatures = mapOf(), + interceptContext = null, + logger = null, + onActivation = {}, + onReady = {}, + onRefresh = {}, + onUpdate = {}, + refreshInterval = null, + stickyFeatures = mapOf(), + onError = {}, + ) + ) + + @Test + fun `instance initialised properly`() { + systemUnderTest.statuses.ready shouldBe true + } +} diff --git a/src/test/kotlin/com/featurevisor/sdk/serializers/ConditionSerializerTest.kt b/src/test/kotlin/com/featurevisor/sdk/serializers/ConditionSerializerTest.kt new file mode 100644 index 0000000..02fcc40 --- /dev/null +++ b/src/test/kotlin/com/featurevisor/sdk/serializers/ConditionSerializerTest.kt @@ -0,0 +1,115 @@ +package com.featurevisor.sdk.serializers + +import com.featurevisor.types.Condition +import com.featurevisor.types.ConditionValue +import com.featurevisor.types.Operator +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeTypeOf +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test + +class ConditionSerializerTest { + + @Test + fun `decode PLAIN condition`() { + val element = """ + { + "attribute": "version", + "operator": "equals", + "value": "1.2.3" + } + """.trimIndent() + + val condition = Json.decodeFromString(element) + + condition.shouldBeTypeOf() + condition.attributeKey shouldBe "version" + condition.operator shouldBe Operator.EQUALS + condition.value shouldBe ConditionValue.StringValue("1.2.3") + } + + @Test + fun `decode AND condition`() { + val element = """ + { + "and": [{ + "attribute": "version", + "operator": "equals", + "value": "1.2.3" + }, { + "attribute": "age", + "operator": "greaterThanOrEqual", + "value": "18" + }] + } + """.trimIndent() + + val condition = Json.decodeFromString(element) + + condition.shouldBeTypeOf() + (condition.and[0] as Condition.Plain).run { + attributeKey shouldBe "version" + operator shouldBe Operator.EQUALS + value shouldBe ConditionValue.StringValue("1.2.3") + } + (condition.and[1] as Condition.Plain).run { + attributeKey shouldBe "age" + operator shouldBe Operator.GREATER_THAN_OR_EQUAL + value shouldBe ConditionValue.IntValue(18) + } + } + + @Test + fun `decode OR condition`() { + val element = """ + { + "or": [{ + "attribute": "version", + "operator": "equals", + "value": "1.2.3" + }, { + "attribute": "age", + "operator": "greaterThanOrEqual", + "value": "18" + }] + } + """.trimIndent() + + val condition = Json.decodeFromString(element) + + condition.shouldBeTypeOf() + (condition.or[0] as Condition.Plain).run { + attributeKey shouldBe "version" + operator shouldBe Operator.EQUALS + value shouldBe ConditionValue.StringValue("1.2.3") + } + (condition.or[1] as Condition.Plain).run { + attributeKey shouldBe "age" + operator shouldBe Operator.GREATER_THAN_OR_EQUAL + value shouldBe ConditionValue.IntValue(18) + } + } + + @Test + fun `decode NOT condition`() { + val element = """ + { + "not": [{ + "attribute": "version", + "operator": "equals", + "value": "1.2.3" + }] + } + """.trimIndent() + + val condition = Json.decodeFromString(element) + + condition.shouldBeTypeOf() + (condition.not[0] as Condition.Plain).run { + attributeKey shouldBe "version" + operator shouldBe Operator.EQUALS + value shouldBe ConditionValue.StringValue("1.2.3") + } + } +} diff --git a/src/test/kotlin/com/featurevisor/sdk/serializers/ConditionValueTest.kt b/src/test/kotlin/com/featurevisor/sdk/serializers/ConditionValueTest.kt new file mode 100644 index 0000000..9857555 --- /dev/null +++ b/src/test/kotlin/com/featurevisor/sdk/serializers/ConditionValueTest.kt @@ -0,0 +1,72 @@ +package com.featurevisor.sdk.serializers + +import com.featurevisor.types.ConditionValue +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeTypeOf +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test +import kotlinx.serialization.decodeFromString + +class ConditionValueTest { + + @Test + fun `decode int value with correct type`() { + val element = """ + 1 + """.trimIndent() + + val result = Json.decodeFromString(element) + + result.shouldBeTypeOf() + result.value shouldBe 1 + } + + @Test + fun `decode boolean value with correct type`() { + val element = """ + true + """.trimIndent() + + val result = Json.decodeFromString(element) + + result.shouldBeTypeOf() + result.value shouldBe true + } + + @Test + fun `decode double value with correct type`() { + val element = """ + 1.2 + """.trimIndent() + + val result = Json.decodeFromString(element) + + result.shouldBeTypeOf() + result.value shouldBe 1.2 + } + + @Test + fun `decode string value with correct type`() { + val element = """ + test + """.trimIndent() + + val result = Json.decodeFromString(element) + + result.shouldBeTypeOf() + result.value shouldBe "test" + } + + @Test + fun `decode array value with correct type`() { + val element = """ + [ "test1", "test2"] + """.trimIndent() + + val result = Json.decodeFromString(element) + + result.shouldBeTypeOf() + result.values[0] shouldBe "test1" + result.values[1] shouldBe "test2" + } +} diff --git a/src/test/kotlin/com/featurevisor/sdk/serializers/GroupSegmentTest.kt b/src/test/kotlin/com/featurevisor/sdk/serializers/GroupSegmentTest.kt new file mode 100644 index 0000000..486d24d --- /dev/null +++ b/src/test/kotlin/com/featurevisor/sdk/serializers/GroupSegmentTest.kt @@ -0,0 +1,68 @@ +package com.featurevisor.sdk.serializers + +import com.featurevisor.types.GroupSegment +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeTypeOf +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test + +class GroupSegmentTest { + + @Test + fun `decode PLAIN segment`() { + val element = """ + testSegment + """.trimIndent() + + val groupSegment = Json.decodeFromString(element) + + groupSegment.shouldBeTypeOf() + groupSegment.segment shouldBe "testSegment" + } + + @Test + fun `decode AND group segment`() { + val element = """ + { + "and": ["testSegment1", "testSegment2"] + } + """.trimIndent() + + val groupSegment = Json.decodeFromString(element) + + groupSegment.shouldBeTypeOf() + groupSegment.segment.and[0] shouldBe GroupSegment.Plain("testSegment1") + groupSegment.segment.and[1] shouldBe GroupSegment.Plain("testSegment2") + } + + @Test + fun `decode OR group segment`() { + val element = """ + { + "or": ["testSegment1", "testSegment2"] + } + """.trimIndent() + + val groupSegment = Json.decodeFromString(element) + + groupSegment.shouldBeTypeOf() + groupSegment.segment.or[0] shouldBe GroupSegment.Plain("testSegment1") + groupSegment.segment.or[1] shouldBe GroupSegment.Plain("testSegment2") + } + + @Test + fun `decode NOT group segment`() { + val element = """ + { + "not": ["testSegment1", "testSegment2"] + } + """.trimIndent() + + val groupSegment = Json.decodeFromString(element) + + groupSegment.shouldBeTypeOf() + groupSegment.segment.not[0] shouldBe GroupSegment.Plain("testSegment1") + groupSegment.segment.not[1] shouldBe GroupSegment.Plain("testSegment2") + } +}