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