Перейти к содержимому

Mock ответов сервера в Android разработке при помощи Interceptor

Думаю многие разработчики сталкивались с ситуацией, когда какой то из эндпоинтов сервера не готов, но его нужно добавить в приложение. Особенно часто такое бывает с негативными кейсами вроде всевозможных ошибок, которые нужно обрабатывать в приложении, и которые часто делаются в последнюю очередь.

Способов решения в данном случае много, можно например подставлять данные на разных этапах их обработки или использовать OkHttp MockWebServer

https://github.com/square/okhttp/tree/master/mockwebserver

или подобные решения.

Подумав над возможными способами, я сделал interceptor, чтобы менять ответ сервера на самом раннем этапе из возможных. А нем используется map, в которой по ключу в виде HttpUrl лежит упрощенная модель ответа сервера, далее из chain вытаскивается url, и если он есть в map, вытаскивается модель ответа и подставляются данные из нее. Я не стал добавлять headers, но это можно очень просто сделать, как и добавить сколько угодно ответов по разным url. Теоретически можно отслеживать не только url, но и какие то другие параметры. Вот что в итоге получилось:

data class MockResponse(
    val isEnabled: Boolean,
    val body: String,
    val message: String,
    val serverCode: Int,
    val contentType: String = "application/json"
)
const val DEBUG_MOCK_INTERCEPTOR_ENABLED = false

val mockResponses = mutableMapOf<String, MockResponse>().apply {
    put(
        key = "https://n",
        value = MockResponse(
            isEnabled = false,
            body = "",
            message = "",
            serverCode = 200,
        )
    )
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody


class MockInterceptor : Interceptor {

  
    companion object {
        private const val TAG = "MockInterceptorTag"
    }

    
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        val mockResponse = mockResponses[request.url.toString()]
        return if (BuildConfig.DEBUG &&
            DEBUG_MOCK_INTERCEPTOR_ENABLED &&
            mockResponse != null &&
            mockResponse.isEnabled
        ) {
            logDebug("Intercepted request: ${request.url}", TAG)
            Response.Builder()
                .code(mockResponse.serverCode)
                .message(mockResponse.message)
                .request(chain.request())
                .protocol(Protocol.HTTP_1_0)
                .body(
                    mockResponse.body
                        .toByteArray()
                        .toResponseBody(mockResponse.contentType.toMediaTypeOrNull())
                )
                .addHeader("Content-type", "application/json")
                .build()
        } else {
            chain.proceed(request)
        }
    }

    
}
single<OkHttpClient>(named(OKHTTP)) {
        logDebug("create OkHttpClient)", TAG)
        OkHttpClient.Builder().apply {
            val trustAllCerts = arrayOf<TrustManager>(object : X509TrustManager {
                @Throws(CertificateException::class)
                override fun checkClientTrusted(
                    chain: Array<X509Certificate>,
                    authType: String
                ) {
                    // NO IMPLEMENTATION
                }

                @Throws(CertificateException::class)
                override fun checkServerTrusted(
                    chain: Array<X509Certificate>,
                    authType: String
                ) {
                    // NO IMPLEMENTATION
                }

                override fun getAcceptedIssuers(): Array<X509Certificate> {
                    return arrayOf()
                }
            }
            )
            val protocolSSL = "SSL"
            val sslContext = SSLContext.getInstance(protocolSSL).apply {
                init(null, trustAllCerts, SecureRandom())
            }
            sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager)
            followRedirects(true)
            followSslRedirects(true)
            addInterceptor(get<ContentTypeInterceptor>())
            addInterceptor(get<HeaderInterceptor>())
            addInterceptor(get<HttpLoggingInterceptor>(named(LOGGING_INTERCEPTOR)))
            addInterceptor(get<HttpLoggingInterceptor>(named(LOG_TO_FILE_INTERCEPTOR)))
            if (BuildConfig.DEBUG && DEBUG_MOCK_INTERCEPTOR_ENABLED) {
                logDebug("add MockInterceptor", TAG)
                addInterceptor(get<MockInterceptor>())
            }
            connectTimeout(TIMEOUT, TimeUnit.SECONDS)
            writeTimeout(TIMEOUT, TimeUnit.SECONDS)
            readTimeout(TIMEOUT, TimeUnit.SECONDS)
        }.build()
    }

также приведу полезные интерсепторы и конверторы

import okhttp3.Interceptor
import okhttp3.Response


class ContentTypeInterceptor : Interceptor {

  
    companion object {
        private const val TAG = "ContentTypeInterceptorTag"
    }

    
    override fun intercept(chain: Interceptor.Chain): Response {
        val originalResponse = chain.proceed(chain.request())
        val currentContentType = originalResponse.header("Content-Type")
        val source = originalResponse.body?.source()
        source?.request(Long.MAX_VALUE)
        val buffer = source?.buffer
        val responseBodyString = buffer?.clone()?.readString(Charsets.UTF_8)
        val newContentType = when {
            responseBodyString?.trim()?.startsWith("{") == true -> "application/json"
            responseBodyString?.trim()?.startsWith("<") == true -> "application/xml"
            else -> null
        }
        return if (
            newContentType != null && (currentContentType == null || !currentContentType.contains(
                newContentType,
                ignoreCase = true
            ))
        ) {
            logDebug("Content type replace with $newContentType", TAG)
            originalResponse.newBuilder()
                .header("Content-Type", newContentType)
                .build()
        } else {
            originalResponse
        }
    }
}
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject


class HeaderInterceptor : okhttp3.Interceptor,
    KoinComponent {

      
    companion object {
        private const val TAG = "HeaderInterceptorTag"
    }

    
    private val preferences: PreferencesRepository by inject()

    
    override fun intercept(chain: okhttp3.Interceptor.Chain): okhttp3.Response {
        logDebug("intercept", TAG)
        val original = chain.request()
        val request = original.newBuilder()
            .header("Content-Type", "application/json")
            .header("Cookie", "KEYCLOAK_LOCALE=bg")
            .method(original.method, original.body)
        val token = preferences.readApplicationInfo()?.accessToken
        if (!token.isNullOrEmpty()) {
            logDebug("add token: $token", TAG)
            request.header("Authorization", "Bearer $token")
        }
        return chain.proceed(request.build())
    }
}
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.asResponseBody
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import retrofit2.Converter
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.converter.simplexml.SimpleXmlConverterFactory
import java.lang.reflect.Type


class NullOrEmptyConverterFactory : Converter.Factory(), KoinComponent {

  
    companion object {
        private const val TAG = "NullOrEmptyConverterFactoryTag"
    }

    
    private val gsonConverterFactory: GsonConverterFactory by inject()
    private val simpleXmlConverterFactory: SimpleXmlConverterFactory by inject()

    
    override fun responseBodyConverter(
        type: Type,
        annotations: Array<Annotation>,
        retrofit: Retrofit
    ): Converter<ResponseBody, *> {
        return Converter<ResponseBody, Any?> { body ->
            if (body.contentLength() != 0L) {
                val contentType = body.contentType()
                val source = body.source()
                source.request(Long.MAX_VALUE)
                val bufferClone = source.buffer.clone()
                val subtype = contentType?.subtype
                val clonedBody =
                    bufferClone.clone().asResponseBody(contentType, bufferClone.size)
                try {
                    when {
                        subtype?.contains("xml", ignoreCase = true) == true -> {
                            val converter = simpleXmlConverterFactory.responseBodyConverter(
                                type,
                                annotations,
                                retrofit
                            )
                            converter?.convert(body)
                        }

                        subtype?.contains("json", ignoreCase = true) == true -> {
                            val converter = gsonConverterFactory.responseBodyConverter(
                                type,
                                annotations,
                                retrofit
                            )
                            converter?.convert(body)
                        }

                        else -> {
                            body.string()
                        }
                    }
                } catch (e: Exception) {
                    logError("Content type not valid, Exception: ${e.message}", e, TAG)
                    clonedBody.string()
                }
            } else null
        }
    }
}
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import okhttp3.ResponseBody
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import retrofit2.Converter
import retrofit2.Retrofit
import java.lang.reflect.Type


// тут смысл в том, что если приходит List, он преобразуется в класс
class ArrayConverterFactory : Converter.Factory(), KoinComponent {

  
    companion object {
        private const val TAG = "ArrayConverterFactoryTag"
    }

    
    val gson: Gson by inject()

    
    override fun responseBodyConverter(
        type: Type,
        annotations: Array<Annotation>,
        retrofit: Retrofit
    ): Converter<ResponseBody, *> {
        return Converter<ResponseBody, Any?> { body ->
            when {
                type is Class<*> && type == SomeResponse::class.java -> {
                    SomeResponse(
                        data = generateList(
                            body = body,
                            type = object : TypeToken<String>() {}.type,
                        )
                    )
                }

                else -> {
                    retrofit.nextResponseBodyConverter<Any?>(
                        this@ArrayConverterFactory,
                        type,
                        annotations
                    ).convert(body)
                }
            }
        }
    }

    
    private fun <T> generateList(body: ResponseBody, type: Type): List<T> {
        val listType = TypeToken.getParameterized(List::class.java, type).type
        return gson.fromJson(body.charStream(), listType)
    }
}
import okhttp3.Interceptor
import okhttp3.Response


class TempHeaderSavingInterceptor : Interceptor {

  
    companion object {
        private const val MOCK_STEP_HEADER_FLOW = "X-Flow"
        private const val MOCK_STEP_HEADER_STEP = "X-State"
        private var lastRequestHeaderFlow: String? = null
        private var lastRequestHeaderStep: String? = null
    }

    
    override fun intercept(chain: Interceptor.Chain): Response {
        val builder = chain.request().newBuilder()
        lastRequestHeaderFlow?.let {
            builder.header(MOCK_STEP_HEADER_FLOW, it)
        }
        lastRequestHeaderStep?.let {
            builder.header(MOCK_STEP_HEADER_STEP, it)
        }
        val req = builder.build()
        return chain.proceed(req).also { response ->
            response.headers[MOCK_STEP_HEADER_FLOW]?.let {
                lastRequestHeaderFlow = it
            }
            response.headers[MOCK_STEP_HEADER_STEP]?.let {
                lastRequestHeaderStep = it
            }
        }
    }
}

Copyright: Roman Kryvolapov