@gpeal @elihart @rossbacher, I made what I thought was a pretty specific and thorough thread at #692 but never heard back, which is totally fine. I get that life happens and open-source work is a beast of its own. Really hoping for a response on this ticket's topics 👇🏽 though
tldr; Is there a way to combine the latest version of Mavericks with "custom" network response and error handling? A way to combine Mavericks with Retrofit API Response/callback/adapters?
This whole ticket is born out two high-level end goals, which are to:
- display a pop-up dialog with the backend error's message in it. To do this, I need to access the message that the backend delivers.
- still have the concept of
Loading so that progress indicators and other UI can tap into a network call's Loading state.
Here are examples of the JSON body that the backend sends the app when there's a error:
{"context": {"reason": "Incorrect username or password."}, "error_code": "incorrect_username_password_combination", "message": "incorrect username password combination"}
or
{"context": {"argument_name": "organization_qid", "reason": "required key not provided"}, "error_code": "bad_argument_value", "message": "bad argument value: organization_qid"}
I've already got the JSON object modeled as POJO 👇🏽
Ideally, the Mavericks Async Fail would end up with the message in it.
When looking at Mavericks' data class Fail, it seems that users aren't given much flexibility.
Ideally, I wouldn't have to create my own custom sealed class setup with Uninitialized, Error, Success, Loading, and so on. I like Mavericks' Async class, especially because it has Loading built in and I've got various progress indicator UI tied to Loading.
App context
Single activity, multiple fragment Android app. Using Kotlin, Retrofit, Coroutines, Moshi, View binding, Navigation Component, and Hilt. Nothing crazy.
App architecture: Fragment <-> ViewModel <-> Repository <–> RetrofitService. Nothing crazy.
Mavericks and Retrofit Gradle versions
val mavericksVersion = "3.0.9"
implementation ("com.airbnb.android:mavericks:$mavericksVersion")
implementation ("com.airbnb.android:mavericks-navigation:$mavericksVersion")
val retrofitVersion = "2.9.0"
implementation ("com.squareup.retrofit2:retrofit:$retrofitVersion")
implementation ("com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2")
implementation ("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
Log in as an example scenario of what I'm talking about:
LoginState has val loginToken: Async<LoginToken> = Uninitialized and 👇🏽 in the ViewModel
init {
onAsync(LoginState::loginToken, onSuccess = { token ->
...
}, onFail = {
Timber.d("LoginState loginToken = $it, cause = ${it.cause}, message = ${it.message}")
})
}
ViewModel's log in method 👇🏽
internal fun performLogin() = withState { state ->
suspend {
loginRepository.getLoginTokenFromBackend(
state.usernameText,
state.passwordText,
encodeUri = { stringToEncode: String? -> Uri.encode(stringToEncode) }
)
}.execute { copy(loginToken = it) }
}
Repository method 👇🏽
suspend fun getLoginTokenFromBackend(
username: String,
password: String,
encodeUri: ((String) -> String)? = null
): LoginToken {
...encoding code here...
return retrofitService.getLoginToken(
authorizationHeader = authHeader,
username = "op=$encodedUsername"
)
}
Retrofit service endpoint is
@GET(...)
suspend fun getLoginToken(
...
): LoginToken
Right now, this setup works correctly and shows a circular progress indicator when loginToken is Loading, thanks to the State's derived property val showProgressInButton = loginToken is Loading
and the Fragment's invalidate()
override fun invalidate() = withState(viewModel) { state ->
binding.progressInLoginButton.isVisible = state.showProgressInButton
}
After submitting an incorrect password, the backend sends a 401 error with a JSON
{"context": {"reason": "Incorrect username or password."}, "error_code": "incorrect_username_password_combination", "message": "incorrect username password combination"}
The Timber line above prints LoginState loginToken = retrofit2.HttpException: HTTP 401 , cause = null, message = HTTP 401
cause and message are null. Is there a recommended way to (simply) go from receiving the JSON body to the Mavericks Fail having some sort of content from the JSON body? A hacky way? Casting somehow?
Or is Mavericks intentionally designed to just throw a pretty generic Fail with the error code and nothing else? Does the backend error need to be structured differently for Mavericks' code to correctly interpret it and provide more info in the Fail?
Because I don't see a way to get access to the JSON info, I'm looking into the often suggested route of creating and using a custom sealed class with networking states. From what it looks like, it's essentially my own version of Mavericks' Async.kt class 👇🏽
Custom Result.kt situation
I'm sure there are issues with it, but I've got 👇🏽 for now.
open class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
object EmptySuccess : Result<Nothing>()
data class Error(val exception: Exception) : Result<Nothing>()
data class ApiError(val apiError: MerchantException) : Result<Nothing>()
data class Unauthorized(val unauthorized: MerchantException) : Result<Nothing>()
object Loading : Result<Nothing>()
object Uninitialized : Result<Nothing>()
}
So, with the Result.kt class above, I can do val loginToken: Result<LoginToken?> = Result.Uninitialized in the LoginState.
Doing Result<LoginToken> instead of Async<LoginToken> now means:
- the Mavericks
.execute extension function no longer works
- I lose the Mavericks
Loading aspect of the networking call
- I need to somehow replicate
Loading to have the circular progress indictor simply tied to Result.Loading.
If I MUST have a custom Result.kt class to create custom errors, any thoughts on a way to somehow combine it with Mavericks? I have👇🏽 working well
fun <T> Result<T>.mappedResponse(): Async<T?>? = when (val result = this) {
is Result.Success<*> -> Success(result.resultData)
is Result.Error -> Fail(result.exception)
is Result.ApiError -> Fail(result.apiError)
is Result.Unauthorized -> Fail(result.unauthorized)
is Result.Loading -> Loading()
else -> Uninitialized()
}
and
internal fun performLogin() = withState { state ->
setState { copy(loginToken = Loading()) }
suspend {
loginRepository.getLoginTokenFromBackend(
state.usernameText,
state.passwordText,
encodeUri = { stringToEncode: String? -> Uri.encode(stringToEncode) }
)
}.execute {
it.invoke()?.mappedResponse()?.let {
copy(loginToken = it)
} ?: copy()
}
}
Capturing exceptions
I've created a handleResponse() method, which takes in a Retrofit2 Response object and checks the response object. I'm hoping to use it for all networking response object values that are wrapped with Result in any of my ViewModels (e.g. val loginToken: Result<LoginToken?> = Result.Uninitialized)
Do notice that it returns some type of Result from the custom Result.kt class I made and described above.
fun <T> handleResponse(response: Response<T>): Result<T> {
try {
val contentType = response.raw().body?.contentType()
val responseIsFromS3Server = "AmazonS3" == response.headers()["Server"]
if (response.isSuccessful) {
val code = response.code()
val responseBody = response.body()
?: return Result.ApiError(
MerchantException(
supportCode = SupportCode.TRANSPORT_EXCEPTION,
message = "response contained no body"
)
)
if (code == 500) {
return Result.ApiError(MerchantException(SupportCode.HUB_EXCEPTION))
} else if (code == 502) {
return Result.ApiError(MerchantException(SupportCode.BAD_GATEWAY_EXCEPTION))
} else if (code == 511) {
return Result.ApiError(MerchantException(SupportCode.CAPTIVE_PORTAL_DETECTED))
} else if (code in 200..204 && (jsonMediaType == contentType || responseIsFromS3Server)) {
return Result.Success(responseBody)
} else if (code in 400..499 && responseIsFromS3Server) {
return Result.ApiError(MerchantException(cause = translateS3Error(response.raw().body)))
} else if (code in 200..204) {
val contentName = contentType?.toString() ?: "null"
val message = "Unexpected content type: $contentName"
return Result.ApiError(
MerchantException(
SupportCode.CONTENT_TYPE_EXCEPTION,
message
)
)
} else {
return Result.ApiError(
MerchantException(
supportCode = SupportCode.COMMUNICATION_EXCEPTION,
message = "Unexpected status code: $code"
)
)
}
} else {
if ("application/vnd.error+json".toMediaTypeOrNull() == contentType) {
val metadataErrorResponse = convertErrorBody(moshi, response.errorBody())
return metadataErrorResponse?.let {
Result.Error(
SwallowedException.fromMetadata(
context = context,
supportMetadata = it,
moshi = moshi
)
)
} ?: Result.Error(Exception("Something went wrong"))
} else {
return Result.ApiError(
MerchantException(
message = "Non-successful response without detailed error body",
// supportCode = SupportCode.UNKNOWN_ERROR
)
)
}
}
} catch (e: Exception) {
return when (e) {
is FileNotFoundException -> Result.ApiError(
SwallowedException(
supportCode = SupportCode.PHOTO_FILE_NOT_FOUND,
cause = e,
)
)
else -> Result.ApiError(
MerchantException(
supportCode = SupportCode.TRANSPORT_EXCEPTION,
cause = e
)
)
}
}
}
val metadataErrorResponse = convertErrorBody(moshi, response.errorBody()) above is what parses the error's JSON body.
This handleResponse() method allows me to do 👇🏽 in the repository
suspend fun getLoginTokenFromBackend(
username: String,
password: String,
encodeUri: ((String) -> String)? = null
): Result<LoginToken?> {
...encoding code here...
return handleResponse(
retrofitService.getLoginToken(
authorizationHeader = authHeader, username = "op=$encodedUsername"
)
)
}
Retrofit endpoint is now wrapped with Response
Running setState { copy(loginToken = Loading()) } at the beginning of the ViewModel method works and correctly leads to showing the circular progress indicator in the fragment. Ideally, I wouldn't have to (remember to) add this setState { copy(loginToken = Loading()) } code before all calls to any repository in all of my ViewModels.
I tried my hand at a custom implemention of .execute with the .customExecute() method below, but it didn't work.
suspend fun <T> Flow<Result<T>>.customExecute(stateReducer: S.(Result<T>) -> S) {
setState { stateReducer(Result.Loading) }
safeCollect { setState { stateReducer(it) } }
}
👇🏽 didn't work 😕 with the method above
viewModelScope.launch(IO) {
flowOf(
loginRepository.getLoginTokenFromBackend(
state.usernameText,
state.passwordText,
encodeUri = { stringToEncode: String? -> Uri.encode(stringToEncode) }
)
).customExecute {
it.mappedResponse()?.let { response ->
copy(loginToken = response)
} ?: copy()
}
}
Any ideas on what a correct custom .execute might look like so that Loading is always first set? Is the .customExecute() method above, close to being correct?
Some other way to handle the response instead of 👇🏽?
it.mappedResponse()?.let { response ->
copy(loginToken = response)
} ?: copy()
Regarding error handling, I've seen:
They're all in my ticket's territory but not really answering what I'm wondering. So yea, @gpeal @elihart @rossbacher, I'd really appreciate any and all thoughts on how to approach this. It seems like a fairly standard use case for Mavericks-loving users. I hope this comment, and hopefully discussion, becomes a resource for anyone else trying to figure out similar things with Mavericks.
@gpeal @elihart @rossbacher, I made what I thought was a pretty specific and thorough thread at #692 but never heard back, which is totally fine. I get that life happens and open-source work is a beast of its own. Really hoping for a response on this ticket's topics 👇🏽 though
tldr; Is there a way to combine the latest version of Mavericks with "custom" network response and error handling? A way to combine Mavericks with Retrofit API
Response/callback/adapters?This whole ticket is born out two high-level end goals, which are to:
Loadingso that progress indicators and other UI can tap into a network call'sLoadingstate.Here are examples of the JSON body that the backend sends the app when there's a error:
or
I've already got the JSON object modeled as POJO 👇🏽
Ideally, the Mavericks Async
Failwould end up with themessagein it.When looking at Mavericks'
data class Fail, it seems that users aren't given much flexibility.Ideally, I wouldn't have to create my own custom sealed class setup with
Uninitialized,Error,Success,Loading, and so on. I like Mavericks'Asyncclass, especially because it hasLoadingbuilt in and I've got various progress indicator UI tied toLoading.App context
Single activity, multiple fragment Android app. Using Kotlin, Retrofit, Coroutines, Moshi, View binding, Navigation Component, and Hilt. Nothing crazy.
App architecture:
Fragment<->ViewModel<->Repository<–>RetrofitService. Nothing crazy.Mavericks and Retrofit Gradle versions
Log in as an example scenario of what I'm talking about:
LoginStatehasval loginToken: Async<LoginToken> = Uninitializedand 👇🏽 in the ViewModelViewModel's log in method 👇🏽
Repository method 👇🏽
Retrofit service endpoint is
Right now, this setup works correctly and shows a circular progress indicator when
loginTokenisLoading, thanks to theState's derived propertyval showProgressInButton = loginToken is Loadingand the
Fragment'sinvalidate()After submitting an incorrect password, the backend sends a
401error with a JSONThe Timber line above prints
LoginState loginToken = retrofit2.HttpException: HTTP 401 , cause = null, message = HTTP 401causeandmessagearenull. Is there a recommended way to (simply) go from receiving the JSON body to the MavericksFailhaving some sort of content from the JSON body? A hacky way? Casting somehow?Or is Mavericks intentionally designed to just throw a pretty generic
Failwith the error code and nothing else? Does the backend error need to be structured differently for Mavericks' code to correctly interpret it and provide more info in theFail?Because I don't see a way to get access to the JSON info, I'm looking into the often suggested route of creating and using a custom sealed class with networking states. From what it looks like, it's essentially my own version of Mavericks'
Async.ktclass 👇🏽Custom
Result.ktsituationI'm sure there are issues with it, but I've got 👇🏽 for now.
So, with the
Result.ktclass above, I can doval loginToken: Result<LoginToken?> = Result.Uninitializedin theLoginState.Doing
Result<LoginToken>instead ofAsync<LoginToken>now means:.executeextension function no longer worksLoadingaspect of the networking callLoadingto have the circular progress indictor simply tied toResult.Loading.If I MUST have a custom
Result.ktclass to create custom errors, any thoughts on a way to somehow combine it with Mavericks? I have👇🏽 working welland
Capturing exceptions
I've created a
handleResponse()method, which takes in a Retrofit2Responseobject and checks the response object. I'm hoping to use it for all networking response object values that are wrapped withResultin any of myViewModels (e.g.val loginToken: Result<LoginToken?> = Result.Uninitialized)Do notice that it returns some type of
Resultfrom the customResult.ktclass I made and described above.val metadataErrorResponse = convertErrorBody(moshi, response.errorBody())above is what parses the error's JSON body.This
handleResponse()method allows me to do 👇🏽 in the repositoryRetrofit endpoint is now wrapped with
ResponseRunning
setState { copy(loginToken = Loading()) }at the beginning of theViewModelmethod works and correctly leads to showing the circular progress indicator in the fragment. Ideally, I wouldn't have to (remember to) add thissetState { copy(loginToken = Loading()) }code before all calls to any repository in all of myViewModels.I tried my hand at a custom implemention of
.executewith the.customExecute()method below, but it didn't work.👇🏽 didn't work 😕 with the method above
Any ideas on what a correct custom
.executemight look like so thatLoadingis always first set? Is the.customExecute()method above, close to being correct?Some other way to handle the response instead of 👇🏽?
Regarding error handling, I've seen:
They're all in my ticket's territory but not really answering what I'm wondering. So yea, @gpeal @elihart @rossbacher, I'd really appreciate any and all thoughts on how to approach this. It seems like a fairly standard use case for Mavericks-loving users. I hope this comment, and hopefully discussion, becomes a resource for anyone else trying to figure out similar things with Mavericks.