diff --git a/.gitignore b/.gitignore index a6005d0..e05f5d6 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ build local.properties .idea +FlagsmithClient/cache diff --git a/FlagsmithClient/build.gradle.kts b/FlagsmithClient/build.gradle.kts index 4252ec7..557976d 100644 --- a/FlagsmithClient/build.gradle.kts +++ b/FlagsmithClient/build.gradle.kts @@ -68,12 +68,17 @@ android { dependencies { implementation("com.google.code.gson:gson:2.10") - implementation("com.github.kittinunf.fuel:fuel:2.3.1") - implementation("com.github.kittinunf.fuel:fuel-gson:2.3.1") + + // HTTP Client + implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("com.squareup.retrofit2:converter-gson:2.9.0") testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") testImplementation("org.mock-server:mockserver-netty-no-dependencies:5.14.0") + + testImplementation("org.awaitility:awaitility-kotlin:4.2.0") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.0.0") } kover { diff --git a/FlagsmithClient/src/main/java/com/flagsmith/Flagsmith.kt b/FlagsmithClient/src/main/java/com/flagsmith/Flagsmith.kt index 961a792..2f587b8 100644 --- a/FlagsmithClient/src/main/java/com/flagsmith/Flagsmith.kt +++ b/FlagsmithClient/src/main/java/com/flagsmith/Flagsmith.kt @@ -1,15 +1,10 @@ package com.flagsmith import android.content.Context -import com.flagsmith.endpoints.FlagsEndpoint -import com.flagsmith.endpoints.IdentityFlagsAndTraitsEndpoint -import com.flagsmith.endpoints.TraitsEndpoint -import com.flagsmith.entities.Flag -import com.flagsmith.entities.IdentityFlagsAndTraits -import com.flagsmith.entities.Trait -import com.flagsmith.entities.TraitWithIdentity +import com.flagsmith.entities.* import com.flagsmith.internal.FlagsmithAnalytics -import com.flagsmith.internal.FlagsmithClient +import com.flagsmith.internal.FlagsmithRetrofitService +import com.flagsmith.internal.enqueueWithResult /** * Flagsmith @@ -28,14 +23,27 @@ class Flagsmith constructor( private val baseUrl: String = "https://edge.api.flagsmith.com/api/v1", private val context: Context? = null, private val enableAnalytics: Boolean = DEFAULT_ENABLE_ANALYTICS, - private val analyticsFlushPeriod: Int = DEFAULT_ANALYTICS_FLUSH_PERIOD_SECONDS + private val analyticsFlushPeriod: Int = DEFAULT_ANALYTICS_FLUSH_PERIOD_SECONDS, + private val cacheConfig: FlagsmithCacheConfig = FlagsmithCacheConfig(), + private val defaultFlags: List = emptyList(), + private val requestTimeoutSeconds: Long = 4L, + private val readTimeoutSeconds: Long = 6L, + private val writeTimeoutSeconds: Long = 6L ) { - private val client: FlagsmithClient = FlagsmithClient(baseUrl, environmentKey) + private val retrofit: FlagsmithRetrofitService = FlagsmithRetrofitService.create( + baseUrl = baseUrl, environmentKey = environmentKey, context = context, cacheConfig = cacheConfig, + requestTimeoutSeconds = requestTimeoutSeconds, readTimeoutSeconds = readTimeoutSeconds, writeTimeoutSeconds = writeTimeoutSeconds) private val analytics: FlagsmithAnalytics? = if (!enableAnalytics) null - else if (context != null) FlagsmithAnalytics(context, client, analyticsFlushPeriod) + else if (context != null) FlagsmithAnalytics(context, retrofit, analyticsFlushPeriod) else throw IllegalArgumentException("Flagsmith requires a context to use the analytics feature") + init { + if (cacheConfig.enableCache && context == null) { + throw IllegalArgumentException("Flagsmith requires a context to use the cache feature") + } + } + companion object { const val DEFAULT_ENABLE_ANALYTICS = true const val DEFAULT_ANALYTICS_FLUSH_PERIOD_SECONDS = 10 @@ -43,11 +51,11 @@ class Flagsmith constructor( fun getFeatureFlags(identity: String? = null, result: (Result>) -> Unit) { if (identity != null) { - getIdentityFlagsAndTraits(identity) { res -> + retrofit.getIdentityFlagsAndTraits(identity).enqueueWithResult { res -> result(res.map { it.flags }) } } else { - client.request(FlagsEndpoint, result) + retrofit.getFlags().enqueueWithResult(defaults = defaultFlags, result = result) } } @@ -68,20 +76,20 @@ class Flagsmith constructor( } fun getTrait(id: String, identity: String, result: (Result) -> Unit) = - getIdentityFlagsAndTraits(identity) { res -> + retrofit.getIdentityFlagsAndTraits(identity).enqueueWithResult { res -> result(res.map { value -> value.traits.find { it.key == id } }) } fun getTraits(identity: String, result: (Result>) -> Unit) = - getIdentityFlagsAndTraits(identity) { res -> + retrofit.getIdentityFlagsAndTraits(identity).enqueueWithResult { res -> result(res.map { it.traits }) } fun setTrait(trait: Trait, identity: String, result: (Result) -> Unit) = - client.request(TraitsEndpoint(trait = trait, identity = identity), result) + retrofit.postTraits(TraitWithIdentity(trait.key, trait.value, Identity(identity))).enqueueWithResult(result = result) fun getIdentity(identity: String, result: (Result) -> Unit) = - getIdentityFlagsAndTraits(identity, result) + retrofit.getIdentityFlagsAndTraits(identity).enqueueWithResult(defaults = null, result = result) private fun getFeatureFlag( featureId: String, @@ -95,8 +103,4 @@ class Flagsmith constructor( }) } - private fun getIdentityFlagsAndTraits( - identity: String, - result: (Result) -> Unit - ) = client.request(IdentityFlagsAndTraitsEndpoint(identity = identity), result) } \ No newline at end of file diff --git a/FlagsmithClient/src/main/java/com/flagsmith/FlagsmithCacheConfig.kt b/FlagsmithClient/src/main/java/com/flagsmith/FlagsmithCacheConfig.kt new file mode 100644 index 0000000..d385493 --- /dev/null +++ b/FlagsmithClient/src/main/java/com/flagsmith/FlagsmithCacheConfig.kt @@ -0,0 +1,7 @@ +package com.flagsmith + +data class FlagsmithCacheConfig ( + val enableCache: Boolean = false, + val cacheTTLSeconds: Long = 3600L, // Default to 1 hour + val cacheSize: Long = 10L * 1024L * 1024L, // 10 MB +) diff --git a/FlagsmithClient/src/main/java/com/flagsmith/endpoints/FlagsEndpoint.kt b/FlagsmithClient/src/main/java/com/flagsmith/endpoints/FlagsEndpoint.kt deleted file mode 100644 index c1b50cb..0000000 --- a/FlagsmithClient/src/main/java/com/flagsmith/endpoints/FlagsEndpoint.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.flagsmith.endpoints - -import com.flagsmith.entities.Flag -import com.flagsmith.entities.FlagListDeserializer - -object FlagsEndpoint : GetEndpoint>( - path = "/flags/", - deserializer = FlagListDeserializer() -) \ No newline at end of file diff --git a/FlagsmithClient/src/main/java/com/flagsmith/entities/Flag.kt b/FlagsmithClient/src/main/java/com/flagsmith/entities/Flag.kt index bf05126..5cb48d8 100644 --- a/FlagsmithClient/src/main/java/com/flagsmith/entities/Flag.kt +++ b/FlagsmithClient/src/main/java/com/flagsmith/entities/Flag.kt @@ -1,17 +1,6 @@ package com.flagsmith.entities -import com.flagsmith.internal.Deserializer -import com.flagsmith.internal.fromJson import com.google.gson.annotations.SerializedName -import com.google.gson.reflect.TypeToken -import java.io.Reader - -class FlagListDeserializer: Deserializer> { - override fun deserialize(reader: Reader): List? { - val type = object : TypeToken>() {}.type - return reader.fromJson?>(type = type) - } -} data class Flag( val feature: Feature, @@ -27,4 +16,4 @@ data class Feature( @SerializedName(value = "initial_value") val initialValue: String, @SerializedName(value = "default_enabled") val defaultEnabled: Boolean, val type: String -) \ No newline at end of file +) diff --git a/FlagsmithClient/src/main/java/com/flagsmith/entities/IdentityFlagsAndTraits.kt b/FlagsmithClient/src/main/java/com/flagsmith/entities/IdentityFlagsAndTraits.kt index 77a52ee..365ccdf 100644 --- a/FlagsmithClient/src/main/java/com/flagsmith/entities/IdentityFlagsAndTraits.kt +++ b/FlagsmithClient/src/main/java/com/flagsmith/entities/IdentityFlagsAndTraits.kt @@ -1,14 +1,5 @@ package com.flagsmith.entities -import com.flagsmith.internal.Deserializer -import com.flagsmith.internal.fromJson -import java.io.Reader - -class IdentityFlagsAndTraitsDeserializer: Deserializer { - override fun deserialize(reader: Reader): IdentityFlagsAndTraits? = - reader.fromJson(IdentityFlagsAndTraits::class.java) -} - data class IdentityFlagsAndTraits( val flags: ArrayList, val traits: ArrayList diff --git a/FlagsmithClient/src/main/java/com/flagsmith/entities/Trait.kt b/FlagsmithClient/src/main/java/com/flagsmith/entities/Trait.kt index df7bbcd..caf1902 100644 --- a/FlagsmithClient/src/main/java/com/flagsmith/entities/Trait.kt +++ b/FlagsmithClient/src/main/java/com/flagsmith/entities/Trait.kt @@ -1,15 +1,9 @@ package com.flagsmith.entities -import com.flagsmith.internal.Deserializer -import com.flagsmith.internal.fromJson + import com.google.gson.annotations.SerializedName import java.io.Reader -class TraitWithIdentityDeserializer: Deserializer { - override fun deserialize(reader: Reader): TraitWithIdentity? = - reader.fromJson(TraitWithIdentity::class.java) -} - data class Trait( val identifier: String? = null, @SerializedName(value = "trait_key") val key: String, diff --git a/FlagsmithClient/src/main/java/com/flagsmith/internal/Deserializer.kt b/FlagsmithClient/src/main/java/com/flagsmith/internal/Deserializer.kt deleted file mode 100644 index 1e72d24..0000000 --- a/FlagsmithClient/src/main/java/com/flagsmith/internal/Deserializer.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.flagsmith.internal - -import com.github.kittinunf.fuel.core.ResponseDeserializable -import com.google.gson.Gson -import java.io.Reader -import java.lang.reflect.Type - -interface Deserializer : ResponseDeserializable -object EmptyDeserializer : Deserializer { - override fun deserialize(reader: Reader) = Unit -} - -fun Reader.fromJson(classType: Class): T? = - Gson().fromJson(this, classType) - -fun Reader.fromJson(type: Type): T? = - Gson().fromJson(this, type) \ No newline at end of file diff --git a/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithAnalytics.kt b/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithAnalytics.kt index 78ae732..53d7f2d 100644 --- a/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithAnalytics.kt +++ b/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithAnalytics.kt @@ -5,13 +5,12 @@ import android.content.SharedPreferences import android.os.Handler import android.os.Looper import android.util.Log -import com.flagsmith.endpoints.AnalyticsEndpoint import org.json.JSONException import org.json.JSONObject class FlagsmithAnalytics constructor( private val context: Context, - private val client: FlagsmithClient, + private val retrofitService: FlagsmithRetrofitService, private val flushPeriod: Int ) { private val applicationContext: Context = context.applicationContext @@ -21,9 +20,8 @@ class FlagsmithAnalytics constructor( private val timerRunnable = object : Runnable { override fun run() { if (currentEvents.isNotEmpty()) { - client.request(AnalyticsEndpoint(eventMap = currentEvents)) { - it - .onSuccess { resetMap() } + retrofitService.postAnalytics(currentEvents).enqueueWithResult { result -> + result.onSuccess { resetMap() } .onFailure { err -> Log.e( "FLAGSMITH", diff --git a/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithClient.kt b/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithClient.kt deleted file mode 100644 index 619e0ff..0000000 --- a/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithClient.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.flagsmith.internal - -import com.flagsmith.endpoints.Endpoint -import com.flagsmith.endpoints.GetEndpoint -import com.flagsmith.endpoints.PostEndpoint -import com.github.kittinunf.fuel.Fuel -import com.github.kittinunf.fuel.core.* -import com.github.kittinunf.fuel.util.FuelRouting -import com.github.kittinunf.result.Result as FuelResult - -class FlagsmithClient( - private val baseUrl: String, - environmentKey: String -) { - val defaultHeaders = mapOf( - "X-Environment-Key" to listOf(environmentKey), - ) - - fun request(endpoint: Endpoint, handler: (Result) -> Unit) = - Fuel.request(createRequest(endpoint)) - .responseObject(endpoint.deserializer) { _, _, res -> - handler(convertToKotlinResult(res)) - } - - private fun createRequest(endpoint: Endpoint): FuelRouting { - return object : FuelRouting { - override val basePath = baseUrl - override val body: String? = endpoint.body - override val bytes: ByteArray? = null - override val headers: Map = defaultHeaders + endpoint.headers - override val method: Method = when (endpoint) { - is GetEndpoint -> Method.GET - is PostEndpoint -> Method.POST - } - override val params: Parameters? = endpoint.params - override val path: String = endpoint.path - } - } - - private fun convertToKotlinResult(result: FuelResult): Result = - result.fold( - success = { value -> Result.success(value) }, - failure = { err -> Result.failure(err) } - ) -} \ No newline at end of file diff --git a/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithRetrofitService.kt b/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithRetrofitService.kt new file mode 100644 index 0000000..e5e7cda --- /dev/null +++ b/FlagsmithClient/src/main/java/com/flagsmith/internal/FlagsmithRetrofitService.kt @@ -0,0 +1,104 @@ +package com.flagsmith.internal; + +import android.content.Context +import android.util.Log +import com.flagsmith.FlagsmithCacheConfig +import com.flagsmith.entities.Flag +import com.flagsmith.entities.IdentityFlagsAndTraits +import com.flagsmith.entities.TraitWithIdentity +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import retrofit2.* +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query + +interface FlagsmithRetrofitService { + + @GET("identities/") + fun getIdentityFlagsAndTraits(@Query("identity") identity: String) : Call + + @GET("flags/") + fun getFlags() : Call> + + @POST("traits/") + fun postTraits(@Body trait: TraitWithIdentity) : Call + + @POST("analytics/flags/") + fun postAnalytics(@Body eventMap: Map) : Call + + companion object { + fun create( + baseUrl: String, + environmentKey: String, + context: Context?, + cacheConfig: FlagsmithCacheConfig, + requestTimeoutSeconds: Long, + readTimeoutSeconds: Long, + writeTimeoutSeconds: Long, + ): FlagsmithRetrofitService { + fun cacheControlInterceptor(): Interceptor { + return Interceptor { chain -> + val response = chain.proceed(chain.request()) + response.newBuilder() + .header("Cache-Control", "public, max-age=${cacheConfig.cacheTTLSeconds}") + .removeHeader("Pragma") + .build() + } + } + + fun envKeyInterceptor(environmentKey: String): Interceptor { + return Interceptor { chain -> + val request = chain.request().newBuilder() + .addHeader("X-environment-key", environmentKey) + .build() + chain.proceed(request) + } + } + + val client = OkHttpClient.Builder() + .addInterceptor(envKeyInterceptor(environmentKey)) + .let { if (cacheConfig.enableCache) it.addNetworkInterceptor(cacheControlInterceptor()) else it } + .callTimeout(requestTimeoutSeconds, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(readTimeoutSeconds, java.util.concurrent.TimeUnit.SECONDS) + .writeTimeout(writeTimeoutSeconds, java.util.concurrent.TimeUnit.SECONDS) + .cache(if (context != null && cacheConfig.enableCache) okhttp3.Cache(context.cacheDir, cacheConfig.cacheSize) else null) + .build() + + val retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .addConverterFactory(GsonConverterFactory.create()) + .client(client) + .build() + + return retrofit.create(FlagsmithRetrofitService::class.java) + } + } +} + +// Convert a Retrofit Call to a standard Kotlin Result by extending the Call class +// This avoids having to use the suspend keyword in the FlagsmithClient to break the API +// And also avoids a lot of code duplication +fun Call.enqueueWithResult(defaults: T? = null, result: (Result) -> Unit) { + this.enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful && response.body() != null) { + result(Result.success(response.body()!!)) + } else { + onFailure(call, HttpException(response)) + } + } + + override fun onFailure(call: Call, t: Throwable) { + // If we've got defaults to return, return them + if (defaults != null) { + result(Result.success(defaults)) + } else { + result(Result.failure(t)) + } + } + }) +} + diff --git a/FlagsmithClient/src/test/java/com/flagsmith/FeatureFlagCachingTests.kt b/FlagsmithClient/src/test/java/com/flagsmith/FeatureFlagCachingTests.kt new file mode 100644 index 0000000..c340947 --- /dev/null +++ b/FlagsmithClient/src/test/java/com/flagsmith/FeatureFlagCachingTests.kt @@ -0,0 +1,292 @@ +package com.flagsmith + +import android.content.Context +import android.content.SharedPreferences +import android.content.res.Resources +import android.graphics.Color +import android.util.Log +import com.flagsmith.entities.Feature +import com.flagsmith.entities.Flag +import com.flagsmith.mockResponses.* +import org.awaitility.Awaitility +import org.awaitility.kotlin.await +import org.awaitility.kotlin.untilNotNull +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockserver.integration.ClientAndServer +import java.io.File +import java.time.Duration + + +class FeatureFlagCachingTests { + private lateinit var mockServer: ClientAndServer + private lateinit var flagsmithWithCache: Flagsmith + private lateinit var flagsmithNoCache: Flagsmith + + @Mock + private lateinit var mockApplicationContext: Context + + @Mock + private lateinit var mockContextResources: Resources + + @Mock + private lateinit var mockSharedPreferences: SharedPreferences + + @Before + fun setup() { + mockServer = ClientAndServer.startClientAndServer() + System.setProperty("mockserver.logLevel", "INFO") + Awaitility.setDefaultTimeout(Duration.ofSeconds(30)); + setupMocks() + val defaultFlags = listOf( + Flag( + feature = Feature( + id = 345345L, + name = "Flag 1", + createdDate = "2023‐07‐07T09:07:16Z", + description = "Flag 1 description", + type = "CONFIG", + defaultEnabled = true, + initialValue = "true" + ), enabled = true, featureStateValue = "Vanilla Ice" + ), + Flag( + feature = Feature( + id = 34345L, + name = "Flag 2", + createdDate = "2023‐07‐07T09:07:16Z", + description = "Flag 2 description", + type = "CONFIG", + defaultEnabled = true, + initialValue = "true" + ), enabled = true, featureStateValue = "value2" + ), + ) + + flagsmithWithCache = Flagsmith( + environmentKey = "", + baseUrl = "http://localhost:${mockServer.localPort}", + enableAnalytics = false, + context = mockApplicationContext, + defaultFlags = defaultFlags, + cacheConfig = FlagsmithCacheConfig(enableCache = true) + ) + + flagsmithNoCache = Flagsmith( + environmentKey = "", + baseUrl = "http://localhost:${mockServer.localPort}", + enableAnalytics = false, + cacheConfig = FlagsmithCacheConfig(enableCache = false), + defaultFlags = defaultFlags + ) + } + + private fun setupMocks() { + MockitoAnnotations.initMocks(this) + + `when`(mockApplicationContext.getResources()).thenReturn(mockContextResources) + `when`(mockApplicationContext.getSharedPreferences(anyString(), anyInt())).thenReturn( + mockSharedPreferences + ) + `when`(mockApplicationContext.cacheDir).thenReturn(File("cache")) + + `when`(mockContextResources.getString(anyInt())).thenReturn("mocked string") + `when`(mockContextResources.getStringArray(anyInt())).thenReturn( + arrayOf( + "mocked string 1", + "mocked string 2" + ) + ) + `when`(mockContextResources.getColor(anyInt())).thenReturn(Color.BLACK) + `when`(mockContextResources.getBoolean(anyInt())).thenReturn(false) + `when`(mockContextResources.getDimension(anyInt())).thenReturn(100f) + `when`(mockContextResources.getIntArray(anyInt())).thenReturn(intArrayOf(1, 2, 3)) + } + + @After + fun tearDown() { + mockServer.stop() + } + + @Test + fun testThrowsExceptionWhenEnableCachingWithoutAContext() { + val exception = Assert.assertThrows(IllegalArgumentException::class.java) { + val flagsmith = Flagsmith( + environmentKey = "", + baseUrl = "http://localhost:${mockServer.localPort}", + enableAnalytics = false, + cacheConfig = FlagsmithCacheConfig(enableCache = true), + ) + } + Assert.assertEquals( + "Flagsmith requires a context to use the cache feature", + exception.message + ) + } + + @Test + fun testGetFeatureFlagsWithIdentityUsesCachedResponseOnSecondRequestFailure() { + mockServer.mockResponseFor(MockEndpoint.GET_IDENTITIES) + mockServer.mockFailureFor(MockEndpoint.GET_IDENTITIES) + + // First time around we should be successful and cache the response + var foundFromServer: Flag? = null + flagsmithWithCache.getFeatureFlags(identity = "person") { result -> + Assert.assertTrue(result.isSuccess) + + foundFromServer = + result.getOrThrow().find { flag -> flag.feature.name == "with-value" } + } + + await untilNotNull { foundFromServer } + Assert.assertNotNull(foundFromServer) + Assert.assertEquals(756.0, foundFromServer?.featureStateValue) + + // Now we mock the failure and expect the cached response to be returned + var foundFromCache: Flag? = null + flagsmithWithCache.getFeatureFlags(identity = "person") { result -> + Assert.assertTrue(result.isSuccess) + + foundFromCache = + result.getOrThrow().find { flag -> flag.feature.name == "with-value" } + } + + await untilNotNull { foundFromCache } + Assert.assertNotNull(foundFromCache) + Assert.assertEquals(756.0, foundFromCache?.featureStateValue) + } + + @Test + fun testGetFeatureFlagsWithIdentityUsesCachedResponseOnSecondRequestTimeout() { + mockServer.mockResponseFor(MockEndpoint.GET_IDENTITIES) + mockServer.mockDelayFor(MockEndpoint.GET_IDENTITIES) + + // First time around we should be successful and cache the response + var foundFromServer: Flag? = null + flagsmithWithCache.getFeatureFlags(identity = "person") { result -> + Assert.assertTrue(result.isSuccess) + + foundFromServer = + result.getOrThrow().find { flag -> flag.feature.name == "with-value" } + Assert.assertNotNull(foundFromServer) + Assert.assertEquals(756.0, foundFromServer?.featureStateValue) + } + + await untilNotNull { foundFromServer } + + // Now we mock the failure and expect the cached response to be returned + var foundFromCache: Flag? = null + flagsmithWithCache.getFeatureFlags(identity = "person") { result -> + Assert.assertTrue(result.isSuccess) + + foundFromCache = + result.getOrThrow().find { flag -> flag.feature.name == "with-value" } + } + + await untilNotNull { foundFromCache } + Assert.assertNotNull(foundFromCache) + Assert.assertEquals(756.0, foundFromCache?.featureStateValue) + } + + @Test + fun testGetFeatureFlagsNoIdentityUsesCachedResponseOnSecondRequestFailure() { + mockServer.mockResponseFor(MockEndpoint.GET_FLAGS) + mockServer.mockFailureFor(MockEndpoint.GET_FLAGS) + + // First time around we should be successful and cache the response + var foundFromServer: Flag? = null + flagsmithWithCache.getFeatureFlags() { result -> + Assert.assertTrue(result.isSuccess) + + foundFromServer = + result.getOrThrow().find { flag -> flag.feature.name == "with-value" } + } + + await untilNotNull { foundFromServer } + Assert.assertNotNull(foundFromServer) + Assert.assertEquals(7.0, foundFromServer?.featureStateValue) + + // Now we mock the failure and expect the cached response to be returned + var foundFromCache: Flag? = null + flagsmithWithCache.getFeatureFlags() { result -> + Assert.assertTrue(result.isSuccess) + + foundFromCache = + result.getOrThrow().find { flag -> flag.feature.name == "with-value" } + } + + await untilNotNull { foundFromCache } + Assert.assertNotNull(foundFromCache) + Assert.assertEquals(7.0, foundFromCache?.featureStateValue) + } + + @Test + fun testGetFlagsWithFailingRequestShouldGetDefaults() { + mockServer.mockFailureFor(MockEndpoint.GET_FLAGS) + mockServer.mockResponseFor(MockEndpoint.GET_FLAGS) + + // First time around we should fail and fall back to the defaults + var foundFromCache: Flag? = null + flagsmithWithCache.getFeatureFlags() { result -> + Assert.assertTrue(result.isSuccess) + + foundFromCache = + result.getOrThrow().find { flag -> flag.feature.name == "Flag 1" } + } + + await untilNotNull { foundFromCache } + Assert.assertNotNull(foundFromCache) + + // Now we mock the server and expect the server response to be returned + var foundFromServer: Flag? = null + flagsmithWithCache.getFeatureFlags() { result -> + Assert.assertTrue(result.isSuccess) + + foundFromServer = + result.getOrThrow().find { flag -> flag.feature.name == "with-value" } + } + + await untilNotNull { foundFromServer } + Assert.assertNotNull(foundFromServer) + Assert.assertEquals(7.0, foundFromServer?.featureStateValue) + } + + @Test + fun testGetFlagsWithTimeoutRequestShouldGetDefaults() { + mockServer.mockDelayFor(MockEndpoint.GET_FLAGS) + mockServer.mockResponseFor(MockEndpoint.GET_FLAGS) + + // First time around we should get the default flag values + var foundFromCache: Flag? = null + flagsmithWithCache.getFeatureFlags() { result -> + Assert.assertTrue(result.isSuccess) + + foundFromCache = + result.getOrThrow().find { flag -> flag.feature.name == "Flag 1" } + } + + await untilNotNull { foundFromCache } + Assert.assertNotNull(foundFromCache) + Assert.assertEquals("Vanilla Ice", foundFromCache?.featureStateValue) + + // Now we mock the successful request and expect the server values + var foundFromServer: Flag? = null + flagsmithWithCache.getFeatureFlags() { result -> + Assert.assertTrue(result.isSuccess) + + foundFromServer = + result.getOrThrow().find { flag -> flag.feature.name == "with-value" } + } + + await untilNotNull { foundFromServer } + Assert.assertNotNull(foundFromServer) + Assert.assertEquals(7.0, foundFromServer?.featureStateValue) + } +} \ No newline at end of file diff --git a/FlagsmithClient/src/test/java/com/flagsmith/FeatureFlagTests.kt b/FlagsmithClient/src/test/java/com/flagsmith/FeatureFlagTests.kt index e044c85..f950ac4 100644 --- a/FlagsmithClient/src/test/java/com/flagsmith/FeatureFlagTests.kt +++ b/FlagsmithClient/src/test/java/com/flagsmith/FeatureFlagTests.kt @@ -25,7 +25,8 @@ class FeatureFlagTests { flagsmith = Flagsmith( environmentKey = "", baseUrl = "http://localhost:${mockServer.localPort}", - enableAnalytics = false + enableAnalytics = false, + cacheConfig = FlagsmithCacheConfig(enableCache = false) ) } diff --git a/FlagsmithClient/src/test/java/com/flagsmith/TraitsTests.kt b/FlagsmithClient/src/test/java/com/flagsmith/TraitsTests.kt index 73f0c16..d586c85 100644 --- a/FlagsmithClient/src/test/java/com/flagsmith/TraitsTests.kt +++ b/FlagsmithClient/src/test/java/com/flagsmith/TraitsTests.kt @@ -23,7 +23,8 @@ class TraitsTests { flagsmith = Flagsmith( environmentKey = "", baseUrl = "http://localhost:${mockServer.localPort}", - enableAnalytics = false + enableAnalytics = false, + cacheConfig = FlagsmithCacheConfig(enableCache = false) ) } diff --git a/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/MockResponses.kt b/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/MockResponses.kt index a6f0ea0..c9f4d28 100644 --- a/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/MockResponses.kt +++ b/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/MockResponses.kt @@ -1,13 +1,16 @@ package com.flagsmith.mockResponses -import com.flagsmith.endpoints.FlagsEndpoint -import com.flagsmith.endpoints.IdentityFlagsAndTraitsEndpoint -import com.flagsmith.endpoints.TraitsEndpoint +import com.flagsmith.mockResponses.endpoints.FlagsEndpoint +import com.flagsmith.mockResponses.endpoints.IdentityFlagsAndTraitsEndpoint +import com.flagsmith.mockResponses.endpoints.TraitsEndpoint import com.flagsmith.entities.Trait import org.mockserver.integration.ClientAndServer +import org.mockserver.matchers.Times +import org.mockserver.model.HttpError import org.mockserver.model.HttpRequest.request import org.mockserver.model.HttpResponse.response import org.mockserver.model.MediaType +import java.util.concurrent.TimeUnit enum class MockEndpoint(val path: String, val body: String) { GET_IDENTITIES(IdentityFlagsAndTraitsEndpoint("").path, MockResponses.getIdentities), @@ -16,7 +19,7 @@ enum class MockEndpoint(val path: String, val body: String) { } fun ClientAndServer.mockResponseFor(endpoint: MockEndpoint) { - `when`(request().withPath(endpoint.path)) + `when`(request().withPath(endpoint.path), Times.once()) .respond( response() .withContentType(MediaType.APPLICATION_JSON) @@ -24,6 +27,36 @@ fun ClientAndServer.mockResponseFor(endpoint: MockEndpoint) { ) } +fun ClientAndServer.mockDelayFor(endpoint: MockEndpoint) { + `when`(request().withPath(endpoint.path), Times.once()) + .respond( + response() + .withContentType(MediaType.APPLICATION_JSON) + .withBody(endpoint.body) + .withDelay(TimeUnit.SECONDS, 8) // REQUEST_TIMEOUT_SECONDS is 4 in the client, so needs to be more + ) +} + +fun ClientAndServer.mockFailureFor(endpoint: MockEndpoint) { + `when`(request().withPath(endpoint.path), Times.once()) + .respond( + response() + .withStatusCode(500) + .withContentType(MediaType.APPLICATION_JSON) + .withBody("{error: \"Internal Server Error\"}") + ) + Times.once() +} + +fun ClientAndServer.mockDropConnection(endpoint: MockEndpoint) { + `when`(request().withPath(endpoint.path), Times.once()) + .error( + HttpError.error() + .withDropConnection(true) + ) + +} + object MockResponses { val getIdentities = """ { diff --git a/FlagsmithClient/src/main/java/com/flagsmith/endpoints/AnalyticsEndpoint.kt b/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/endpoints/AnalyticsEndpoint.kt similarity index 63% rename from FlagsmithClient/src/main/java/com/flagsmith/endpoints/AnalyticsEndpoint.kt rename to FlagsmithClient/src/test/java/com/flagsmith/mockResponses/endpoints/AnalyticsEndpoint.kt index 5e3438b..d81b1ba 100644 --- a/FlagsmithClient/src/main/java/com/flagsmith/endpoints/AnalyticsEndpoint.kt +++ b/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/endpoints/AnalyticsEndpoint.kt @@ -1,11 +1,9 @@ -package com.flagsmith.endpoints +package com.flagsmith.mockResponses.endpoints -import com.flagsmith.internal.EmptyDeserializer import com.google.gson.Gson data class AnalyticsEndpoint(private val eventMap: Map) : PostEndpoint( path = "/analytics/flags/", body = Gson().toJson(eventMap), - deserializer = EmptyDeserializer ) \ No newline at end of file diff --git a/FlagsmithClient/src/main/java/com/flagsmith/endpoints/Endpoint.kt b/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/endpoints/Endpoint.kt similarity index 78% rename from FlagsmithClient/src/main/java/com/flagsmith/endpoints/Endpoint.kt rename to FlagsmithClient/src/test/java/com/flagsmith/mockResponses/endpoints/Endpoint.kt index 196a73c..42e256f 100644 --- a/FlagsmithClient/src/main/java/com/flagsmith/endpoints/Endpoint.kt +++ b/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/endpoints/Endpoint.kt @@ -1,20 +1,18 @@ -package com.flagsmith.endpoints +package com.flagsmith.mockResponses.endpoints -import com.flagsmith.internal.Deserializer +//import com.flagsmith.internal.Deserializer sealed interface Endpoint { val body: String? val path: String val params: List>? val headers: Map> - val deserializer: Deserializer } sealed class GetEndpoint( final override val path: String, final override val params: List> = emptyList(), final override val headers: Map> = emptyMap(), - final override val deserializer: Deserializer ) : Endpoint { final override val body: String? = null } @@ -24,7 +22,6 @@ sealed class PostEndpoint( final override val body: String, final override val params: List> = emptyList(), headers: Map> = emptyMap(), - final override val deserializer: Deserializer ) : Endpoint { final override val headers: Map> = headers + mapOf("Content-Type" to listOf("application/json")) diff --git a/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/endpoints/FlagsEndpoint.kt b/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/endpoints/FlagsEndpoint.kt new file mode 100644 index 0000000..bac89e9 --- /dev/null +++ b/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/endpoints/FlagsEndpoint.kt @@ -0,0 +1,7 @@ +package com.flagsmith.mockResponses.endpoints + +import com.flagsmith.entities.Flag + +object FlagsEndpoint : GetEndpoint>( + path = "/flags/", +) \ No newline at end of file diff --git a/FlagsmithClient/src/main/java/com/flagsmith/endpoints/IdentityFlagsAndTraitsEndpoint.kt b/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/endpoints/IdentityFlagsAndTraitsEndpoint.kt similarity index 62% rename from FlagsmithClient/src/main/java/com/flagsmith/endpoints/IdentityFlagsAndTraitsEndpoint.kt rename to FlagsmithClient/src/test/java/com/flagsmith/mockResponses/endpoints/IdentityFlagsAndTraitsEndpoint.kt index 638adc7..ce62a5c 100644 --- a/FlagsmithClient/src/main/java/com/flagsmith/endpoints/IdentityFlagsAndTraitsEndpoint.kt +++ b/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/endpoints/IdentityFlagsAndTraitsEndpoint.kt @@ -1,11 +1,9 @@ -package com.flagsmith.endpoints +package com.flagsmith.mockResponses.endpoints import com.flagsmith.entities.IdentityFlagsAndTraits -import com.flagsmith.entities.IdentityFlagsAndTraitsDeserializer data class IdentityFlagsAndTraitsEndpoint(private val identity: String) : GetEndpoint( path = "/identities/", params = listOf("identifier" to identity), - deserializer = IdentityFlagsAndTraitsDeserializer() ) \ No newline at end of file diff --git a/FlagsmithClient/src/main/java/com/flagsmith/endpoints/TraitsEndpoint.kt b/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/endpoints/TraitsEndpoint.kt similarity index 77% rename from FlagsmithClient/src/main/java/com/flagsmith/endpoints/TraitsEndpoint.kt rename to FlagsmithClient/src/test/java/com/flagsmith/mockResponses/endpoints/TraitsEndpoint.kt index 7e5bafb..d68ea71 100644 --- a/FlagsmithClient/src/main/java/com/flagsmith/endpoints/TraitsEndpoint.kt +++ b/FlagsmithClient/src/test/java/com/flagsmith/mockResponses/endpoints/TraitsEndpoint.kt @@ -1,9 +1,8 @@ -package com.flagsmith.endpoints +package com.flagsmith.mockResponses.endpoints import com.flagsmith.entities.Identity import com.flagsmith.entities.Trait import com.flagsmith.entities.TraitWithIdentity -import com.flagsmith.entities.TraitWithIdentityDeserializer import com.google.gson.Gson data class TraitsEndpoint(private val trait: Trait, private val identity: String) : @@ -16,5 +15,4 @@ data class TraitsEndpoint(private val trait: Trait, private val identity: String identity = Identity(identity) ) ), - deserializer = TraitWithIdentityDeserializer() ) \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 5b231ab..4924a83 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,4 +18,4 @@ dependencyResolutionManagement { rootProject.name = "Flagsmith" -include(":FlagsmithClient") \ No newline at end of file +include(":FlagsmithClient")