mirror of
https://github.com/bitwarden/android.git
synced 2024-11-24 02:15:53 +03:00
Add interceptor to convert network response JSON keys to camel case
Adds an OkHttp interceptor, `ResponseJsonKeyNameInterceptor`, which converts all JSON object keys in network responses from pascal case to camel case. This addresses inconsistencies in key casing from the server. Includes unit tests for the interceptor to ensure it handles various scenarios, such as different casing styles, empty or null responses, invalid JSON, and non-JSON content types.
This commit is contained in:
parent
5a4b8d64ab
commit
dafa131277
22 changed files with 357 additions and 187 deletions
|
@ -32,52 +32,52 @@ sealed class GetTokenResponseJson {
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Success(
|
data class Success(
|
||||||
@SerialName("access_token")
|
@SerialName("accessToken")
|
||||||
val accessToken: String,
|
val accessToken: String,
|
||||||
|
|
||||||
@SerialName("refresh_token")
|
@SerialName("refreshToken")
|
||||||
val refreshToken: String,
|
val refreshToken: String,
|
||||||
|
|
||||||
@SerialName("token_type")
|
@SerialName("tokenType")
|
||||||
val tokenType: String,
|
val tokenType: String,
|
||||||
|
|
||||||
@SerialName("expires_in")
|
@SerialName("expiresIn")
|
||||||
val expiresInSeconds: Int,
|
val expiresInSeconds: Int,
|
||||||
|
|
||||||
@SerialName("Key")
|
@SerialName("key")
|
||||||
val key: String?,
|
val key: String?,
|
||||||
|
|
||||||
@SerialName("PrivateKey")
|
@SerialName("privateKey")
|
||||||
val privateKey: String?,
|
val privateKey: String?,
|
||||||
|
|
||||||
@SerialName("Kdf")
|
@SerialName("kdf")
|
||||||
val kdfType: KdfTypeJson,
|
val kdfType: KdfTypeJson,
|
||||||
|
|
||||||
@SerialName("KdfIterations")
|
@SerialName("kdfIterations")
|
||||||
val kdfIterations: Int?,
|
val kdfIterations: Int?,
|
||||||
|
|
||||||
@SerialName("KdfMemory")
|
@SerialName("kdfMemory")
|
||||||
val kdfMemory: Int?,
|
val kdfMemory: Int?,
|
||||||
|
|
||||||
@SerialName("KdfParallelism")
|
@SerialName("kdfParallelism")
|
||||||
val kdfParallelism: Int?,
|
val kdfParallelism: Int?,
|
||||||
|
|
||||||
@SerialName("ForcePasswordReset")
|
@SerialName("forcePasswordReset")
|
||||||
val shouldForcePasswordReset: Boolean,
|
val shouldForcePasswordReset: Boolean,
|
||||||
|
|
||||||
@SerialName("ResetMasterPassword")
|
@SerialName("resetMasterPassword")
|
||||||
val shouldResetMasterPassword: Boolean,
|
val shouldResetMasterPassword: Boolean,
|
||||||
|
|
||||||
@SerialName("TwoFactorToken")
|
@SerialName("twoFactorToken")
|
||||||
val twoFactorToken: String?,
|
val twoFactorToken: String?,
|
||||||
|
|
||||||
@SerialName("MasterPasswordPolicy")
|
@SerialName("masterPasswordPolicy")
|
||||||
val masterPasswordPolicyOptions: MasterPasswordPolicyOptionsJson?,
|
val masterPasswordPolicyOptions: MasterPasswordPolicyOptionsJson?,
|
||||||
|
|
||||||
@SerialName("UserDecryptionOptions")
|
@SerialName("userDecryptionOptions")
|
||||||
val userDecryptionOptions: UserDecryptionOptionsJson?,
|
val userDecryptionOptions: UserDecryptionOptionsJson?,
|
||||||
|
|
||||||
@SerialName("KeyConnectorUrl")
|
@SerialName("keyConnectorUrl")
|
||||||
val keyConnectorUrl: String?,
|
val keyConnectorUrl: String?,
|
||||||
) : GetTokenResponseJson()
|
) : GetTokenResponseJson()
|
||||||
|
|
||||||
|
@ -86,7 +86,7 @@ sealed class GetTokenResponseJson {
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class CaptchaRequired(
|
data class CaptchaRequired(
|
||||||
@SerialName("HCaptcha_SiteKey")
|
@SerialName("hCaptchaSiteKey")
|
||||||
val captchaKey: String,
|
val captchaKey: String,
|
||||||
) : GetTokenResponseJson()
|
) : GetTokenResponseJson()
|
||||||
|
|
||||||
|
@ -95,35 +95,15 @@ sealed class GetTokenResponseJson {
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Invalid(
|
data class Invalid(
|
||||||
@SerialName("ErrorModel")
|
|
||||||
val errorModel: ErrorModel?,
|
|
||||||
@SerialName("errorModel")
|
@SerialName("errorModel")
|
||||||
val legacyErrorModel: LegacyErrorModel?,
|
val errorModel: ErrorModel?,
|
||||||
) : GetTokenResponseJson() {
|
) : GetTokenResponseJson() {
|
||||||
|
|
||||||
/**
|
|
||||||
* The error message returned from the server, or null.
|
|
||||||
*/
|
|
||||||
val errorMessage: String?
|
|
||||||
get() = errorModel?.errorMessage ?: legacyErrorModel?.errorMessage
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The error body of an invalid request containing a message.
|
* The error body of an invalid request containing a message.
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ErrorModel(
|
data class ErrorModel(
|
||||||
@SerialName("Message")
|
|
||||||
val errorMessage: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The legacy error body of an invalid request containing a message.
|
|
||||||
*
|
|
||||||
* This model is used to support older versions of the error response model that used
|
|
||||||
* lower-case keys.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class LegacyErrorModel(
|
|
||||||
@SerialName("message")
|
@SerialName("message")
|
||||||
val errorMessage: String,
|
val errorMessage: String,
|
||||||
)
|
)
|
||||||
|
@ -145,16 +125,16 @@ sealed class GetTokenResponseJson {
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TwoFactorRequired(
|
data class TwoFactorRequired(
|
||||||
@SerialName("TwoFactorProviders2")
|
@SerialName("twoFactorProviders2")
|
||||||
val authMethodsData: Map<TwoFactorAuthMethod, JsonObject?>,
|
val authMethodsData: Map<TwoFactorAuthMethod, JsonObject?>,
|
||||||
|
|
||||||
@SerialName("TwoFactorProviders")
|
@SerialName("twoFactorProviders")
|
||||||
val twoFactorProviders: List<String>?,
|
val twoFactorProviders: List<String>?,
|
||||||
|
|
||||||
@SerialName("CaptchaBypassToken")
|
@SerialName("captchaBypassToken")
|
||||||
val captchaToken: String?,
|
val captchaToken: String?,
|
||||||
|
|
||||||
@SerialName("SsoEmail2faSessionToken")
|
@SerialName("ssoEmail2faSessionToken")
|
||||||
val ssoToken: String?,
|
val ssoToken: String?,
|
||||||
) : GetTokenResponseJson()
|
) : GetTokenResponseJson()
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,6 @@ import kotlinx.serialization.Serializable
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class KeyConnectorUserDecryptionOptionsJson(
|
data class KeyConnectorUserDecryptionOptionsJson(
|
||||||
@SerialName("KeyConnectorUrl")
|
@SerialName("keyConnectorUrl")
|
||||||
val keyConnectorUrl: String,
|
val keyConnectorUrl: String,
|
||||||
)
|
)
|
||||||
|
|
|
@ -16,24 +16,24 @@ import kotlinx.serialization.Serializable
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MasterPasswordPolicyOptionsJson(
|
data class MasterPasswordPolicyOptionsJson(
|
||||||
@SerialName("MinComplexity")
|
@SerialName("minComplexity")
|
||||||
val minimumComplexity: Int?,
|
val minimumComplexity: Int?,
|
||||||
|
|
||||||
@SerialName("MinLength")
|
@SerialName("minLength")
|
||||||
val minimumLength: Int?,
|
val minimumLength: Int?,
|
||||||
|
|
||||||
@SerialName("RequireUpper")
|
@SerialName("requireUpper")
|
||||||
val shouldRequireUppercase: Boolean?,
|
val shouldRequireUppercase: Boolean?,
|
||||||
|
|
||||||
@SerialName("RequireLower")
|
@SerialName("requireLower")
|
||||||
val shouldRequireLowercase: Boolean?,
|
val shouldRequireLowercase: Boolean?,
|
||||||
|
|
||||||
@SerialName("RequireNumbers")
|
@SerialName("requireNumbers")
|
||||||
val shouldRequireNumbers: Boolean?,
|
val shouldRequireNumbers: Boolean?,
|
||||||
|
|
||||||
@SerialName("RequireSpecial")
|
@SerialName("requireSpecial")
|
||||||
val shouldRequireSpecialCharacters: Boolean?,
|
val shouldRequireSpecialCharacters: Boolean?,
|
||||||
|
|
||||||
@SerialName("EnforceOnLogin")
|
@SerialName("enforceOnLogin")
|
||||||
val shouldEnforceOnLogin: Boolean?,
|
val shouldEnforceOnLogin: Boolean?,
|
||||||
)
|
)
|
||||||
|
|
|
@ -13,15 +13,15 @@ import kotlinx.serialization.Serializable
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RefreshTokenResponseJson(
|
data class RefreshTokenResponseJson(
|
||||||
@SerialName("access_token")
|
@SerialName("accessToken")
|
||||||
val accessToken: String,
|
val accessToken: String,
|
||||||
|
|
||||||
@SerialName("expires_in")
|
@SerialName("expiresIn")
|
||||||
val expiresIn: Int,
|
val expiresIn: Int,
|
||||||
|
|
||||||
@SerialName("refresh_token")
|
@SerialName("refreshToken")
|
||||||
val refreshToken: String,
|
val refreshToken: String,
|
||||||
|
|
||||||
@SerialName("token_type")
|
@SerialName("tokenType")
|
||||||
val tokenType: String,
|
val tokenType: String,
|
||||||
)
|
)
|
||||||
|
|
|
@ -38,7 +38,7 @@ sealed class RegisterResponseJson {
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ValidationErrors(
|
data class ValidationErrors(
|
||||||
@SerialName("HCaptcha_SiteKey")
|
@SerialName("hCaptchaSiteKey")
|
||||||
val captchaKeys: List<String>,
|
val captchaKeys: List<String>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -53,17 +53,9 @@ sealed class RegisterResponseJson {
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Invalid(
|
data class Invalid(
|
||||||
@SerialName("message")
|
@SerialName("message")
|
||||||
private val invalidMessage: String? = null,
|
val invalidMessage: String? = null,
|
||||||
|
|
||||||
@SerialName("Message")
|
|
||||||
private val errorMessage: String? = null,
|
|
||||||
|
|
||||||
@SerialName("validationErrors")
|
@SerialName("validationErrors")
|
||||||
val validationErrors: Map<String, List<String>>?,
|
val validationErrors: Map<String, List<String>>?,
|
||||||
) : RegisterResponseJson() {
|
) : RegisterResponseJson()
|
||||||
/**
|
|
||||||
* A generic error message.
|
|
||||||
*/
|
|
||||||
val message: String? get() = invalidMessage ?: errorMessage
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,17 +29,9 @@ sealed class SendVerificationEmailResponseJson {
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Invalid(
|
data class Invalid(
|
||||||
@SerialName("message")
|
@SerialName("message")
|
||||||
private val invalidMessage: String? = null,
|
val invalidMessage: String? = null,
|
||||||
|
|
||||||
@SerialName("Message")
|
|
||||||
private val errorMessage: String? = null,
|
|
||||||
|
|
||||||
@SerialName("validationErrors")
|
@SerialName("validationErrors")
|
||||||
val validationErrors: Map<String, List<String>>?,
|
val validationErrors: Map<String, List<String>>?,
|
||||||
) : SendVerificationEmailResponseJson() {
|
) : SendVerificationEmailResponseJson()
|
||||||
/**
|
|
||||||
* A generic error message.
|
|
||||||
*/
|
|
||||||
val message: String? get() = invalidMessage ?: errorMessage
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,18 +15,18 @@ import kotlinx.serialization.Serializable
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TrustedDeviceUserDecryptionOptionsJson(
|
data class TrustedDeviceUserDecryptionOptionsJson(
|
||||||
@SerialName("EncryptedPrivateKey")
|
@SerialName("encryptedPrivateKey")
|
||||||
val encryptedPrivateKey: String?,
|
val encryptedPrivateKey: String?,
|
||||||
|
|
||||||
@SerialName("EncryptedUserKey")
|
@SerialName("encryptedUserKey")
|
||||||
val encryptedUserKey: String?,
|
val encryptedUserKey: String?,
|
||||||
|
|
||||||
@SerialName("HasAdminApproval")
|
@SerialName("hasAdminApproval")
|
||||||
val hasAdminApproval: Boolean,
|
val hasAdminApproval: Boolean,
|
||||||
|
|
||||||
@SerialName("HasLoginApprovingDevice")
|
@SerialName("hasLoginApprovingDevice")
|
||||||
val hasLoginApprovingDevice: Boolean,
|
val hasLoginApprovingDevice: Boolean,
|
||||||
|
|
||||||
@SerialName("HasManageResetPasswordPermission")
|
@SerialName("hasManageResetPasswordPermission")
|
||||||
val hasManageResetPasswordPermission: Boolean,
|
val hasManageResetPasswordPermission: Boolean,
|
||||||
)
|
)
|
||||||
|
|
|
@ -14,12 +14,12 @@ import kotlinx.serialization.Serializable
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
data class UserDecryptionOptionsJson(
|
data class UserDecryptionOptionsJson(
|
||||||
@SerialName("HasMasterPassword")
|
@SerialName("hasMasterPassword")
|
||||||
val hasMasterPassword: Boolean,
|
val hasMasterPassword: Boolean,
|
||||||
|
|
||||||
@SerialName("TrustedDeviceOption")
|
@SerialName("trustedDeviceOption")
|
||||||
val trustedDeviceUserDecryptionOptions: TrustedDeviceUserDecryptionOptionsJson?,
|
val trustedDeviceUserDecryptionOptions: TrustedDeviceUserDecryptionOptionsJson?,
|
||||||
|
|
||||||
@SerialName("KeyConnectorOption")
|
@SerialName("keyConnectorOption")
|
||||||
val keyConnectorUserDecryptionOptions: KeyConnectorUserDecryptionOptionsJson?,
|
val keyConnectorUserDecryptionOptions: KeyConnectorUserDecryptionOptionsJson?,
|
||||||
)
|
)
|
||||||
|
|
|
@ -869,7 +869,7 @@ class AuthRepositoryImpl(
|
||||||
?.values
|
?.values
|
||||||
?.firstOrNull()
|
?.firstOrNull()
|
||||||
?.firstOrNull()
|
?.firstOrNull()
|
||||||
?: it.message,
|
?: it.invalidMessage,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1524,7 +1524,7 @@ class AuthRepositoryImpl(
|
||||||
)
|
)
|
||||||
|
|
||||||
is GetTokenResponseJson.Invalid -> LoginResult.Error(
|
is GetTokenResponseJson.Invalid -> LoginResult.Error(
|
||||||
errorMessage = loginResponse.errorMessage,
|
errorMessage = loginResponse.errorModel?.errorMessage,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.Refres
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
|
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
|
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.HeadersInterceptor
|
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.HeadersInterceptor
|
||||||
|
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.ResponseJsonKeyTransformerInterceptor
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits
|
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.RetrofitsImpl
|
import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.RetrofitsImpl
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.network.serializer.ZonedDateTimeSerializer
|
import com.x8bit.bitwarden.data.platform.datasource.network.serializer.ZonedDateTimeSerializer
|
||||||
|
@ -66,6 +67,12 @@ object PlatformNetworkModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
fun providesHeadersInterceptor(): HeadersInterceptor = HeadersInterceptor()
|
fun providesHeadersInterceptor(): HeadersInterceptor = HeadersInterceptor()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providesResponseJsonKeyTransformerInterceptor(
|
||||||
|
json: Json,
|
||||||
|
): ResponseJsonKeyTransformerInterceptor = ResponseJsonKeyTransformerInterceptor(json)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun providesRefreshAuthenticator(): RefreshAuthenticator = RefreshAuthenticator()
|
fun providesRefreshAuthenticator(): RefreshAuthenticator = RefreshAuthenticator()
|
||||||
|
@ -76,6 +83,7 @@ object PlatformNetworkModule {
|
||||||
authTokenInterceptor: AuthTokenInterceptor,
|
authTokenInterceptor: AuthTokenInterceptor,
|
||||||
baseUrlInterceptors: BaseUrlInterceptors,
|
baseUrlInterceptors: BaseUrlInterceptors,
|
||||||
headersInterceptor: HeadersInterceptor,
|
headersInterceptor: HeadersInterceptor,
|
||||||
|
responseJsonKeyTransformerInterceptor: ResponseJsonKeyTransformerInterceptor,
|
||||||
refreshAuthenticator: RefreshAuthenticator,
|
refreshAuthenticator: RefreshAuthenticator,
|
||||||
json: Json,
|
json: Json,
|
||||||
): Retrofits =
|
): Retrofits =
|
||||||
|
@ -84,6 +92,7 @@ object PlatformNetworkModule {
|
||||||
baseUrlInterceptors = baseUrlInterceptors,
|
baseUrlInterceptors = baseUrlInterceptors,
|
||||||
headersInterceptor = headersInterceptor,
|
headersInterceptor = headersInterceptor,
|
||||||
refreshAuthenticator = refreshAuthenticator,
|
refreshAuthenticator = refreshAuthenticator,
|
||||||
|
responseJsonKeyTransformerInterceptor = responseJsonKeyTransformerInterceptor,
|
||||||
json = json,
|
json = json,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
package com.x8bit.bitwarden.data.platform.datasource.network.interceptor
|
||||||
|
|
||||||
|
import com.x8bit.bitwarden.data.platform.util.parseToJsonElementOrNull
|
||||||
|
import com.x8bit.bitwarden.data.platform.util.transformKeysToCamelCase
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An OkHttp interceptor that transforms the JSON response body by converting all JSON object keys
|
||||||
|
* to camel case.
|
||||||
|
*
|
||||||
|
* This interceptor is useful for handling inconsistencies in JSON responses where key casing might
|
||||||
|
* vary.
|
||||||
|
*/
|
||||||
|
class ResponseJsonKeyTransformerInterceptor(private val json: Json) : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
val response = chain.proceed(request)
|
||||||
|
|
||||||
|
val responseBody = response.body
|
||||||
|
?: return response
|
||||||
|
|
||||||
|
val transformedJsonResponseBody = json
|
||||||
|
.parseToJsonElementOrNull(responseBody.string())
|
||||||
|
?.transformKeysToCamelCase()
|
||||||
|
?.toString()
|
||||||
|
?.toResponseBody(response.body?.contentType())
|
||||||
|
?: return response
|
||||||
|
|
||||||
|
return response.newBuilder()
|
||||||
|
.body(transformedJsonResponseBody)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,6 +6,7 @@ import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthToke
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptor
|
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptor
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
|
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.HeadersInterceptor
|
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.HeadersInterceptor
|
||||||
|
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.ResponseJsonKeyTransformerInterceptor
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_KEY_AUTHORIZATION
|
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_KEY_AUTHORIZATION
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
@ -23,6 +24,7 @@ class RetrofitsImpl(
|
||||||
baseUrlInterceptors: BaseUrlInterceptors,
|
baseUrlInterceptors: BaseUrlInterceptors,
|
||||||
headersInterceptor: HeadersInterceptor,
|
headersInterceptor: HeadersInterceptor,
|
||||||
refreshAuthenticator: RefreshAuthenticator,
|
refreshAuthenticator: RefreshAuthenticator,
|
||||||
|
responseJsonKeyTransformerInterceptor: ResponseJsonKeyTransformerInterceptor,
|
||||||
json: Json,
|
json: Json,
|
||||||
) : Retrofits {
|
) : Retrofits {
|
||||||
//region Authenticated Retrofits
|
//region Authenticated Retrofits
|
||||||
|
@ -86,6 +88,7 @@ class RetrofitsImpl(
|
||||||
private val baseOkHttpClient: OkHttpClient =
|
private val baseOkHttpClient: OkHttpClient =
|
||||||
OkHttpClient.Builder()
|
OkHttpClient.Builder()
|
||||||
.addInterceptor(headersInterceptor)
|
.addInterceptor(headersInterceptor)
|
||||||
|
.addInterceptor(responseJsonKeyTransformerInterceptor)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val authenticatedOkHttpClient: OkHttpClient by lazy {
|
private val authenticatedOkHttpClient: OkHttpClient by lazy {
|
||||||
|
|
|
@ -2,6 +2,13 @@ package com.x8bit.bitwarden.data.platform.util
|
||||||
|
|
||||||
import kotlinx.serialization.SerializationException
|
import kotlinx.serialization.SerializationException
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlin.collections.component1
|
||||||
|
import kotlin.collections.component2
|
||||||
|
import kotlin.collections.map
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to decode the given JSON [string] into the given type [T]. If there is an error in
|
* Attempts to decode the given JSON [string] into the given type [T]. If there is an error in
|
||||||
|
@ -17,3 +24,48 @@ inline fun <reified T> Json.decodeFromStringOrNull(
|
||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely parses a JSON string to a JsonElement, returning null if the parsing fails.
|
||||||
|
*/
|
||||||
|
fun Json.parseToJsonElementOrNull(json: String): JsonElement? = try {
|
||||||
|
parseToJsonElement(json)
|
||||||
|
} catch (_: SerializationException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively transforms keys of a JsonElement to camel case.
|
||||||
|
*
|
||||||
|
* This function handles both JsonObject and JsonArray, converting keys within JsonObjects to camel
|
||||||
|
* case.
|
||||||
|
*
|
||||||
|
* @return A new JsonElement with transformed keys.
|
||||||
|
*/
|
||||||
|
fun JsonElement.transformKeysToCamelCase(): JsonElement =
|
||||||
|
when (this) {
|
||||||
|
is JsonObject -> buildJsonObject {
|
||||||
|
this@transformKeysToCamelCase.entries
|
||||||
|
.forEach { (key: String, value: JsonElement) ->
|
||||||
|
val transformedKey = if (key.contains("-") || key.contains("_")) {
|
||||||
|
key
|
||||||
|
.lowercase()
|
||||||
|
.split("_", "-")
|
||||||
|
.mapIndexed { index, originalKey ->
|
||||||
|
if (index > 0) {
|
||||||
|
originalKey.replaceFirstChar { it.uppercase() }
|
||||||
|
} else {
|
||||||
|
originalKey.lowercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.joinToString("")
|
||||||
|
} else {
|
||||||
|
key.replaceFirstChar { it.lowercase() }
|
||||||
|
}
|
||||||
|
this@buildJsonObject.put(transformedKey, value.transformKeysToCamelCase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is JsonArray -> JsonArray(this.map { it.transformKeysToCamelCase() })
|
||||||
|
else -> this
|
||||||
|
}
|
||||||
|
|
|
@ -1223,16 +1223,16 @@ private const val USER_STATE_JSON = """
|
||||||
"kdfMemory": 16,
|
"kdfMemory": 16,
|
||||||
"kdfParallelism": 4,
|
"kdfParallelism": 4,
|
||||||
"accountDecryptionOptions": {
|
"accountDecryptionOptions": {
|
||||||
"HasMasterPassword": true,
|
"hasMasterPassword": true,
|
||||||
"TrustedDeviceOption": {
|
"trustedDeviceOption": {
|
||||||
"EncryptedPrivateKey": "encryptedPrivateKey",
|
"encryptedPrivateKey": "encryptedPrivateKey",
|
||||||
"EncryptedUserKey": "encryptedUserKey",
|
"encryptedUserKey": "encryptedUserKey",
|
||||||
"HasAdminApproval": true,
|
"hasAdminApproval": true,
|
||||||
"HasLoginApprovingDevice": true,
|
"hasLoginApprovingDevice": true,
|
||||||
"HasManageResetPasswordPermission": true
|
"hasManageResetPasswordPermission": true
|
||||||
},
|
},
|
||||||
"KeyConnectorOption": {
|
"keyConnectorOption": {
|
||||||
"KeyConnectorUrl": "keyConnectorUrl"
|
"keyConnectorUrl": "keyConnectorUrl"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -167,7 +167,7 @@ class IdentityServiceTest : BaseServiceTest() {
|
||||||
val result = identityService.register(registerRequestBody)
|
val result = identityService.register(registerRequestBody)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
RegisterResponseJson.Invalid(
|
RegisterResponseJson.Invalid(
|
||||||
errorMessage = "Slow down! Too many requests. Try again soon.",
|
invalidMessage = "Slow down! Too many requests. Try again soon.",
|
||||||
validationErrors = null,
|
validationErrors = null,
|
||||||
),
|
),
|
||||||
result.getOrThrow(),
|
result.getOrThrow(),
|
||||||
|
@ -179,7 +179,7 @@ class IdentityServiceTest : BaseServiceTest() {
|
||||||
val json = """
|
val json = """
|
||||||
{
|
{
|
||||||
"validationErrors": {
|
"validationErrors": {
|
||||||
"HCaptcha_SiteKey": [
|
"hCaptchaSiteKey": [
|
||||||
"mock_token"
|
"mock_token"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -273,22 +273,6 @@ class IdentityServiceTest : BaseServiceTest() {
|
||||||
assertEquals(INVALID_LOGIN.asSuccess(), result)
|
assertEquals(INVALID_LOGIN.asSuccess(), result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `getToken when response is a 400 with a legacy error body should return Invalid`() =
|
|
||||||
runTest {
|
|
||||||
server.enqueue(MockResponse().setResponseCode(400).setBody(LEGACY_INVALID_LOGIN_JSON))
|
|
||||||
val result = identityService.getToken(
|
|
||||||
email = EMAIL,
|
|
||||||
authModel = IdentityTokenAuthModel.MasterPassword(
|
|
||||||
username = EMAIL,
|
|
||||||
password = PASSWORD_HASH,
|
|
||||||
),
|
|
||||||
captchaToken = null,
|
|
||||||
uniqueAppId = UNIQUE_APP_ID,
|
|
||||||
)
|
|
||||||
assertEquals(LEGACY_INVALID_LOGIN.asSuccess(), result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `prevalidateSso when response is success should return PrevalidateSsoResponseJson`() =
|
fun `prevalidateSso when response is success should return PrevalidateSsoResponseJson`() =
|
||||||
runTest {
|
runTest {
|
||||||
|
@ -358,7 +342,7 @@ class IdentityServiceTest : BaseServiceTest() {
|
||||||
val result = identityService.registerFinish(registerFinishRequestBody)
|
val result = identityService.registerFinish(registerFinishRequestBody)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
RegisterResponseJson.Invalid(
|
RegisterResponseJson.Invalid(
|
||||||
errorMessage = "Slow down! Too many requests. Try again soon.",
|
invalidMessage = "Slow down! Too many requests. Try again soon.",
|
||||||
validationErrors = null,
|
validationErrors = null,
|
||||||
),
|
),
|
||||||
result.getOrThrow(),
|
result.getOrThrow(),
|
||||||
|
@ -503,10 +487,10 @@ private val PREVALIDATE_SSO_BODY = PrevalidateSsoResponseJson(
|
||||||
|
|
||||||
private const val REFRESH_TOKEN_JSON = """
|
private const val REFRESH_TOKEN_JSON = """
|
||||||
{
|
{
|
||||||
"access_token": "accessToken",
|
"accessToken": "accessToken",
|
||||||
"expires_in": 3600,
|
"expiresIn": 3600,
|
||||||
"refresh_token": "refreshToken",
|
"refreshToken": "refreshToken",
|
||||||
"token_type": "Bearer"
|
"tokenType": "Bearer"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -519,23 +503,23 @@ private val REFRESH_TOKEN_BODY = RefreshTokenResponseJson(
|
||||||
|
|
||||||
private const val CAPTCHA_BODY_JSON = """
|
private const val CAPTCHA_BODY_JSON = """
|
||||||
{
|
{
|
||||||
"HCaptcha_SiteKey": "123"
|
"hCaptchaSiteKey": "123"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
private val CAPTCHA_BODY = GetTokenResponseJson.CaptchaRequired("123")
|
private val CAPTCHA_BODY = GetTokenResponseJson.CaptchaRequired("123")
|
||||||
|
|
||||||
private const val TWO_FACTOR_BODY_JSON = """
|
private const val TWO_FACTOR_BODY_JSON = """
|
||||||
{
|
{
|
||||||
"TwoFactorProviders2": {"1": {"Email": "ex***@email.com"}, "0": {"Email": null}},
|
"twoFactorProviders2": {"1": {"email": "ex***@email.com"}, "0": {"email": null}},
|
||||||
"SsoEmail2faSessionToken": "exampleToken",
|
"ssoEmail2faSessionToken": "exampleToken",
|
||||||
"CaptchaBypassToken": "BWCaptchaBypass_ABCXYZ",
|
"captchaBypassToken": "BWCaptchaBypass_ABCXYZ",
|
||||||
"TwoFactorProviders": ["1", "3", "0"]
|
"twoFactorProviders": ["1", "3", "0"]
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
private val TWO_FACTOR_BODY = GetTokenResponseJson.TwoFactorRequired(
|
private val TWO_FACTOR_BODY = GetTokenResponseJson.TwoFactorRequired(
|
||||||
authMethodsData = mapOf(
|
authMethodsData = mapOf(
|
||||||
TwoFactorAuthMethod.EMAIL to JsonObject(mapOf("Email" to JsonPrimitive("ex***@email.com"))),
|
TwoFactorAuthMethod.EMAIL to JsonObject(mapOf("email" to JsonPrimitive("ex***@email.com"))),
|
||||||
TwoFactorAuthMethod.AUTHENTICATOR_APP to JsonObject(mapOf("Email" to JsonNull)),
|
TwoFactorAuthMethod.AUTHENTICATOR_APP to JsonObject(mapOf("email" to JsonNull)),
|
||||||
),
|
),
|
||||||
ssoToken = "exampleToken",
|
ssoToken = "exampleToken",
|
||||||
captchaToken = "BWCaptchaBypass_ABCXYZ",
|
captchaToken = "BWCaptchaBypass_ABCXYZ",
|
||||||
|
@ -544,41 +528,41 @@ private val TWO_FACTOR_BODY = GetTokenResponseJson.TwoFactorRequired(
|
||||||
|
|
||||||
private const val LOGIN_SUCCESS_JSON = """
|
private const val LOGIN_SUCCESS_JSON = """
|
||||||
{
|
{
|
||||||
"access_token": "accessToken",
|
"accessToken": "accessToken",
|
||||||
"expires_in": 3600,
|
"expiresIn": 3600,
|
||||||
"token_type": "Bearer",
|
"tokenType": "Bearer",
|
||||||
"refresh_token": "refreshToken",
|
"refreshToken": "refreshToken",
|
||||||
"PrivateKey": "privateKey",
|
"privateKey": "privateKey",
|
||||||
"Key": "key",
|
"key": "key",
|
||||||
"MasterPasswordPolicy": {
|
"masterPasswordPolicy": {
|
||||||
"MinComplexity": 10,
|
"minComplexity": 10,
|
||||||
"MinLength": 100,
|
"minLength": 100,
|
||||||
"RequireUpper": true,
|
"requireUpper": true,
|
||||||
"RequireLower": true,
|
"requireLower": true,
|
||||||
"RequireNumbers": true,
|
"requireNumbers": true,
|
||||||
"RequireSpecial": true,
|
"requireSpecial": true,
|
||||||
"EnforceOnLogin": true
|
"enforceOnLogin": true
|
||||||
},
|
},
|
||||||
"ForcePasswordReset": true,
|
"forcePasswordReset": true,
|
||||||
"ResetMasterPassword": true,
|
"resetMasterPassword": true,
|
||||||
"Kdf": 1,
|
"kdf": 1,
|
||||||
"KdfIterations": 600000,
|
"kdfIterations": 600000,
|
||||||
"KdfMemory": 16,
|
"kdfMemory": 16,
|
||||||
"KdfParallelism": 4,
|
"kdfParallelism": 4,
|
||||||
"UserDecryptionOptions": {
|
"userDecryptionOptions": {
|
||||||
"HasMasterPassword": true,
|
"hasMasterPassword": true,
|
||||||
"TrustedDeviceOption": {
|
"trustedDeviceOption": {
|
||||||
"EncryptedPrivateKey": "encryptedPrivateKey",
|
"encryptedPrivateKey": "encryptedPrivateKey",
|
||||||
"EncryptedUserKey": "encryptedUserKey",
|
"encryptedUserKey": "encryptedUserKey",
|
||||||
"HasAdminApproval": true,
|
"hasAdminApproval": true,
|
||||||
"HasLoginApprovingDevice": true,
|
"hasLoginApprovingDevice": true,
|
||||||
"HasManageResetPasswordPermission": true
|
"hasManageResetPasswordPermission": true
|
||||||
},
|
},
|
||||||
"KeyConnectorOption": {
|
"keyConnectorOption": {
|
||||||
"KeyConnectorUrl": "keyConnectorUrl"
|
"keyConnectorUrl": "keyConnectorUrl"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"KeyConnectorUrl": "keyConnectorUrl"
|
"keyConnectorUrl": "keyConnectorUrl"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -622,25 +606,17 @@ private val LOGIN_SUCCESS = GetTokenResponseJson.Success(
|
||||||
)
|
)
|
||||||
|
|
||||||
private const val INVALID_LOGIN_JSON = """
|
private const val INVALID_LOGIN_JSON = """
|
||||||
{
|
|
||||||
"ErrorModel": {
|
|
||||||
"Message": "123"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
private const val LEGACY_INVALID_LOGIN_JSON = """
|
|
||||||
{
|
{
|
||||||
"errorModel": {
|
"errorModel": {
|
||||||
"message": "Legacy-123"
|
"message": "123"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
private const val TOO_MANY_REQUEST_ERROR_JSON = """
|
private const val TOO_MANY_REQUEST_ERROR_JSON = """
|
||||||
{
|
{
|
||||||
"Object": "error",
|
"object": "error",
|
||||||
"Message": "Slow down! Too many requests. Try again soon."
|
"message": "Slow down! Too many requests. Try again soon."
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -669,14 +645,6 @@ private val INVALID_LOGIN = GetTokenResponseJson.Invalid(
|
||||||
errorModel = GetTokenResponseJson.Invalid.ErrorModel(
|
errorModel = GetTokenResponseJson.Invalid.ErrorModel(
|
||||||
errorMessage = "123",
|
errorMessage = "123",
|
||||||
),
|
),
|
||||||
legacyErrorModel = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val LEGACY_INVALID_LOGIN = GetTokenResponseJson.Invalid(
|
|
||||||
errorModel = null,
|
|
||||||
legacyErrorModel = GetTokenResponseJson.Invalid.LegacyErrorModel(
|
|
||||||
errorMessage = "Legacy-123",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private val SEND_VERIFICATION_EMAIL_REQUEST = SendVerificationEmailRequestJson(
|
private val SEND_VERIFICATION_EMAIL_REQUEST = SendVerificationEmailRequestJson(
|
||||||
|
|
|
@ -1527,7 +1527,6 @@ class AuthRepositoryTest {
|
||||||
errorModel = GetTokenResponseJson.Invalid.ErrorModel(
|
errorModel = GetTokenResponseJson.Invalid.ErrorModel(
|
||||||
errorMessage = "mock_error_message",
|
errorMessage = "mock_error_message",
|
||||||
),
|
),
|
||||||
legacyErrorModel = null,
|
|
||||||
)
|
)
|
||||||
.asSuccess()
|
.asSuccess()
|
||||||
|
|
||||||
|
@ -2320,7 +2319,6 @@ class AuthRepositoryTest {
|
||||||
errorModel = GetTokenResponseJson.Invalid.ErrorModel(
|
errorModel = GetTokenResponseJson.Invalid.ErrorModel(
|
||||||
errorMessage = "mock_error_message",
|
errorMessage = "mock_error_message",
|
||||||
),
|
),
|
||||||
legacyErrorModel = null,
|
|
||||||
)
|
)
|
||||||
.asSuccess()
|
.asSuccess()
|
||||||
|
|
||||||
|
@ -2789,7 +2787,6 @@ class AuthRepositoryTest {
|
||||||
errorModel = GetTokenResponseJson.Invalid.ErrorModel(
|
errorModel = GetTokenResponseJson.Invalid.ErrorModel(
|
||||||
errorMessage = "mock_error_message",
|
errorMessage = "mock_error_message",
|
||||||
),
|
),
|
||||||
legacyErrorModel = null,
|
|
||||||
)
|
)
|
||||||
.asSuccess()
|
.asSuccess()
|
||||||
|
|
||||||
|
|
|
@ -50,7 +50,7 @@ class AuthTokenInterceptorTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val USER_ID: String = "user_id"
|
private const val USER_ID: String = "user_id"
|
||||||
private const val ACCESS_TOKEN: String = "access_token"
|
private const val ACCESS_TOKEN: String = "accessToken"
|
||||||
private val USER_STATE: UserStateJson = UserStateJson(
|
private val USER_STATE: UserStateJson = UserStateJson(
|
||||||
activeUserId = USER_ID,
|
activeUserId = USER_ID,
|
||||||
accounts = mapOf(USER_ID to mockk()),
|
accounts = mapOf(USER_ID to mockk()),
|
||||||
|
|
|
@ -0,0 +1,99 @@
|
||||||
|
package com.x8bit.bitwarden.data.platform.datasource.network.interceptor
|
||||||
|
|
||||||
|
import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule
|
||||||
|
import com.x8bit.bitwarden.data.platform.util.parseToJsonElementOrNull
|
||||||
|
import com.x8bit.bitwarden.data.util.assertJsonEquals
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkStatic
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Protocol
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class ResponseJsonKeyNameInterceptorTest {
|
||||||
|
|
||||||
|
private val interceptor = ResponseJsonKeyTransformerInterceptor(
|
||||||
|
json = PlatformNetworkModule.providesJson(),
|
||||||
|
)
|
||||||
|
private val request = Request.Builder()
|
||||||
|
.url("http://localhost")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
unmockkStatic(Json::parseToJsonElementOrNull)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `intercept should return original response when response body is null`() {
|
||||||
|
val originalResponse = Response.Builder()
|
||||||
|
.request(request)
|
||||||
|
.code(200)
|
||||||
|
.message("OK")
|
||||||
|
.protocol(Protocol.HTTP_1_1)
|
||||||
|
.build()
|
||||||
|
val response = interceptor.intercept(
|
||||||
|
chain = FakeInterceptorChain(
|
||||||
|
request = request,
|
||||||
|
responseProvider = { originalResponse },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
originalResponse,
|
||||||
|
response,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `intercept should return original response when parseToJsonElementOrNull is null`() {
|
||||||
|
mockkStatic(Json::parseToJsonElementOrNull)
|
||||||
|
every { Json.parseToJsonElementOrNull(any()) } returns null
|
||||||
|
|
||||||
|
val originalResponse = Response.Builder()
|
||||||
|
.request(request)
|
||||||
|
.code(200)
|
||||||
|
.message("OK")
|
||||||
|
.protocol(Protocol.HTTP_1_1)
|
||||||
|
.body("".toResponseBody())
|
||||||
|
.build()
|
||||||
|
val response = interceptor.intercept(
|
||||||
|
chain = FakeInterceptorChain(
|
||||||
|
request = request,
|
||||||
|
responseProvider = { originalResponse },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
originalResponse,
|
||||||
|
response,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `intercept should return transformed response`() {
|
||||||
|
val response = interceptor.intercept(
|
||||||
|
chain = FakeInterceptorChain(
|
||||||
|
request = request,
|
||||||
|
responseProvider = {
|
||||||
|
Response.Builder()
|
||||||
|
.request(it)
|
||||||
|
.code(200)
|
||||||
|
.message("OK")
|
||||||
|
.protocol(Protocol.HTTP_1_1)
|
||||||
|
.body(
|
||||||
|
"""[{"PascalArray":[{"PascalCase":0}]}]""".toResponseBody(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertJsonEquals(
|
||||||
|
"""[{"pascalArray":[{"pascalCase":0}]}]""",
|
||||||
|
response.body!!.string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.Refres
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
|
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
|
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
|
||||||
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.HeadersInterceptor
|
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.HeadersInterceptor
|
||||||
|
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.ResponseJsonKeyTransformerInterceptor
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
|
@ -39,11 +40,15 @@ class RetrofitsTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private val headersInterceptors = mockk<HeadersInterceptor> {
|
private val headersInterceptors = mockk<HeadersInterceptor> {
|
||||||
mockIntercept { isheadersInterceptorCalled = true }
|
mockIntercept { isHeadersInterceptorCalled = true }
|
||||||
}
|
}
|
||||||
private val refreshAuthenticator = mockk<RefreshAuthenticator> {
|
private val refreshAuthenticator = mockk<RefreshAuthenticator> {
|
||||||
mockAuthenticate { isRefreshAuthenticatorCalled = true }
|
mockAuthenticate { isRefreshAuthenticatorCalled = true }
|
||||||
}
|
}
|
||||||
|
private val responseJsonKeyTransformerInterceptor =
|
||||||
|
mockk<ResponseJsonKeyTransformerInterceptor> {
|
||||||
|
mockIntercept { isResponseJsonKeyTransformerInterceptorCalled = true }
|
||||||
|
}
|
||||||
private val json = Json
|
private val json = Json
|
||||||
private val server = MockWebServer()
|
private val server = MockWebServer()
|
||||||
|
|
||||||
|
@ -52,15 +57,17 @@ class RetrofitsTest {
|
||||||
baseUrlInterceptors = baseUrlInterceptors,
|
baseUrlInterceptors = baseUrlInterceptors,
|
||||||
headersInterceptor = headersInterceptors,
|
headersInterceptor = headersInterceptors,
|
||||||
refreshAuthenticator = refreshAuthenticator,
|
refreshAuthenticator = refreshAuthenticator,
|
||||||
|
responseJsonKeyTransformerInterceptor = responseJsonKeyTransformerInterceptor,
|
||||||
json = json,
|
json = json,
|
||||||
)
|
)
|
||||||
|
|
||||||
private var isAuthInterceptorCalled = false
|
private var isAuthInterceptorCalled = false
|
||||||
private var isApiInterceptorCalled = false
|
private var isApiInterceptorCalled = false
|
||||||
private var isheadersInterceptorCalled = false
|
private var isHeadersInterceptorCalled = false
|
||||||
private var isIdentityInterceptorCalled = false
|
private var isIdentityInterceptorCalled = false
|
||||||
private var isEventsInterceptorCalled = false
|
private var isEventsInterceptorCalled = false
|
||||||
private var isRefreshAuthenticatorCalled = false
|
private var isRefreshAuthenticatorCalled = false
|
||||||
|
private var isResponseJsonKeyTransformerInterceptorCalled = false
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
|
@ -158,7 +165,7 @@ class RetrofitsTest {
|
||||||
|
|
||||||
assertTrue(isAuthInterceptorCalled)
|
assertTrue(isAuthInterceptorCalled)
|
||||||
assertTrue(isApiInterceptorCalled)
|
assertTrue(isApiInterceptorCalled)
|
||||||
assertTrue(isheadersInterceptorCalled)
|
assertTrue(isHeadersInterceptorCalled)
|
||||||
assertFalse(isIdentityInterceptorCalled)
|
assertFalse(isIdentityInterceptorCalled)
|
||||||
assertFalse(isEventsInterceptorCalled)
|
assertFalse(isEventsInterceptorCalled)
|
||||||
}
|
}
|
||||||
|
@ -176,7 +183,7 @@ class RetrofitsTest {
|
||||||
|
|
||||||
assertTrue(isAuthInterceptorCalled)
|
assertTrue(isAuthInterceptorCalled)
|
||||||
assertFalse(isApiInterceptorCalled)
|
assertFalse(isApiInterceptorCalled)
|
||||||
assertTrue(isheadersInterceptorCalled)
|
assertTrue(isHeadersInterceptorCalled)
|
||||||
assertFalse(isIdentityInterceptorCalled)
|
assertFalse(isIdentityInterceptorCalled)
|
||||||
assertTrue(isEventsInterceptorCalled)
|
assertTrue(isEventsInterceptorCalled)
|
||||||
}
|
}
|
||||||
|
@ -194,7 +201,7 @@ class RetrofitsTest {
|
||||||
|
|
||||||
assertFalse(isAuthInterceptorCalled)
|
assertFalse(isAuthInterceptorCalled)
|
||||||
assertTrue(isApiInterceptorCalled)
|
assertTrue(isApiInterceptorCalled)
|
||||||
assertTrue(isheadersInterceptorCalled)
|
assertTrue(isHeadersInterceptorCalled)
|
||||||
assertFalse(isIdentityInterceptorCalled)
|
assertFalse(isIdentityInterceptorCalled)
|
||||||
assertFalse(isEventsInterceptorCalled)
|
assertFalse(isEventsInterceptorCalled)
|
||||||
}
|
}
|
||||||
|
@ -212,7 +219,7 @@ class RetrofitsTest {
|
||||||
|
|
||||||
assertFalse(isAuthInterceptorCalled)
|
assertFalse(isAuthInterceptorCalled)
|
||||||
assertFalse(isApiInterceptorCalled)
|
assertFalse(isApiInterceptorCalled)
|
||||||
assertTrue(isheadersInterceptorCalled)
|
assertTrue(isHeadersInterceptorCalled)
|
||||||
assertTrue(isIdentityInterceptorCalled)
|
assertTrue(isIdentityInterceptorCalled)
|
||||||
assertFalse(isEventsInterceptorCalled)
|
assertFalse(isEventsInterceptorCalled)
|
||||||
}
|
}
|
||||||
|
@ -231,7 +238,7 @@ class RetrofitsTest {
|
||||||
|
|
||||||
assertTrue(isAuthInterceptorCalled)
|
assertTrue(isAuthInterceptorCalled)
|
||||||
assertFalse(isApiInterceptorCalled)
|
assertFalse(isApiInterceptorCalled)
|
||||||
assertTrue(isheadersInterceptorCalled)
|
assertTrue(isHeadersInterceptorCalled)
|
||||||
assertFalse(isIdentityInterceptorCalled)
|
assertFalse(isIdentityInterceptorCalled)
|
||||||
assertFalse(isEventsInterceptorCalled)
|
assertFalse(isEventsInterceptorCalled)
|
||||||
}
|
}
|
||||||
|
@ -250,7 +257,7 @@ class RetrofitsTest {
|
||||||
|
|
||||||
assertFalse(isAuthInterceptorCalled)
|
assertFalse(isAuthInterceptorCalled)
|
||||||
assertFalse(isApiInterceptorCalled)
|
assertFalse(isApiInterceptorCalled)
|
||||||
assertTrue(isheadersInterceptorCalled)
|
assertTrue(isHeadersInterceptorCalled)
|
||||||
assertFalse(isIdentityInterceptorCalled)
|
assertFalse(isIdentityInterceptorCalled)
|
||||||
assertFalse(isEventsInterceptorCalled)
|
assertFalse(isEventsInterceptorCalled)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.x8bit.bitwarden.data.platform.util
|
package com.x8bit.bitwarden.data.platform.util
|
||||||
|
|
||||||
|
import com.x8bit.bitwarden.data.util.assertJsonEquals
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
@ -47,6 +48,35 @@ class JsonExtensionsTest {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `transformPascalKeysToCamelCase should transform keys with '-' or '_' to camelCase`() {
|
||||||
|
val jsonData = json.parseToJsonElement("""[{"kebab-array":[{"snake_case":0}]}]""")
|
||||||
|
assertJsonEquals(
|
||||||
|
"""[{"kebabArray":[{"snakeCase":0}]}]""",
|
||||||
|
jsonData.transformKeysToCamelCase().toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MaxLineLength")
|
||||||
|
@Test
|
||||||
|
fun `transformKeysToCamelCase should return transformed response when root object is JSONArray`() {
|
||||||
|
val jsonData = Json.parseToJsonElement("""[{"PascalArray":[{"PascalCase":0}]}]""")
|
||||||
|
assertJsonEquals(
|
||||||
|
"""[{"pascalArray":[{"pascalCase":0}]}]""",
|
||||||
|
jsonData.transformKeysToCamelCase().toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parseToJsonElementOrNull should return null when json is empty string`() {
|
||||||
|
assertNull(json.parseToJsonElementOrNull(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parseToJsonElementOrNull should return null when json is invalid`() {
|
||||||
|
assertNull(json.parseToJsonElementOrNull("{OK}"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
|
@ -1344,4 +1344,4 @@ private val FIXED_CLOCK: Clock = Clock.fixed(
|
||||||
ZoneOffset.UTC,
|
ZoneOffset.UTC,
|
||||||
)
|
)
|
||||||
|
|
||||||
private const val ACCESS_TOKEN: String = "access_token"
|
private const val ACCESS_TOKEN: String = "accessToken"
|
||||||
|
|
|
@ -60,6 +60,10 @@ In some cases a source of data may be continuously observed and in these cases a
|
||||||
|
|
||||||
Nearly all classes in the data layer consist of interfaces representing exposed behavior and a corresponding `...Impl` class implementing that interface (ex: [AuthDiskSource](../app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt) / [AuthDiskSourceImpl](../app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt)). All `...Impl` classes are intended to be manually constructed while their associated interfaces are provided for dependency injection via a [Hilt Module](https://dagger.dev/hilt/modules.html) (ex: [PlatformNetworkModule](../app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt)). This prevents the `...Impl` classes from being injected by accident and allows the interfaces to be easily mocked/faked in tests.
|
Nearly all classes in the data layer consist of interfaces representing exposed behavior and a corresponding `...Impl` class implementing that interface (ex: [AuthDiskSource](../app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt) / [AuthDiskSourceImpl](../app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt)). All `...Impl` classes are intended to be manually constructed while their associated interfaces are provided for dependency injection via a [Hilt Module](https://dagger.dev/hilt/modules.html) (ex: [PlatformNetworkModule](../app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt)). This prevents the `...Impl` classes from being injected by accident and allows the interfaces to be easily mocked/faked in tests.
|
||||||
|
|
||||||
|
### Note on Bitwarden server communication
|
||||||
|
|
||||||
|
All network responses containing JSON are transformed such that their keys conform to the `camelCase` naming convention. This is done to address inconsistencies in key casing from the server. Transformation is handled by [ResponseJsonKeyNameInterceptor](../app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/interceptor/ResponseJsonKeyTransformerInterceptor.kt).
|
||||||
|
|
||||||
## UI Layer
|
## UI Layer
|
||||||
|
|
||||||
The UI layer adheres to the concept of [unidirectional data flow](https://developer.android.com/develop/ui/compose/architecture#udf) and makes use of the MVVM design pattern. Both concepts are in line what Google currently recommends as the best approach for building the UI-layer of a modern Android application and this allows us to make use of all the available tooling Google provides as part of the [Jetpack suite of libraries](https://developer.android.com/jetpack). The MVVM implementation is built around the Android [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel) class and the UI itself is constructed using the [Jetpack Compose](https://developer.android.com/develop/ui/compose), a declarative UI framework specifically built around the unidirectional data flow approach.
|
The UI layer adheres to the concept of [unidirectional data flow](https://developer.android.com/develop/ui/compose/architecture#udf) and makes use of the MVVM design pattern. Both concepts are in line what Google currently recommends as the best approach for building the UI-layer of a modern Android application and this allows us to make use of all the available tooling Google provides as part of the [Jetpack suite of libraries](https://developer.android.com/jetpack). The MVVM implementation is built around the Android [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel) class and the UI itself is constructed using the [Jetpack Compose](https://developer.android.com/develop/ui/compose), a declarative UI framework specifically built around the unidirectional data flow approach.
|
||||||
|
|
Loading…
Reference in a new issue