Requests to the network with Clean Architecture – Handling errors from the server. Boilerplate part 3

We continue to supplement the series of articles. Today we will analyze how easy it is to process an error from the server. What we will analyze today is closely related to previous articles, and if you have not read them yet, I strongly recommend that you follow the links, read and return.

From this release, we will write applications with the following logic:

  • Authorization – we implement authorization, add error handling from the server there, save tokens and check for authorization. And with successful authorization, navigation to the main page.

  • Main page – on the main page we will have a data sheet pulldown with pagination, when you click on a certain item with data, it will navigate us to the detailed page.

  • Detail page – we implement a simple detailed page where the id is passed where we do pulldowns on the same id.

Let’s combine what we have already analyzed, navigation and queries. Then we start error handling. Will need to create sealed Class NetworkError in the module domain:

sealed class NetworkError {
    class Api(val error: MutableMap<String, List<String>>) : NetworkError()
    class Unexpected(val error: String) : NetworkError()
}

Thinking about error handling of the fields where the user enters data (hereinafter referred to as “inputs”) from the server in applications, my colleagues and I came to the optimal solution. Errors will be sent as key value i.e. Map. In the key, the name of the input will come in the value of a sheet of errors. Imagine a simple case, an error for the email input. The key will come "email" meaning will come ["incorrect email type", "some error"]. We accept a list of errors due to the fact that there may be several errors, but you can simply process this moment for yourself, conditionally if only one error always comes, accept just a regular string, that is String. Also, we will use MutableMap later you will understand why.

Change the processing type in the layer repository domain and wrap our request in Response<T> in order to receive errorBody().

interface SignInRepository {
  
    fun signIn(userSignIn: UserSignIn): Flow<Either<NetworkError, Sign>>
}
interface SignInApiService {

    @POST
    suspend fun signIn(@Body userSignInDto: UserSignInDto): Response<SignInResponse>
}

Next, we refactor the implementation of mappers from the layer data into layer domain in order to be able to use them together with generics, then you will understand why we need this. Creating an interface DataMapper<T, S> in layer data and additionally create extension functions that will help us implement everything.

interface DataMapper<T, S> {
    fun T.mapToDomain(): S
}

fun <T : DataMapper<T, S>, S> T.mapToDomain() = this.mapToDomain()

We implement in SignInResponse

class SignInResponse(
    @SerializedName("token")
    val token: String
) : DataMapper<SignInResponse, SignIn> {

    override fun SignInResponse.mapToDomain() = SignIn(
        token
    )
}

And after all this, we change the implementation of the method doRequest now let’s call it doNetworkRequest() because it will only work with requests to the network. And as advised in the comments, the parameter was removed from the last article doSomethingInSuccess.

abstract class BaseRepository {

    /**
     * Do network request
     *
     * @return result in [flow] with [Either]
     */
    protected fun <T : DataMapper<T, S>, S> doNetworkRequest(
        request: suspend () -> Response<T>
    ) = flow<Either<NetworkError, S>> {
        request().let {
            if (it.isSuccessful && it.body() != null) {
                emit(Either.Right(it.body()!!.mapToDomain()))
            } else {
                emit(Either.Left(NetworkError.Api(it.errorBody().toApiError())))
            }
        }
    }.flowOn(Dispatchers.IO).catch { exception ->
        emit(
            Either.Left(NetworkError.Unexpected(exception.localizedMessage ?: "Error Occurred!"))
        )
    }

    /**
     * Convert network error from server side
     */
    private fun ResponseBody?.toApiError(): MutableMap<String, List<String>> {
        return Gson().fromJson(
            this?.string(),
            object : TypeToken<MutableMap<String, List<String>>>() {}.type
        )
    }
}

Now let’s break down the code. Generic T which is our model SignInResponse is now an implementing interface DataMapper<T, S>. A generic S is already a layer model domain. In parameter request is now back T with wrap Response. Next, the usual check for isSuccessful and null. Upon successful processing, returns Either.Right with our model SignInResponse which, using the method mapToDomain() map in SignIn layer model domain. On error from the server, we return Either.Left only since NetworkError.Api in which we call errorBody() method and turn it into our MutableMap using the function toApiError().

Let’s see what it looks like now SignInRepositoryImpl.

class SignInRepositoryImpl @Inject constructor(
    private val service: SignInApiService
) : BaseRepository(), SignInRepository {

    override fun signIn(userSignIn: UserSignIn) = doNetworkRequest {
        service.signIn(userSignIn.fromDomain()).also { data ->
            data.body()?.let {
                // save token
                it.token
            }
        }
    }
}

If we are on the level data saved the token, it is no longer necessary to pass it to the layer presentation‘a.

AT UseCase‘ah everything remains the same so let’s move on ViewModel. Functions need to be tweaked. collectRequest(). Just change the return type from String on the NetworkError.

abstract class BaseViewModel : ViewModel() {

    /**
     * Creates [MutableStateFlow] with [UIState] and the given initial value [UIState.Idle]
     */
    @Suppress("FunctionName")
    fun <T> MutableUIStateFlow() = MutableStateFlow<UIState<T>>(UIState.Idle())

    /**
     * Collect network request
     *
     * @return [UIState] depending request result
     */
    protected fun <T> Flow<Either<NetworkError, T>>.collectRequest(
        state: MutableStateFlow<UIState<T>>,
    ) {
        viewModelScope.launch(Dispatchers.IO) {
            state.value = UIState.Loading()
            this@collectRequest.collect {
                when (it) {
                    is Either.Left -> state.value = UIState.Error(it.value)
                    is Either.Right -> state.value = UIState.Success(it.value)
                }
            }
        }
    }

    /**
     * Collect network request with mapping from domain to ui
     *
     * @return [UIState] depending request result
     */
    protected fun <T, S> Flow<Either<NetworkError, T>>.collectRequest(
        state: MutableStateFlow<UIState<S>>,
        mappedData: (T) -> S
    ) {
        viewModelScope.launch(Dispatchers.IO) {
            state.value = UIState.Loading()
            this@collectRequest.collect {
                when (it) {
                    is Either.Left -> state.value = UIState.Error(it.value)
                    is Either.Right -> state.value = UIState.Success(mappedData(it.value))
                }
            }
        }
    }
}

As we remember from the changes in the last article, we have two functions collectRequest()one with mapping the other without.

Here we have changed the error return type to NetworkErrorbut now we have UIState.Error does not take a value Stringso we change there too.

sealed class UIState<T> {
    class Idle<T> : UIState<T>()
    class Loading<T> : UIState<T>()
    class Error<T>(val error: NetworkError) : UIState<T>()
    class Success<T>(val data: T) : UIState<T>()
}

Next, we change the same in the method collectUIState() which is located in BaseFragment‘e.

abstract class BaseFragment<ViewModel : BaseViewModel, Binding : ViewBinding>(
    @LayoutRes layoutId: Int
) : Fragment(layoutId) {
    
    // ...

    /**
     * Collect [UIState] with [collectFlowSafely] and optional states params
     * @param state for working with all states
     * @param onError for error handling
     * @param onSuccess for working with data
     */
    protected fun <T> StateFlow<UIState<T>>.collectUIState(
        lifecycleState: Lifecycle.State = Lifecycle.State.STARTED,
        state: ((UIState<T>) -> Unit)? = null,
        onError: ((error: String) -> Unit),
        onSuccess: ((data: T) -> Unit)
    ) {
        collectFlowSafely(lifecycleState) {
            this.collect {
                state?.invoke(it)
                when (it) {
                    is UIState.Idle -> {}
                    is UIState.Loading -> {}
                    is UIState.Error -> onError.invoke(it.error)
                    is UIState.Success -> onSuccess.invoke(it.data)
                }
            }
        }
    }
}

Next in processing, we need to somehow display the error from Map‘s into an error inside an input. The same is wrong username or passwordwe will return the error key usernameand the value is a sheet of strings, it may contain errors on the type required, incorrect and so on. Now how do we display this, of course we can manually prescribe everything, but laziness is the engine of progress (do not write repetitive code 🙂 ) so we will write an extension function for this. To understand in which inputs what to network, we will use the attribute tag at the views. If there is an error with the key username then the input will have tag username.

We create .kt file and name it NetworkErrorExtensions and write processing methods there.

fun NetworkError.setupApiErrors(vararg inputs: TextInputLayout) {
    if (this is NetworkError.Api) {
        for (input in inputs) {
            error[input.tag].also { error ->
                if (error == null) {
                    input.isErrorEnabled = false
                } else {
                    input.error = error.joinToString()
                    this.error.remove(input.tag)
                }
            }
        }
    }
}

fun NetworkError.setupUnexpectedErrors(context: Context) {
    if (this is NetworkError.Unexpected) {
        Toast.makeText(context, this.error, Toast.LENGTH_LONG).show()
    }
}
  • NetworkError.setupApiErrors() – Function expands sealed class NetworkErrortakes as a parameter vararg from TextInputLayout and then check it Api or not, if so then goes to further logic. It runs through the loop through the inputs that came into the parameter, after that, by their tag, it takes out the key from Map if there is such a key, then it displays an error and an input and deletes this key and Map and the cycle repeats.

  • NetworkError.setupUnexpectedErrors() – Extension function sealed class NetworkError which just checks the type Unexpected and if so, it displays an error in the form Toast.

Next, we call these methods in SignInFragment

@AndroidEntryPoint
class SignInFragment : BaseFragment<SignInViewModel, FragmentSignInBinding>(
    R.layout.fragment_sign_in
) {

    // ...

    override fun setupSubscribers() = with(binding) {
        viewModel.signInState.collectUIState(
            state = {
                it.setupViewVisibility(groupSignIn, loaderSignIn, true)
            },
            onError = {
                it.setupApiErrors(
                    inputLayoutSignInUsername,
                    inputLayoutSignInPassword
                )
                it.setupUnexpectedErrors(requireContext())
            },
            onSuccess = {
                findNavController().navigate(R.id.action_signInFragment_to_homeFragment)
            }
        )
    }
}

On this, everything as a result will look like this.

PS Of course, this is all purely for example. On the authorization page, you can never display an error in inputs, everything should be displayed in Toast, but that’s all for examples :). Below are links to repositories.

Similar Posts

Leave a Reply