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