View test reports →

Coverage Summary for Class: WiretapOkHttpInterceptor (dev.skymansandy.wiretap.okhttp.http)

Class Method, % Branch, % Line, % Instruction, %
WiretapOkHttpInterceptor 100% (5/5) 100% (10/10) 95.5% (21/22) 98% (150/153)
WiretapOkHttpInterceptor$intercept$1 100% (1/1) 50% (34/68) 86.5% (109/126) 78% (750/961)
WiretapOkHttpInterceptor$intercept$1$logEntryId$1 100% (1/1) 100% (7/7) 100% (52/52)
WiretapOkHttpInterceptor$intercept$1$response$1 100% (1/1) 33.3% (2/6) 100% (16/16) 86.4% (76/88)
WiretapOkHttpInterceptor$special$$inlined$inject$default$1 0% (0/1)
WiretapOkHttpInterceptor$special$$inlined$inject$default$2 0% (0/1)
WiretapOkHttpInterceptor$special$$inlined$inject$default$3 0% (0/1)
Total 72.7% (8/11) 54.8% (46/84) 89.5% (153/171) 82% (1028/1254)


 /*
  * Copyright (c) 2026 skymansandy. All rights reserved.
  */
 
 package dev.skymansandy.wiretap.okhttp.http
 
 import co.touchlab.stately.concurrency.AtomicBoolean
 import dev.skymansandy.wiretap.di.WiretapDi
 import dev.skymansandy.wiretap.domain.model.HttpLog
 import dev.skymansandy.wiretap.domain.model.ResponseSource
 import dev.skymansandy.wiretap.domain.model.RuleAction
 import dev.skymansandy.wiretap.domain.model.config.http.LogRetention
 import dev.skymansandy.wiretap.domain.model.config.http.WiretapHttpConfig
 import dev.skymansandy.wiretap.domain.model.config.http.applyHeaderAction
 import dev.skymansandy.wiretap.domain.orchestrator.HttpLogManager
 import dev.skymansandy.wiretap.domain.orchestrator.withConsoleLogging
 import dev.skymansandy.wiretap.domain.usecase.FindMatchingRuleUseCase
 import dev.skymansandy.wiretap.helper.logger.WiretapLogger
 import dev.skymansandy.wiretap.helper.markers.InternalWiretapApi
 import dev.skymansandy.wiretap.helper.util.currentNanoTime
 import dev.skymansandy.wiretap.helper.util.currentTimeMillis
 import dev.skymansandy.wiretap.helper.util.truncateBody
 import kotlinx.coroutines.NonCancellable
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.withContext
 import okhttp3.Interceptor
 import okhttp3.MediaType.Companion.toMediaType
 import okhttp3.Protocol
 import okhttp3.Response
 import okhttp3.ResponseBody.Companion.toResponseBody
 import okio.Buffer
 import org.koin.core.Koin
 import org.koin.core.component.KoinComponent
 import org.koin.core.component.inject
 import java.io.IOException
 
 /**
  * OkHttp interceptor for Wiretap network inspection.
  *
  * Configuration is applied via a builder lambda:
  * ```kotlin
  * OkHttpClient.Builder()
  *     .addInterceptor(WiretapOkHttpInterceptor {
  *         shouldLog = { url, _ -> url.contains("/api/") }
  *         headerAction = { key ->
  *             if (key.equals("Authorization", ignoreCase = true)) HeaderAction.Mask()
  *             else HeaderAction.Keep
  *         }
  *         logRetention = LogRetention.Days(7)
  *     })
  *     .build()
  * ```
  */
 @OptIn(InternalWiretapApi::class)
 class WiretapOkHttpInterceptor(
     configure: WiretapHttpConfig.() -> Unit = {},
 ) : Interceptor, KoinComponent {
 
     private val config = WiretapHttpConfig().apply(configure)
 
     override fun getKoin(): Koin = WiretapDi.getKoin()
 
     private val wiretapLogger: WiretapLogger by inject()
     private val sharedHttpLogManager: HttpLogManager by inject()
 
     private val httpLogManager: HttpLogManager by lazy {
         if (config.consoleLogging) sharedHttpLogManager.withConsoleLogging(wiretapLogger)
         else sharedHttpLogManager
     }
 
     private val findMatchingRule: FindMatchingRuleUseCase by inject()
 
     private val sessionInitialized = AtomicBoolean(false)
 
     // Retention cleanup: runs once per plugin installation
     private suspend fun initSessionIfNeeded() {
         if (sessionInitialized.compareAndSet(expected = false, new = true)) {
             when (val logRetention = config.logRetention) {
                 LogRetention.Forever -> Unit
                 is LogRetention.AppSession -> httpLogManager.clearHttpLogs()
                 is LogRetention.Days -> {
                     val cutoff = currentTimeMillis() - logRetention.days * 24L * 60 * 60 * 1000
                     httpLogManager.purgeHttpLogsOlderThan(cutoff)
                 }
             }
         }
     }
 
     @Suppress("LongMethod", "CyclomaticComplexMethod")
     override fun intercept(chain: Interceptor.Chain): Response = runBlocking {
         if (!config.enabled) {
             return@runBlocking chain.proceed(chain.request())
         }
 
         initSessionIfNeeded()
 
         val request = chain.request()
 
         // Skip WebSocket upgrade requests — handled by WiretapOkHttpWebSocketListener
         if (request.header("Upgrade").equals("websocket", ignoreCase = true)) {
             return@runBlocking chain.proceed(request)
         }
 
         val url = request.url.toString()
         val method = request.method
         val startNano = currentNanoTime()
 
         val reqHeaders = request.headers.toMap()
         val requestBody = try {
             val copy = request.newBuilder().build()
             val buffer = Buffer()
             copy.body?.writeTo(buffer)
             buffer.readUtf8()
         } catch (_: Exception) {
             null
         }
 
         val matchingRule = findMatchingRule(url, method, reqHeaders, requestBody)
 
         // Log request immediately so it appears in the UI (gated by shouldLog).
         // NonCancellable ensures the save completes and returns the ID even if the
         // coroutine is canceled mid-flight, preventing orphaned "..." entries.
         val logEntryId = if (config.shouldLog(url, method)) {
             withContext(NonCancellable) {
                 httpLogManager.logHttpAndGetId(
                     HttpLog(
                         url = url,
                         method = method,
                         requestHeaders = reqHeaders.applyHeaderAction(config.headerAction),
                         requestBody = requestBody.truncateBody(config.maxContentLength),
                         timestamp = currentTimeMillis(),
                     ),
                 )
             }
         } else {
             -1L
         }
 
         if (matchingRule?.action is RuleAction.Mock || matchingRule?.action is RuleAction.MockAndThrottle) {
             val responseCode: Int
             val mockBody: String?
             val mockHeaders: Map<String, String>?
             val source: ResponseSource
 
             when (val action = matchingRule.action) {
                 is RuleAction.Mock -> {
                     responseCode = action.responseCode
                     mockBody = action.responseBody
                     mockHeaders = action.responseHeaders
                     source = ResponseSource.Mock
                 }
 
                 is RuleAction.MockAndThrottle -> {
                     val minDelay = action.delayMs
                     val maxDelay = action.delayMaxMs ?: minDelay
                     val delayMs =
                         if (maxDelay > minDelay) (minDelay..maxDelay).random() else minDelay
                     if (delayMs > 0) delay(delayMs)
 
                     responseCode = action.responseCode
                     mockBody = action.responseBody
                     mockHeaders = action.responseHeaders
                     source = ResponseSource.MockAndThrottle
                 }
 
                 else -> error("unreachable")
             }
 
             val contentType = mockHeaders
                 ?.entries
                 ?.firstOrNull { it.key.equals("Content-Type", ignoreCase = true) }
                 ?.value
                 ?: "application/json; charset=utf-8"
             val body = (mockBody ?: "")
                 .toResponseBody(contentType.toMediaType())
             val mockResponse = Response.Builder()
                 .request(request)
                 .protocol(Protocol.HTTP_1_1)
                 .code(responseCode)
                 .message("Mock")
                 .addHeader("Content-Type", contentType)
                 .body(body)
                 .apply { mockHeaders?.forEach { (k, v) -> addHeader(k, v) } }
                 .build()
 
             if (logEntryId >= 0) {
                 val mockRespHeaders = mockResponse.headers.toMap()
                 val durationNs = currentNanoTime() - startNano
                 httpLogManager.updateHttp(
                     HttpLog(
                         id = logEntryId,
                         url = url,
                         method = method,
                         requestHeaders = reqHeaders.applyHeaderAction(config.headerAction),
                         requestBody = requestBody.truncateBody(config.maxContentLength),
                         responseCode = mockResponse.code,
                         responseHeaders = mockRespHeaders.applyHeaderAction(config.headerAction),
                         responseBody = mockBody.truncateBody(config.maxContentLength),
                         durationMs = durationNs / 1_000_000,
                         durationNs = durationNs,
                         source = source,
                         timestamp = currentTimeMillis(),
                         matchedRuleId = matchingRule.id,
                     ),
                 )
             }
 
             return@runBlocking mockResponse
         }
 
         if (matchingRule?.action is RuleAction.Throttle) {
             val throttle = matchingRule.action as RuleAction.Throttle
             val minDelay = throttle.delayMs
             val maxDelay = throttle.delayMaxMs ?: minDelay
             val delayMs = if (maxDelay > minDelay) (minDelay..maxDelay).random() else minDelay
             if (delayMs > 0) delay(delayMs)
         }
 
         val response = try {
             chain.proceed(request)
         } catch (e: Exception) {
             if (logEntryId >= 0) {
                 val isCancelled = e is IOException && e.message == "Canceled"
                 withContext(NonCancellable) {
                     val durationNs = currentNanoTime() - startNano
                     httpLogManager.updateHttp(
                         HttpLog(
                             id = logEntryId,
                             url = url,
                             method = method,
                             requestHeaders = reqHeaders.applyHeaderAction(config.headerAction),
                             requestBody = requestBody.truncateBody(config.maxContentLength),
                             responseCode = if (isCancelled) -1 else 0,
                             responseHeaders = emptyMap(),
                             responseBody = (e.message ?: e::class.simpleName ?: "Unknown error")
                                 .truncateBody(config.maxContentLength),
                             durationMs = durationNs / 1_000_000,
                             durationNs = durationNs,
                             source = ResponseSource.Network,
                             timestamp = currentTimeMillis(),
                         ),
                     )
                 }
             }
             throw e
         }
 
         // SSE stream — remove the HTTP log entry; SSE listener handles it
         val respContentType = response.header("Content-Type")
         if (respContentType != null && respContentType.contains(
                 "text/event-stream",
                 ignoreCase = true,
             )
         ) {
             if (logEntryId >= 0) {
                 httpLogManager.deleteHttpLog(logEntryId)
             }
             return@runBlocking response
         }
 
         val durationNs = currentNanoTime() - startNano
         val durationMs = durationNs / 1_000_000
 
         val source = when (matchingRule?.action) {
             is RuleAction.Throttle -> ResponseSource.Throttle
             else -> ResponseSource.Network
         }
 
         if (logEntryId >= 0) {
             val meta = extractResponseMetadata(response, chain, config.maxContentLength)
             httpLogManager.updateHttp(
                 HttpLog(
                     id = logEntryId,
                     url = url,
                     method = method,
                     requestHeaders = reqHeaders.applyHeaderAction(config.headerAction),
                     requestBody = null,
                     responseCode = response.code,
                     responseHeaders = meta.responseHeaders.applyHeaderAction(config.headerAction),
                     responseBody = meta.responseBody.truncateBody(config.maxContentLength),
                     durationMs = durationMs,
                     durationNs = durationNs,
                     source = source,
                     timestamp = currentTimeMillis(),
                     matchedRuleId = matchingRule?.id,
                     protocol = meta.protocol,
                     remoteAddress = meta.remoteAddress,
                     tlsProtocol = meta.tlsProtocol,
                     cipherSuite = meta.cipherSuite,
                     certificateCn = meta.certificateCn,
                     issuerCn = meta.issuerCn,
                     certificateExpiry = meta.certificateExpiry,
                 ),
             )
         }
 
         response
     }
 }