Pull to refresh

Запросы в сеть с Clean Architecture — Обработка ошибок с сервера. Boilerplate ч. 3

Reading time7 min
Views6.1K

Продолжаем дополнять серию статей. Сегодня мы разберем как легко обработать ошибку с сервера. То что мы будем сегодня разбирать тесно связано с предыдущими статьями и если вы их ещё не читали то настоятельно рекомендую перейти по ссылкам, прочитать и вернуться.

С этого выпуска мы напишем приложения с такой логикой:

  • Авторизация - реализуем авторизацию, добавим туда обработку ошибок с сервера, сохраним токены и проверку на авторизованность. И при успешной авторизации навигацию на главную страницу.

  • Главная страница - на главной странице у нас будет стягивания листа данных с пагинацией, при нажатии на определенны item с данными нас навигирует в детальную страницу.

  • Детальная страница - реализуем простую детальную страницу куда передается id'шка где мы делаем стягивания по той же id.

Объединим то что мы уже ранее разбирали, навигацию и запросы. После начинаем обработку ошибок. Нужно будет создать sealed класс NetworkError в модуле domain:

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

Думая над обработкой ошибок полей куда юзер вводит данные (далее "инпуты") с сервера в приложениях мы с коллегами пришли к оптимальныму решению. Ошибки будут отправляться в виде ключ значение то есть Map. В ключе будет приходить название инпута в значении лист из ошибок. Представим простой кейс, ошибка для инпута email. Ключ придет "email" значение придет ["incorrect email type", "some error"]. Принимаем лист из ошибок по причине того что ошибок может быть несколько, но вы можете просто этот момент обработать под себя, условно если всегда будет приходить только одна ошибка принимать просто обычную строку то есть String. А ещё мы будем использовать MutableMap позже поймете для чего.

Меняем типа обработки в репозитории слоя domain и обернем наш запрос в Response<T> для того чтобы получать errorBody().

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

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

Далее мы рефакторим реализацию мапперов с слоя data в слой domain для того чтобы могли их использовать вместе с generic'ами, далее поймете зачем нам это. Создаем интерфейс DataMapper<T, S> в слое data и дополнительно создаем функции расширения которая поможет нам все реализовать.

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

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

Имплементируем в SignInResponse

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

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

И после всего этого меняем реализацию метода doRequest теперь будем называть его doNetworkRequest() потому что будет он работать только с запросами в сеть. И ещё как посоветовали в комментариях с прошлой статьи был убран параметр 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
        )
    }
}

Теперь давайте разберем код. Generic T коим является наша моделька SignInResponse теперь является имплементирующим интерфейс DataMapper<T, S>. А generic S является уже моделькой слоя domain. В параметре request теперь возвращается T с оберткой Response. Далее обычная проверка на isSuccessful и null. При успешной обработке возвращается Either.Right с нашей моделькой SignInResponse который с помощью метода mapToDomain() мапиться в SignIn модельку слоя domain. При ошибке с сервера мы возвращаем Either.Left только уже с NetworkError.Api в котором мы вызываем errorBody() метод и превращаем его в наш MutableMap с помощью функции toApiError().

Давайте посмотрим как теперь выглядит 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
            }
        }
    }
}

Если что мы на уровне data сохранили токен, уже не обязательно передавать его в слой presentation'a.

В UseCase'ах все остается так же поэтому перейдем во ViewModel. Там нужно будет подправить функции collectRequest(). Просто меняем возвращаемый тип с String на 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))
                }
            }
        }
    }
}

Как мы помним из изменений прошлой статьи, у нас появилось две функции collectRequest(), один с маппингом другой без.

Здесь мы поменяли тип возвращения ошибок на NetworkError, но теперь у нас UIState.Error не принимает значение String, значит там тоже меняем.

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

Далее меняем так же в методе collectUIState() который находиться в 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)
                }
            }
        }
    }
}

Далее в обработке нам нужно как-то отобразить ошибку из Map'ы в ошибку внутри инпута. Тот же не правильный username или password, мы вернем ошибку ключ username, а значение лист из строк, в нем может быть ошибки по типу required, incorrect и так далее. Теперь как нам отобразить это, конечно мы можем каждый вручную все прописывать, но лень двигатель прогресса (не пишите повторяющийся код :) ) поэтому мы напишем функцию расширения для этого. Чтобы понимать в какие инпуты что сетить мы будем использовать атрибут tag у вьюшек. Если будет ошибка с ключем username то и у инпута будет tag username.

Создаем .kt файл и назовем его NetworkErrorExtensions и пишем там методы обработки.

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() - Функция расширяет sealed class NetworkError, принимает в параметр vararg из TextInputLayout и далее проверяет это Api или нет, если да то идет к дальнейшей логике. Пробегается циклом по инпутам которые пришли в параметр, после по их тегу достает ключ из Map если такой ключ есть то он отображает ошибку и инпута и удаляет этот ключ и Map и цикл повторяется.

  • NetworkError.setupUnexpectedErrors() - Функция расширения sealed class NetworkError который просто проверяет тип Unexpected и если да то отображает ошибку в виде Toast.

Далее мы вызываем эти методы в 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)
            }
        )
    }
}

На этом все в результате все будет выглядить вот так.

P. S. Конечно же это все чисто для примера. На странице авториации никогда нельзя отображать ошибку в инпутах, все должно отображаться в Toast, но это все для примеров :). Снизу ссылки на репозитории.

Tags:
Hubs:
Rating0
Comments2

Articles