Coverage Summary for Class: WiretapKtorHttpPluginKt (dev.skymansandy.wiretap.plugin.http)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| WiretapKtorHttpPluginKt |
100%
(6/6)
|
45%
(9/20)
|
91.7%
(55/60)
|
86.2%
(326/378)
|
| WiretapKtorHttpPluginKt$buildMockCall$1 |
|
| WiretapKtorHttpPluginKt$WiretapKtorHttpPlugin$2$1 |
100%
(1/1)
|
|
100%
(2/2)
|
100%
(12/12)
|
| WiretapKtorHttpPluginKt$WiretapKtorHttpPlugin$2$2 |
100%
(2/2)
|
53.6%
(30/56)
|
75.4%
(49/65)
|
59.3%
(681/1149)
|
| WiretapKtorHttpPluginKt$WiretapKtorHttpPlugin$2$2$1$1 |
0%
(0/1)
|
|
0%
(0/1)
|
0%
(0/14)
|
| WiretapKtorHttpPluginKt$WiretapKtorHttpPlugin$2$2$2 |
0%
(0/1)
|
0%
(0/6)
|
0%
(0/15)
|
0%
(0/80)
|
| WiretapKtorHttpPluginKt$WiretapKtorHttpPlugin$2$2$logEntryId$1 |
100%
(1/1)
|
|
100%
(7/7)
|
100%
(50/50)
|
| WiretapKtorHttpPluginKt$WiretapKtorHttpPlugin$2$3$1 |
100%
(1/1)
|
65%
(13/20)
|
84.6%
(22/26)
|
76.4%
(265/347)
|
| WiretapKtorHttpPluginKt$WiretapKtorHttpPlugin$2$3$1$1 |
100%
(1/1)
|
100%
(2/2)
|
100%
(16/16)
|
100%
(75/75)
|
| Total |
85.7%
(12/14)
|
51.9%
(54/104)
|
78.6%
(151/192)
|
66.9%
(1409/2105)
|
/*
* Copyright (c) 2026 skymansandy. All rights reserved.
*/
package dev.skymansandy.wiretap.plugin.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.WiretapRule
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 io.ktor.client.call.HttpClientCall
import io.ktor.client.plugins.api.Send
import io.ktor.client.plugins.api.createClientPlugin
import io.ktor.client.plugins.observer.ResponseObserver
import io.ktor.client.request.HttpResponseData
import io.ktor.client.statement.bodyAsText
import io.ktor.client.statement.request
import io.ktor.http.Headers
import io.ktor.http.HttpProtocolVersion
import io.ktor.http.HttpStatusCode
import io.ktor.http.content.OutgoingContent
import io.ktor.util.AttributeKey
import io.ktor.util.date.GMTDate
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.InternalAPI
import io.ktor.utils.io.readRemaining
import io.ktor.utils.io.readText
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.Koin
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
private val RequestTimestampKey = AttributeKey<Long>("WiretapRequestTimestamp")
private val RequestNanoTimestampKey = AttributeKey<Long>("WiretapRequestNanoTimestamp")
private val MatchedRuleKey = AttributeKey<WiretapRule>("WiretapMatchedRule")
private val LogEntryIdKey = AttributeKey<Long>("WiretapLogEntryId")
private val RequestHeadersKey = AttributeKey<Map<String, String>>("WiretapRequestHeaders")
/**
* Ktor client plugin for Wiretap network inspection.
*
* Intercepts HTTP requests and responses to log them via the Wiretap orchestrator.
* Supports mock and throttle rules — matching requests can return fake responses
* or be delayed before reaching the network.
*
* Install in your [io.ktor.client.HttpClient] configuration:
* ```kotlin
* HttpClient {
* install(WiretapKtorHttpPlugin) {
* shouldLog = { url, _ -> url.contains("/api/") }
* headerAction = { key ->
* if (key.equals("Authorization", ignoreCase = true)) HeaderAction.Mask()
* else HeaderAction.Keep
* }
* logRetention = LogRetention.Days(7)
* }
* }
* ```
*
* WebSocket upgrade requests (101) are skipped — use [dev.skymansandy.wiretap.plugin.ws.WiretapKtorWebSocketPlugin] for those.
*
* @see WiretapHttpConfig
* @see dev.skymansandy.wiretap.plugin.ws.WiretapKtorWebSocketPlugin
*/
@OptIn(InternalAPI::class)
val WiretapKtorHttpPlugin = createClientPlugin("WiretapPlugin", ::WiretapHttpConfig) {
val config = pluginConfig
val deps = WiretapDeps(config.consoleLogging)
val logScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val sessionInitialized = AtomicBoolean(false)
// Retention cleanup: runs once per plugin installation
suspend fun initSessionIfNeeded() {
if (sessionInitialized.compareAndSet(expected = false, new = true)) {
when (val logRetention = config.logRetention) {
LogRetention.Forever -> Unit
is LogRetention.AppSession -> deps.httpLogManager.clearHttpLogs()
is LogRetention.Days -> {
val cutoff = currentTimeMillis() - logRetention.days * 24L * 60 * 60 * 1000
deps.httpLogManager.purgeHttpLogsOlderThan(cutoff)
}
}
}
}
onRequest { request, _ ->
request.attributes.put(RequestTimestampKey, currentTimeMillis())
request.attributes.put(RequestNanoTimestampKey, currentNanoTime())
}
on(Send) { request ->
if (!config.enabled) return@on proceed(request)
initSessionIfNeeded()
// Skip WebSocket upgrade requests — handled by WiretapKtorWebSocketPlugin
val upgradeHeader = request.headers.getAll("Upgrade")
val isWebSocketUpgrade =
upgradeHeader?.any { it.equals("websocket", ignoreCase = true) } == true
val urlString = request.url.buildString()
val isWebSocketScheme =
urlString.startsWith("ws://") || urlString.startsWith("wss://")
if (isWebSocketUpgrade || isWebSocketScheme) return@on proceed(request)
// Skip SSE requests — handled by WiretapKtorSsePlugin / wiretapped()
val acceptHeader = request.headers.getAll("Accept")
val isSseRequest =
acceptHeader?.any { it.contains("text/event-stream", ignoreCase = true) } == true
if (isSseRequest) return@on proceed(request)
val url = request.url.buildString()
val method = request.method.value
val requestHeaders = request.headers.entries()
.associate { (key, values) -> key to values.joinToString(", ") }
request.attributes.put(RequestHeadersKey, requestHeaders)
val requestBody = try {
when (val body = request.body) {
is OutgoingContent.ByteArrayContent -> body.bytes().decodeToString()
is OutgoingContent.ReadChannelContent -> body.readFrom().readRemaining().readText()
else -> null
}
} catch (_: Exception) {
null
}
val matchingRule = deps.findMatchingRule(url, method, requestHeaders, requestBody)
if (matchingRule != null) {
request.attributes.put(MatchedRuleKey, matchingRule)
}
// 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) {
deps.httpLogManager.logHttpAndGetId(
HttpLog(
url = url,
method = method,
requestHeaders = requestHeaders.applyHeaderAction(config.headerAction),
requestBody = requestBody.truncateBody(config.maxContentLength),
timestamp = currentTimeMillis(),
),
)
}
} else {
-1L
}
if (logEntryId >= 0) {
request.attributes.put(LogEntryIdKey, logEntryId)
// Safety net: if the coroutine is cancelled after proceed() returns
// but before the ResponseObserver fires, mark the entry as canceled.
// The conditional SQL only updates entries still at -2 (in-progress),
// so it won't overwrite a legitimate response logged by the observer.
coroutineContext[Job]?.invokeOnCompletion { cause ->
if (cause != null) {
logScope.launch {
deps.httpLogManager.markHttpCancelledIfInProgress(logEntryId)
}
}
}
}
try {
when (val action = matchingRule?.action) {
is RuleAction.Mock -> {
buildMockCall(
action.responseCode, action.responseHeaders, action.responseBody,
ResponseSource.Mock, logEntryId, url, method,
requestHeaders, requestBody, request, matchingRule, config, deps, client,
)
}
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)
buildMockCall(
action.responseCode, action.responseHeaders, action.responseBody,
ResponseSource.MockAndThrottle, logEntryId, url, method,
requestHeaders, requestBody, request, matchingRule, config, deps, client,
)
}
is RuleAction.Throttle -> {
val minDelay = action.delayMs
val maxDelay = action.delayMaxMs ?: minDelay
val delayMs =
if (maxDelay > minDelay) (minDelay..maxDelay).random() else minDelay
if (delayMs > 0) delay(delayMs)
proceed(request)
}
else -> proceed(request)
}
} catch (e: Exception) {
if (logEntryId >= 0) {
val startNano =
request.attributes.getOrNull(RequestNanoTimestampKey) ?: currentNanoTime()
val durationNs = currentNanoTime() - startNano
// NonCancellable ensures the DB update completes even when
// the coroutine is canceled, so the entry moves from "..." to "!!!".
withContext(NonCancellable) {
deps.httpLogManager.updateHttp(
HttpLog(
id = logEntryId,
url = url,
method = method,
requestHeaders = requestHeaders.applyHeaderAction(config.headerAction),
requestBody = requestBody.truncateBody(config.maxContentLength),
responseCode = if (e is CancellationException) -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
}
}
// Use ResponseObserver to read the response body for logging.
// Unlike onResponse + bodyAsText(), ResponseObserver splits the ByteReadChannel
// so the observer gets its own copy — downstream consumers (e.g. custom
// ContentConverters in ContentNegotiation) always receive a fresh channel.
// This fixes the JsonDecodingException caused by consuming the one-shot stream.
ResponseObserver.install(ResponseObserver.prepare {
onResponse { response ->
val request = response.request
val logEntryId = request.attributes.getOrNull(LogEntryIdKey) ?: return@onResponse
// WebSocket upgrade (101) — remove any HTTP log entry; socket plugin handles it
if (response.status.value == 101) {
deps.httpLogManager.deleteHttpLog(logEntryId)
return@onResponse
}
// SSE stream — remove the HTTP log entry; SSE plugin handles it
val contentType = response.headers["Content-Type"]
if (contentType != null && contentType.contains("text/event-stream", ignoreCase = true)) {
deps.httpLogManager.deleteHttpLog(logEntryId)
return@onResponse
}
val startNano = request.attributes.getOrNull(RequestNanoTimestampKey) ?: currentNanoTime()
val durationNs = currentNanoTime() - startNano
val durationMs = durationNs / 1_000_000
val url = request.url.toString()
val method = request.method.value
val requestHeaders = request.attributes.getOrNull(RequestHeadersKey)
?: request.headers.entries().associate { (key, values) -> key to values.joinToString(", ") }
val responseHeaders = response.headers.entries()
.associate { (key, values) -> key to values.joinToString(", ") }
val responseBody = try {
response.bodyAsText()
} catch (_: Exception) {
null
}
val source = when (request.attributes.getOrNull(MatchedRuleKey)?.action) {
is RuleAction.Mock -> ResponseSource.Mock
is RuleAction.Throttle -> ResponseSource.Throttle
is RuleAction.MockAndThrottle -> ResponseSource.MockAndThrottle
else -> ResponseSource.Network
}
val protocol = response.version.let { "${it.name}/${it.major}.${it.minor}" }
// NonCancellable ensures the DB update completes even when
// the coroutine is canceled mid-response, so the entry doesn't stay stuck at "...".
withContext(NonCancellable) {
deps.httpLogManager.updateHttp(
HttpLog(
id = logEntryId,
url = url,
method = method,
requestHeaders = requestHeaders.applyHeaderAction(config.headerAction),
requestBody = null,
responseCode = response.status.value,
responseHeaders = responseHeaders.applyHeaderAction(config.headerAction),
responseBody = responseBody.truncateBody(config.maxContentLength),
durationMs = durationMs,
durationNs = durationNs,
source = source,
timestamp = currentTimeMillis(),
matchedRuleId = request.attributes.getOrNull(MatchedRuleKey)?.id,
protocol = protocol,
),
)
}
}
}, client)
}
@OptIn(InternalWiretapApi::class)
private class WiretapDeps(consoleLogging: Boolean) : KoinComponent {
override fun getKoin(): Koin = WiretapDi.getKoin()
private val wiretapLogger: WiretapLogger by inject()
private val sharedHttpLogManager: HttpLogManager by inject()
val httpLogManager: HttpLogManager by lazy {
if (consoleLogging) sharedHttpLogManager.withConsoleLogging(wiretapLogger)
else sharedHttpLogManager
}
val findMatchingRule: FindMatchingRuleUseCase by inject()
}
@Suppress("LongParameterList")
@OptIn(InternalAPI::class)
private suspend fun buildMockCall(
responseCode: Int,
responseHeaders: Map<String, String>?,
responseBody: String?,
source: ResponseSource,
logEntryId: Long,
url: String,
method: String,
requestHeaders: Map<String, String>,
requestBody: String?,
request: io.ktor.client.request.HttpRequestBuilder,
matchingRule: WiretapRule,
config: WiretapHttpConfig,
deps: WiretapDeps,
httpClient: io.ktor.client.HttpClient,
): HttpClientCall {
val statusCode = HttpStatusCode.fromValue(responseCode)
val mockHeaders = Headers.build {
responseHeaders?.forEach { (k, v) -> append(k, v) }
}
val body = responseBody?.encodeToByteArray() ?: ByteArray(0)
val call = HttpClientCall(
client = httpClient,
requestData = request.build(),
responseData = HttpResponseData(
statusCode = statusCode,
requestTime = GMTDate(),
headers = mockHeaders,
version = HttpProtocolVersion.HTTP_1_1,
body = ByteReadChannel(body),
callContext = currentCoroutineContext() + Job(),
),
)
if (logEntryId >= 0) {
val startNano = request.attributes.getOrNull(RequestNanoTimestampKey)
?: currentNanoTime()
val durationNs = currentNanoTime() - startNano
val mockRespHeaders = responseHeaders ?: emptyMap()
deps.httpLogManager.updateHttp(
HttpLog(
id = logEntryId,
url = url,
method = method,
requestHeaders = requestHeaders.applyHeaderAction(config.headerAction),
requestBody = requestBody.truncateBody(config.maxContentLength),
responseCode = statusCode.value,
responseHeaders = mockRespHeaders.applyHeaderAction(config.headerAction),
responseBody = responseBody.truncateBody(config.maxContentLength),
durationMs = durationNs / 1_000_000,
durationNs = durationNs,
source = source,
timestamp = currentTimeMillis(),
matchedRuleId = matchingRule.id,
),
)
}
return call
}