mirror of
https://github.com/bitwarden/android.git
synced 2024-11-24 10:25:57 +03:00
BIT-5, BIT-101: Setup networking layer add get config call (#9)
Co-authored-by: joshua-livefront <joshua@livefront.com>
This commit is contained in:
parent
116d48d8ac
commit
dc2ed4403a
9 changed files with 305 additions and 0 deletions
|
@ -100,6 +100,7 @@ dependencies {
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
testImplementation(libs.mockk.mockk)
|
testImplementation(libs.mockk.mockk)
|
||||||
testImplementation(libs.robolectric.robolectric)
|
testImplementation(libs.robolectric.robolectric)
|
||||||
|
testImplementation(libs.square.okhttp.mockwebserver)
|
||||||
testImplementation(libs.square.turbine)
|
testImplementation(libs.square.turbine)
|
||||||
|
|
||||||
detektPlugins(libs.detekt.detekt.formatting)
|
detektPlugins(libs.detekt.detekt.formatting)
|
||||||
|
|
|
@ -0,0 +1,62 @@
|
||||||
|
package com.x8bit.bitwarden.data.datasource.network
|
||||||
|
|
||||||
|
import okhttp3.Request
|
||||||
|
import okio.Timeout
|
||||||
|
import retrofit2.Call
|
||||||
|
import retrofit2.Callback
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.Response.success
|
||||||
|
import java.lang.reflect.Type
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [Call] for wrapping a network request into a [Result].
|
||||||
|
*/
|
||||||
|
class ResultCall<T>(
|
||||||
|
private val backingCall: Call<T>,
|
||||||
|
private val successType: Type,
|
||||||
|
) : Call<Result<T>> {
|
||||||
|
override fun cancel(): Unit = backingCall.cancel()
|
||||||
|
|
||||||
|
override fun clone(): Call<Result<T>> = ResultCall(backingCall, successType)
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun createResult(body: T?): Result<T> {
|
||||||
|
return when {
|
||||||
|
body != null -> Result.success(body)
|
||||||
|
successType == Unit::class.java -> Result.success(Unit as T)
|
||||||
|
else -> Result.failure(IllegalStateException("Unexpected null body!"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun enqueue(callback: Callback<Result<T>>): Unit = backingCall.enqueue(
|
||||||
|
object : Callback<T> {
|
||||||
|
override fun onResponse(call: Call<T>, response: Response<T>) {
|
||||||
|
val body = response.body()
|
||||||
|
val result: Result<T> = if (!response.isSuccessful) {
|
||||||
|
Result.failure(HttpException(response))
|
||||||
|
} else {
|
||||||
|
createResult(body)
|
||||||
|
}
|
||||||
|
callback.onResponse(this@ResultCall, success(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(call: Call<T>, t: Throwable) {
|
||||||
|
val result: Result<T> = Result.failure(t)
|
||||||
|
callback.onResponse(this@ResultCall, success(result))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun execute(): Response<Result<T>> = throw UnsupportedOperationException(
|
||||||
|
"This call can't be executed synchronously",
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun isCanceled(): Boolean = backingCall.isCanceled
|
||||||
|
|
||||||
|
override fun isExecuted(): Boolean = backingCall.isExecuted
|
||||||
|
|
||||||
|
override fun request(): Request = backingCall.request()
|
||||||
|
|
||||||
|
override fun timeout(): Timeout = backingCall.timeout()
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.x8bit.bitwarden.data.datasource.network
|
||||||
|
|
||||||
|
import retrofit2.Call
|
||||||
|
import retrofit2.CallAdapter
|
||||||
|
import java.lang.reflect.Type
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [CallAdapter] for wrapping network requests into [kotlin.Result].
|
||||||
|
*/
|
||||||
|
class ResultCallAdapter<T>(
|
||||||
|
private val successType: Type,
|
||||||
|
) : CallAdapter<T, Call<Result<T>>> {
|
||||||
|
|
||||||
|
override fun responseType(): Type = successType
|
||||||
|
override fun adapt(call: Call<T>): Call<Result<T>> = ResultCall(call, successType)
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.x8bit.bitwarden.data.datasource.network
|
||||||
|
|
||||||
|
import retrofit2.Call
|
||||||
|
import retrofit2.CallAdapter
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import java.lang.reflect.ParameterizedType
|
||||||
|
import java.lang.reflect.Type
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [CallAdapter.Factory] for wrapping network requests into [kotlin.Result].
|
||||||
|
*/
|
||||||
|
class ResultCallAdapterFactory : CallAdapter.Factory() {
|
||||||
|
override fun get(
|
||||||
|
returnType: Type,
|
||||||
|
annotations: Array<out Annotation>,
|
||||||
|
retrofit: Retrofit,
|
||||||
|
): CallAdapter<*, *>? {
|
||||||
|
check(returnType is ParameterizedType) { "$returnType must be parameterized" }
|
||||||
|
val containerType = getParameterUpperBound(0, returnType)
|
||||||
|
|
||||||
|
if (getRawType(containerType) != Result::class.java) return null
|
||||||
|
check(containerType is ParameterizedType) { "$containerType must be parameterized" }
|
||||||
|
|
||||||
|
val requestType = getParameterUpperBound(0, containerType)
|
||||||
|
|
||||||
|
return if (getRawType(returnType) == Call::class.java) {
|
||||||
|
ResultCallAdapter<Any>(successType = requestType)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.x8bit.bitwarden.data.datasource.network.api
|
||||||
|
|
||||||
|
import com.x8bit.bitwarden.data.datasource.network.models.ConfigResponseJson
|
||||||
|
import retrofit2.http.GET
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This interface defines the API service for fetching configuration data.
|
||||||
|
*/
|
||||||
|
interface ConfigApi {
|
||||||
|
/**
|
||||||
|
* Retrieves the configuration data from the server.
|
||||||
|
*
|
||||||
|
* @return A [ConfigResponseJson] containing the configuration response model.
|
||||||
|
*/
|
||||||
|
@GET("config")
|
||||||
|
suspend fun getConfig(): Result<ConfigResponseJson>
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
package com.x8bit.bitwarden.data.datasource.network.di
|
||||||
|
|
||||||
|
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
import com.x8bit.bitwarden.data.datasource.network.ResultCallAdapterFactory
|
||||||
|
import com.x8bit.bitwarden.data.datasource.network.api.ConfigApi
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class provides network-related functionality for the application.
|
||||||
|
* It initializes and configures the networking components.
|
||||||
|
*/
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object NetworkModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideConfigApiService(retrofit: Retrofit): ConfigApi {
|
||||||
|
return retrofit.create(ConfigApi::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideOkHttpClient(): OkHttpClient {
|
||||||
|
return OkHttpClient.Builder()
|
||||||
|
.addInterceptor(
|
||||||
|
HttpLoggingInterceptor().apply {
|
||||||
|
setLevel(HttpLoggingInterceptor.Level.BODY)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
||||||
|
val contentType = "application/json".toMediaType()
|
||||||
|
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl("https://api.bitwarden.com")
|
||||||
|
.client(okHttpClient)
|
||||||
|
.addConverterFactory(Json.asConverterFactory(contentType))
|
||||||
|
.addCallAdapterFactory(ResultCallAdapterFactory())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,70 @@
|
||||||
|
package com.x8bit.bitwarden.data.datasource.network.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the response model for configuration data fetched from the server.
|
||||||
|
*
|
||||||
|
* @property type The object type, typically "config".
|
||||||
|
* @property version The version of the configuration data.
|
||||||
|
* @property gitHash The Git hash associated with the configuration data.
|
||||||
|
* @property server The server information (nullable).
|
||||||
|
* @property environment The environment information containing URLs (vault, api, identity, etc.).
|
||||||
|
* @property featureStates A map containing various feature states.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ConfigResponseJson(
|
||||||
|
@SerialName("object")
|
||||||
|
val type: String?,
|
||||||
|
@SerialName("version")
|
||||||
|
val version: String?,
|
||||||
|
@SerialName("gitHash")
|
||||||
|
val gitHash: String?,
|
||||||
|
@SerialName("server")
|
||||||
|
val server: ServerJson?,
|
||||||
|
@SerialName("environment")
|
||||||
|
val environment: EnvironmentJson?,
|
||||||
|
@SerialName("featureStates")
|
||||||
|
val featureStates: Map<String, Boolean>?,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Represents a server in the configuration response.
|
||||||
|
*
|
||||||
|
* @param name The name of the server.
|
||||||
|
* @param url The URL of the server.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ServerJson(
|
||||||
|
@SerialName("name")
|
||||||
|
val name: String?,
|
||||||
|
@SerialName("url")
|
||||||
|
val url: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the environment details in the configuration response.
|
||||||
|
*
|
||||||
|
* @param cloudRegion The cloud region associated with the environment.
|
||||||
|
* @param vaultUrl The URL of the vault service in the environment.
|
||||||
|
* @param apiUrl The URL of the API service in the environment.
|
||||||
|
* @param identityUrl The URL of the identity service in the environment.
|
||||||
|
* @param notificationsUrl The URL of the notifications service in the environment.
|
||||||
|
* @param ssoUrl The URL of the single sign-on (SSO) service in the environment.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class EnvironmentJson(
|
||||||
|
@SerialName("cloudRegion")
|
||||||
|
val cloudRegion: String?,
|
||||||
|
@SerialName("vault")
|
||||||
|
val vaultUrl: String?,
|
||||||
|
@SerialName("api")
|
||||||
|
val apiUrl: String?,
|
||||||
|
@SerialName("identity")
|
||||||
|
val identityUrl: String?,
|
||||||
|
@SerialName("notifications")
|
||||||
|
val notificationsUrl: String?,
|
||||||
|
@SerialName("sso")
|
||||||
|
val ssoUrl: String?,
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
package com.x8bit.bitwarden.example
|
||||||
|
|
||||||
|
import com.x8bit.bitwarden.data.datasource.network.ResultCallAdapterFactory
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import retrofit2.http.GET
|
||||||
|
|
||||||
|
class ResultCallAdapterTests {
|
||||||
|
|
||||||
|
private val server: MockWebServer = MockWebServer().apply { start() }
|
||||||
|
private val testService: FakeService =
|
||||||
|
Retrofit.Builder()
|
||||||
|
.baseUrl(server.url("/").toString())
|
||||||
|
// add the adapter being tested
|
||||||
|
.addCallAdapterFactory(ResultCallAdapterFactory())
|
||||||
|
.build()
|
||||||
|
.create()
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun after() {
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when server returns error response code result should be failure`() = runBlocking {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(500))
|
||||||
|
val result = testService.requestWithUnitData()
|
||||||
|
assertTrue(result.isFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when server returns successful response result should be success`() = runBlocking {
|
||||||
|
server.enqueue(MockResponse())
|
||||||
|
val result = testService.requestWithUnitData()
|
||||||
|
assertTrue(result.isSuccess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fake retrofit service used for testing call adapters.
|
||||||
|
*/
|
||||||
|
private interface FakeService {
|
||||||
|
@GET("/fake")
|
||||||
|
suspend fun requestWithUnitData(): Result<Unit>
|
||||||
|
}
|
|
@ -77,6 +77,7 @@ nulab-zxcvbn4j = { module = "com.nulab-inc:zxcvbn", version.ref = "zxcvbn4j" }
|
||||||
robolectric-robolectric = { module = "org.robolectric:robolectric", version.ref = "roboelectric" }
|
robolectric-robolectric = { module = "org.robolectric:robolectric", version.ref = "roboelectric" }
|
||||||
square-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
square-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||||
square-okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
square-okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
||||||
|
square-okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
|
||||||
square-retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
|
square-retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
|
||||||
square-turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
|
square-turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
|
||||||
zxing-zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
|
zxing-zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
|
||||||
|
|
Loading…
Reference in a new issue