androidkotlin-multiplatformktor-client

How to intercept and edit in a http request the body using ktor-client (KMM)


My task is to add to every http post request, which is being send via the ktor client, specific parameters to the body. When I was using retrofit, I did this easily by using Interceptors. Due to switch to KMM, we need to convert those interceptors to kTor. I'm currently using okhttp3 as the engine but I want to switch to CIO due to its multiplatform capabilities. Dunno if the switch would make a big difference to the interceptors though.

I tried it but it failed until now and currently I'm a little bit helpless how to fix this issue.

Here was my approach, which wasnt working.

I know that kTor also has interceptors like retrofit. Therefore I created a custom interceptor which included my networkmanager interceptor. I then updated within the networkmanager interceptor the body within the context. Shouldnt this then override the body at the initial http request?

NetworkModule:

private fun provideKtorClient(
    config: AppConfig,
    networkParamsInterceptor: NetworkParamsInterceptor,
    json: Json,
): HttpClient {
    return HttpClient(OkHttp) {
        expectSuccess = false
        install(Logging) {
            logger = Logger.DEFAULT
            level = if (BuildConfig.DEBUG) LogLevel.ALL else LogLevel.NONE
        }
        install(ContentNegotiation) {
            json(json)
        }
        defaultRequest {
            url(config.baseUrl)
            contentType(ContentType.Application.Json)
        }
        engine {}

        install(InterceptorImpl) {
            interceptor = networkParamsInterceptor
        }
    }
}

private fun provideJson(): Json {
    return Json {
        ignoreUnknownKeys = true
        isLenient = true
        coerceInputValues = true
    }
}

InterceptorImpl: (I tried here also to add the newly body to the proceedWith method within the install method, but this threw me also errors that the content could not be correctly serialized)

interface HttpClientInterceptor {
    fun intercept(context: HttpRequestBuilder): HttpRequestBuilder
}

class InterceptorImpl(private var config: Config) {
       class Config {
        lateinit var interceptor: HttpClientInterceptor
    }

    companion object : HttpClientPlugin<Config, InterceptorImpl> {
        override val key: AttributeKey<InterceptorImpl> = AttributeKey("CustomInterceptors")

        override fun prepare(block: Config.() -> Unit): InterceptorImpl {
            val config = Config().apply(block)
            return InterceptorImpl(config)
        }

        override fun install(plugin: InterceptorImpl, scope: HttpClient) {
            scope.requestPipeline.intercept(HttpRequestPipeline.State) {
                plugin.config.interceptor.intercept(context)
            }
        }
    }

}

NetworkParamsInterceptor: (Here the data gets added to the new body and somehow this doesnt do anything. When logging the data, the body gets added correctly with all the necessary data, but when finally sending the request, then the body is again reset to only the parameter which has been send from the initial request --> will be shown after this file)

class NetworkParamsInterceptor(
    private val context: Context,
    private val userPreferences: UserPreferences,
) : HttpClientInterceptor {

    private val defaultParams: Map<String, String> by lazy { buildDefaultParams() }

    @OptIn(InternalAPI::class)
    override fun intercept(context: HttpRequestBuilder): HttpRequestBuilder {
        return context.apply {
            // Set default headers
            defaultParams.forEach { (key, value) -> header(key, value) }

            // For POST requests, add default parameters to the body
            if (method.value.equals("POST", ignoreCase = true)) {
                val contentType = headers["Content-Type"]
                if(body is FormDataContent){
                    val bod = body as FormDataContent
                    bod.formData.forEach { s, strings -> println("XXX 2: $s, $strings")}
                    val newBody = mergeParamsWithJsonBody(bod, defaultParams)
                    body = newBody
                    bodyType = typeInfo<FormDataContent>()
                }
            }
        }
    }

    private fun buildDefaultParams(): Map<String, String> {
        val map = mutableMapOf<String, String>(
            "os_version" to android.os.Build.VERSION.SDK_INT.toString(),
            "device_model" to android.os.Build.MODEL,
            "hl" to java.util.Locale.getDefault().toString()
        )

        val apiKey = context.packageManager.getApplicationInfo(
            context.packageName,
            PackageManager.GET_META_DATA
        ).metaData?.getString("findpenguins.app-api.key")
        apiKey?.let { map["key"] = it }

        val appVersion = context.packageManager.getPackageInfo(context.packageName, 0).versionName
        appVersion?.let { map["app_version"] = it }

        userPreferences.getAccessToken()?.let { map["access_token"] = it }
        return map
    }

    private fun mergeParamsWithJsonBody(oldBody: FormDataContent, params: Map<String, String>): JsonObject {
        return buildJsonObject {
            // Copy existing parameters
            oldBody.formData.forEach { s, strings ->
                put(s, JsonPrimitive(strings.first()))
            }
            // Add new parameters
            params.forEach { (key, value) ->
                put(key, JsonPrimitive(value))
            }
        }
    }

TripCalendarApiService (Initial Request where the body gets added correctly)

class TripCalendarApiService(private val client: HttpClient, private val json: Json) {

    suspend fun getCalendarTrip(tripId: String): Result<TripCalendarResponse> {
        return try {
            val response = client.post {
                url {
                    path("trip", "calendar")
                    contentType(ContentType.Application.FormUrlEncoded)
                    setBody(FormDataContent(Parameters.build {
                        append("trip", tripId)
                    }))
                }
            }

            val responseBody = response.bodyAsText()

            if (response.status == HttpStatusCode.OK) {
                val tripResponse = json.decodeFromString<TripCalendarResponse>(responseBody)
                Result.success(tripResponse)
            } else {
                Result.failure(DataError.ApiError(response.status.value, Throwable(responseBody)))
            }
        } catch (e: Exception) {
            Result.failure(DataError.UnknownError(e))
        }
    }
}

Solution

  • I fixed this issue by upgrading to the latest ktor version (which was 3.0.0-beta-1) and using ktors new plugin functionality (which can be used essentially like interceptors).

    Here is the Plugin I wrote: Note --> The callback transformRequestBody is doing all the heavy lifting and manipulates the body data

    actual class NetworkParamsPlugin : KoinComponent {
    
        private val defaultParams: Map<String, String> by lazy { buildDefaultParams() }
        private val context: Context by inject()
        private val userPreferences: DataBridge by inject()
    
        actual fun setup(config: HttpClientConfig<*>) {
            config.install(createClientPlugin("NetworkParamsPlugin") {
                onRequest { request, content ->
                    defaultParams.forEach { (key, value) -> request.header(key, value) }
                }
    
                transformRequestBody { request, body, _ ->
                    if (request.method.value.equals("POST", ignoreCase = true)) {
                        if (body is FormDataContent) {
                            val newBody = mergeParamsWithFormDataBody(body, defaultParams)
                            return@transformRequestBody FormDataContent(newBody)
                        }
                    }
    
    
                    body as OutgoingContent
                }
            })
        }
    
        private fun buildDefaultParams(): Map<String, String> {
            val map = mutableMapOf<String, String>(
                "os_version" to android.os.Build.VERSION.SDK_INT.toString(),
                "device_model" to android.os.Build.MODEL,
                "hl" to java.util.Locale.getDefault().toString()
            )
    
            val apiKey = context.packageManager.getApplicationInfo(
                context.packageName,
                PackageManager.GET_META_DATA
            ).metaData?.getString("key")
            apiKey?.let { map["key"] = it }
    
            val appVersion = context.packageManager.getPackageInfo(context.packageName, 0).versionName
            appVersion?.let { map["app_version"] = it }
    
            userPreferences.getAccessToken()?.let { map["access_token"] = it }
            return map
        }
    
        private fun mergeParamsWithFormDataBody(
            oldBody: FormDataContent,
            params: Map<String, String>
        ): Parameters {
            return Parameters.build {
                appendAll(oldBody.formData)
                // Add new parameters
                params.forEach { (key, value) ->
                    append(key, value)
                }
            }
        }
    }
    

    And then I added the plugin the following way to my ktor client:

    HttpClient(clientEngine()) {
            expectSuccess = false
            install(Logging) {
                // IMPORTANT: If you change the level or logger the body in ktor will be intercepted and remove from the final response
                // Therefore, if you want to see the body in the response, you need to change the level to NONE back!!!!
                // Open issue: https://youtrack.jetbrains.com/issue/KTOR-6474/SaveBodyPlugin-Logging-plugin-consumes-response-body
                logger = Logger.DEFAULT
                level = LogLevel.NONE
            }
            install(ContentNegotiation) {
                json(json)
            }
            install(SaveBodyPlugin) {
                disabled = false
            }
            HttpResponseValidator {
                validateResponse { response ->
                    errorHandlingInterceptor.handleResponse(response = response)
                }
            }
            defaultRequest {
                url(config.baseUrl)
                contentType(ContentType.Application.Json)
            }
    
            engine {
            }
    
            NetworkParamsPlugin().setup(this)
    
        }