Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
8 changes: 8 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
13 changes: 13 additions & 0 deletions murphy-ktor/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
}
68 changes: 68 additions & 0 deletions murphy-ktor/src/main/kotlin/io/murphy/ktor/MurphyPlugin.kt
Original file line number Diff line number Diff line change
@@ -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,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.murphy.ktor

import io.murphy.core.MurphyScenario

class MurphyPluginConfig {
var scenario: MurphyScenario? = null
}
204 changes: 204 additions & 0 deletions murphy-ktor/src/test/kotlin/io/murphy/ktor/MurphyPluginTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ plugins {
rootProject.name = "murphy"

include("murphy-core")
include("murphy-ktor")
include("murphy-okhttp")
include("murphy-spring")