Mock відповідей сервера в Android розробці за допомогою Interceptor
Вступ
Думаю, багато розробників стикалися із ситуацією, коли якийсь із ендпоінтів сервера не готовий, але його потрібно додати до застосунку. Особливо часто таке буває з негативними кейсами на кшталт усіляких помилок, які потрібно обробляти в застосунку, і які часто роблять в останню чергу.
Способів розв'язання в цьому випадку багато, можна наприклад підставляти дані на різних етапах їх обробки або використовувати OkHttp MockWebServer
https://github.com/square/okhttp/tree/master/mockwebserver
або подібні рішення.
Подумавши над можливими способами, я зробив interceptor, щоб змінювати відповідь сервера на найранішому з можливих етапів. А в ньому використовується map, у якій за ключем у вигляді HttpUrl лежить спрощена модель відповіді сервера, далі з chain дістається url, і якщо він є в map, дістається модель відповіді та підставляються дані з неї. Я не став додавати headers, але це можна дуже просто зробити, як і додати скільки завгодно відповідей за різними url. Теоретично можна відстежувати не лише url, а й якісь інші параметри. Ось що в підсумку вийшло:
MockResponse data class
data class MockResponse( val isEnabled: Boolean, val body: String, val message: String, val serverCode: Int, val contentType: String = "application/json")Конфігурація mockResponses
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, ) )Клас MockInterceptor
import okhttp3.Interceptorimport okhttp3.MediaType.Companion.toMediaTypeOrNullimport okhttp3.Protocolimport okhttp3.Responseimport 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) } }
}Налаштування OkHttpClient
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.Interceptorimport 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.KoinComponentimport 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.ResponseBodyimport okhttp3.ResponseBody.Companion.asResponseBodyimport org.koin.core.component.KoinComponentimport org.koin.core.component.injectimport retrofit2.Converterimport retrofit2.Retrofitimport retrofit2.converter.gson.GsonConverterFactoryimport retrofit2.converter.simplexml.SimpleXmlConverterFactoryimport 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.Gsonimport com.google.gson.reflect.TypeTokenimport okhttp3.ResponseBodyimport org.koin.core.component.KoinComponentimport org.koin.core.component.injectimport retrofit2.Converterimport retrofit2.Retrofitimport 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.Interceptorimport 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 } } }}