From 1b362b706ad0a5a70b0c2c4e5c395cbe9949dc53 Mon Sep 17 00:00:00 2001 From: Roman Konstantynovskyi Date: Wed, 21 Jan 2026 01:12:26 +0200 Subject: [PATCH] feat: Add Ktor `MurphyPlugin` --- README.md | 9 +- gradle/libs.versions.toml | 8 + murphy-ktor/build.gradle.kts | 13 ++ .../kotlin/io/murphy/ktor/MurphyPlugin.kt | 68 ++++++ .../io/murphy/ktor/MurphyPluginConfig.kt | 7 + .../kotlin/io/murphy/ktor/MurphyPluginTest.kt | 204 ++++++++++++++++++ settings.gradle.kts | 1 + 7 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 murphy-ktor/build.gradle.kts create mode 100644 murphy-ktor/src/main/kotlin/io/murphy/ktor/MurphyPlugin.kt create mode 100644 murphy-ktor/src/main/kotlin/io/murphy/ktor/MurphyPluginConfig.kt create mode 100644 murphy-ktor/src/test/kotlin/io/murphy/ktor/MurphyPluginTest.kt diff --git a/README.md b/README.md index 391166c..594dba4 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,13 @@ val client = WebClient.builder() .build() ``` +#### Ktor Client +```kotlin +val client = HttpClient(CIO) { + install(MurphyPlugin) { this.scenario = scenario } +} +``` + ## 🛠 Available Effects - `latency(ms)`: Adds a fixed delay. - `jitter(min, max)`: Adds a random delay within a specified range. @@ -109,4 +116,4 @@ val loggerEffect = Effect { context -> - [x] OkHttp Interceptor - [x] Spring RestClient Interceptor - [x] Spring WebClient Filter -- [ ] Ktor Client Plugin +- [x] Ktor Client Plugin diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 55253b3..d07b0bb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,11 +3,19 @@ kotlin = "2.2.21" junit = "5.10.2" okhttp="4.12.0" spring-boot = "4.0.1" +ktor = "3.3.3" [libraries] +# JUnit junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } +# Ktor +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } +# OKHttp okhttp3-client = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp3-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } +# Spring spring-boot-dependencies = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "spring-boot" } # plugin dependencies plugin-kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } diff --git a/murphy-ktor/build.gradle.kts b/murphy-ktor/build.gradle.kts new file mode 100644 index 0000000..72d1cf9 --- /dev/null +++ b/murphy-ktor/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("murphy.kotlin-library") +} + +dependencies { + api(project(":murphy-core")) + + implementation(libs.ktor.client.core) + + testImplementation(libs.ktor.client.mock) + testImplementation(libs.ktor.client.cio) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test") +} diff --git a/murphy-ktor/src/main/kotlin/io/murphy/ktor/MurphyPlugin.kt b/murphy-ktor/src/main/kotlin/io/murphy/ktor/MurphyPlugin.kt new file mode 100644 index 0000000..b8de084 --- /dev/null +++ b/murphy-ktor/src/main/kotlin/io/murphy/ktor/MurphyPlugin.kt @@ -0,0 +1,68 @@ +package io.murphy.ktor + +import io.ktor.client.call.HttpClientCall +import io.ktor.client.plugins.api.Send +import io.ktor.client.plugins.api.createClientPlugin +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.HttpResponseData +import io.ktor.http.Headers +import io.ktor.http.HttpProtocolVersion +import io.ktor.http.HttpStatusCode +import io.ktor.http.encodedPath +import io.ktor.util.date.GMTDate +import io.ktor.util.toMap +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.InternalAPI +import io.murphy.core.MurphyContext +import io.murphy.core.MurphyResponse +import io.murphy.core.effect.DelayEffect +import kotlinx.coroutines.delay + +val MurphyPlugin = createClientPlugin("MurphyPlugin", ::MurphyPluginConfig) { + val scenario = pluginConfig.scenario ?: return@createClientPlugin + + on(Send) { request -> + val context = MurphyContext( + url = request.url.toString(), + path = request.url.encodedPath, + method = request.method.value, + headers = request.headers.build().toMap(), + ) + + val rule = scenario.findRule(context) ?: return@on proceed(request) + + rule.effects.forEach { effect -> + if (effect is DelayEffect) { + delay(effect.duration) + } else { + val murphyResponse = effect.apply(context) + if (murphyResponse != null) { + + @OptIn(InternalAPI::class) + return@on HttpClientCall( + client = client, + requestData = request.build(), + responseData = murphyResponse.toHttpResponseData(request), + ) + } + } + } + + proceed(request) + } +} + +private fun MurphyResponse.toHttpResponseData(request: HttpRequestBuilder): HttpResponseData { + return HttpResponseData( + statusCode = HttpStatusCode.fromValue(code), + requestTime = GMTDate(), + headers = Headers.build { + headers.forEach { (name, values) -> + appendAll(name, values) + } + }, + version = HttpProtocolVersion.HTTP_1_1, + body = ByteReadChannel(body), + callContext = request.executionContext, + ) +} diff --git a/murphy-ktor/src/main/kotlin/io/murphy/ktor/MurphyPluginConfig.kt b/murphy-ktor/src/main/kotlin/io/murphy/ktor/MurphyPluginConfig.kt new file mode 100644 index 0000000..01084e3 --- /dev/null +++ b/murphy-ktor/src/main/kotlin/io/murphy/ktor/MurphyPluginConfig.kt @@ -0,0 +1,7 @@ +package io.murphy.ktor + +import io.murphy.core.MurphyScenario + +class MurphyPluginConfig { + var scenario: MurphyScenario? = null +} diff --git a/murphy-ktor/src/test/kotlin/io/murphy/ktor/MurphyPluginTest.kt b/murphy-ktor/src/test/kotlin/io/murphy/ktor/MurphyPluginTest.kt new file mode 100644 index 0000000..acb82b4 --- /dev/null +++ b/murphy-ktor/src/test/kotlin/io/murphy/ktor/MurphyPluginTest.kt @@ -0,0 +1,204 @@ +package io.murphy.ktor + +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.murphy.core.Effects +import io.murphy.core.Effects.withProbability +import io.murphy.core.Matchers +import io.murphy.core.MurphyRule +import io.murphy.core.MurphyScenario +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class MurphyPluginTest { + + @Test + fun `plugin blocks request and returns response`() = runTest { + val json = """{"message": "I'm a teapot"}""" + val rule = MurphyRule.builder() + .matches(Matchers.always()) + .causes(Effects.json(code = 418, json)) + .build() + + var serverCallCount = 0 + val client = HttpClient(MockEngine) { + install(MurphyPlugin) { + scenario = MurphyScenario.from(rule) + } + engine { + addHandler { + serverCallCount++ + respond("Should not be called", HttpStatusCode.OK) + } + } + } + + val response = client.get("https://test.com/test") + + assertEquals(HttpStatusCode.fromValue(418), response.status) + assertEquals(ContentType.Application.Json, response.contentType()) + assertEquals(json, response.bodyAsText()) + assertEquals(0, serverCallCount) + } + + @Test + fun `plugin applies delay before proceeding to network`() = runTest { + val delay = 100L + val rule = MurphyRule.builder() + .matches(Matchers.always()) + .causes(Effects.latency(delay)) + .build() + + var serverCallCount = 0 + val client = HttpClient(MockEngine) { + install(MurphyPlugin) { + scenario = MurphyScenario.from(rule) + } + engine { + addHandler { + serverCallCount++ + respond("Mock Server Response", HttpStatusCode.OK) + } + } + } + + val start = currentTime + val response = client.get("https://test.com/test") + val elapsed = currentTime - start + + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("Mock Server Response", response.bodyAsText()) + assert(elapsed >= delay) + assertEquals(1, serverCallCount) + } + + @Test + fun `plugin applies delay before returning response`() = runTest { + val delay = 100L + val rule = MurphyRule.builder() + .matches(Matchers.always()) + .causes( + Effects.latency(delay), + Effects.status(202), + ) + .build() + + var serverCallCount = 0 + val client = HttpClient(MockEngine) { + install(MurphyPlugin) { + scenario = MurphyScenario.from(rule) + } + engine { + addHandler { + serverCallCount++ + respond("Should not be called", HttpStatusCode.OK) + } + } + } + + val start = currentTime + val response = client.get("https://test.com/test") + val elapsed = currentTime - start + + assertEquals(HttpStatusCode.fromValue(202), response.status) + assert(elapsed >= delay) + assertEquals(0, serverCallCount) + } + + @Test + fun `plugin proceeds to network if matcher does not match`() = runTest { + val rule = MurphyRule.builder() + .matches(Matchers.method("POST")) + .causes(Effects.status(500)) + .build() + + var serverCallConfig = 0 + val client = HttpClient(MockEngine) { + install(MurphyPlugin) { + scenario = MurphyScenario.from(rule) + } + engine { + addHandler { + serverCallConfig++ + respond("Mock Server Response", HttpStatusCode.OK) + } + } + } + + val response = client.get("https://test.com/test") + + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("Mock Server Response", response.bodyAsText()) + assertEquals(1, serverCallConfig) + } + + @Test + fun `plugin proceeds to network if probability is not met`() = runTest { + val rule = MurphyRule.builder() + .matches(Matchers.always()) + .causes(Effects.status(500).withProbability(0.0)) + .build() + + var serverCallCount = 0 + val client = HttpClient(MockEngine) { + install(MurphyPlugin) { + scenario = MurphyScenario.from(rule) + } + engine { + addHandler { + serverCallCount++ + respond("Mock Server Response", HttpStatusCode.OK) + } + } + } + + val response = client.get("https://test.com/test") + + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("Mock Server Response", response.bodyAsText()) + assertEquals(1, serverCallCount) + } + + @Test + fun `scenario with multiple rules - first match wins`() = runTest { + val scenario = MurphyScenario.from( + MurphyRule.builder() + .matches(Matchers.path("/api/foo")) + .causes(Effects.status(201)) + .build(), + MurphyRule.builder() + .matches(Matchers.path("/api/**")) + .causes(Effects.status(500)) + .build() + ) + + var serverCallCount = 0 + val client = HttpClient(MockEngine) { + install(MurphyPlugin) { + this.scenario = scenario + } + engine { + addHandler { + serverCallCount++ + respond("Should not be called", HttpStatusCode.OK) + } + } + } + + val responseFoo = client.get("https://test.com/api/foo") + assertEquals(HttpStatusCode.fromValue(201), responseFoo.status) + + val responseBar = client.get("https://test.com/api/bar") + assertEquals(HttpStatusCode.fromValue(500), responseBar.status) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 5a39356..4d12454 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,5 +5,6 @@ plugins { rootProject.name = "murphy" include("murphy-core") +include("murphy-ktor") include("murphy-okhttp") include("murphy-spring")