Думаю многие разработчики сталкивались с ситуацией, когда какой то из эндпоинтов сервера не готов, но его нужно добавить в приложение. Особенно часто такое бывает с негативными кейсами вроде всевозможных ошибок, которые нужно обрабатывать в приложении, и которые часто делаются в последнюю очередь.
Способов решения в данном случае много, можно например подставлять данные на разных этапах их обработки или использовать 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 } } } }