I think many developers have encountered a situation when some of the server endpoints are not ready, but it needs to be added to the application. This happens especially often with negative cases like all sorts of errors that need to be processed in the application, and which are often done last.
There are many ways to solve this case, for example, you can substitute data at different stages of their processing or use OkHttp MockWebServer
https://github.com/square/okhttp/tree/master/mockwebserver
or similar solutions.
After thinking about possible ways, I made an interceptor to change the server response at the earliest possible stage. And it uses a map, in which a simplified model of the server response is located by the key in the form of HttpUrl, then the url is pulled out of the chain, and if it is in the map, the response model is pulled out and the data from it is substituted. I did not add headers, but this can be done very easily, as well as adding as many responses as you like for different urls. Theoretically, it is possible to track not only URL, but also some other parameters. Here is what we ended up with:
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() }
I will also provide useful interceptors and converters
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 // the point here is that if a List comes in, it is converted to a class 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 } } } }