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:
Patrick Honkonen 2024-11-05 15:18:28 -05:00
parent 5a4b8d64ab
commit dafa131277
No known key found for this signature in database
GPG key ID: B63AF42A5531C877
22 changed files with 357 additions and 187 deletions

View file

@ -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()
} }

View file

@ -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,
) )

View file

@ -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?,
) )

View file

@ -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,
) )

View file

@ -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
}
} }

View file

@ -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
}
} }

View file

@ -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,
) )

View file

@ -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?,
) )

View file

@ -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,
) )
} }
}, },

View file

@ -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,
) )

View file

@ -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()
}
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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"
} }
} }
}, },

View file

@ -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(

View file

@ -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()

View file

@ -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()),

View file

@ -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(),
)
}
}

View file

@ -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)
} }

View file

@ -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

View file

@ -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"

View file

@ -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.