From 3fac4878f06624f2652111f9c0ce39bc897ccbb5 Mon Sep 17 00:00:00 2001 From: Roman Konstantynovskyi Date: Wed, 21 Jan 2026 02:01:59 +0200 Subject: [PATCH] feat: Add `MurphyHttpClient` --- README.md | 7 + murphy-http/build.gradle.kts | 9 + .../kotlin/io/murphy/http/MurphyHttpClient.kt | 187 ++++++++++++++ .../io/murphy/http/MurphyHttpResponse.kt | 35 +++ .../io/murphy/http/MurphyHttpClientTest.kt | 229 ++++++++++++++++++ settings.gradle.kts | 1 + 6 files changed, 468 insertions(+) create mode 100644 murphy-http/build.gradle.kts create mode 100644 murphy-http/src/main/kotlin/io/murphy/http/MurphyHttpClient.kt create mode 100644 murphy-http/src/main/kotlin/io/murphy/http/MurphyHttpResponse.kt create mode 100644 murphy-http/src/test/kotlin/io/murphy/http/MurphyHttpClientTest.kt diff --git a/README.md b/README.md index 594dba4..c3daa9a 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,12 @@ val client = HttpClient(CIO) { } ``` +#### Java HttpClient + +```java +var client = MurphyHttpClient.decorate(HttpClient.newHttpClient(), scenario); +``` + ## 🛠 Available Effects - `latency(ms)`: Adds a fixed delay. - `jitter(min, max)`: Adds a random delay within a specified range. @@ -117,3 +123,4 @@ val loggerEffect = Effect { context -> - [x] Spring RestClient Interceptor - [x] Spring WebClient Filter - [x] Ktor Client Plugin +- [x] Java HttpClient Decorator diff --git a/murphy-http/build.gradle.kts b/murphy-http/build.gradle.kts new file mode 100644 index 0000000..e2fb531 --- /dev/null +++ b/murphy-http/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + id("murphy.kotlin-library") +} + +dependencies { + api(project(":murphy-core")) + + testImplementation(libs.okhttp3.mockwebserver) +} diff --git a/murphy-http/src/main/kotlin/io/murphy/http/MurphyHttpClient.kt b/murphy-http/src/main/kotlin/io/murphy/http/MurphyHttpClient.kt new file mode 100644 index 0000000..e2d9bd0 --- /dev/null +++ b/murphy-http/src/main/kotlin/io/murphy/http/MurphyHttpClient.kt @@ -0,0 +1,187 @@ +package io.murphy.http + +import io.murphy.core.Effect +import io.murphy.core.MurphyContext +import io.murphy.core.MurphyResponse +import io.murphy.core.MurphyScenario +import io.murphy.core.effect.DelayEffect +import java.net.Authenticator +import java.net.CookieHandler +import java.net.ProxySelector +import java.net.http.HttpClient +import java.net.http.HttpHeaders +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.ByteBuffer +import java.time.Duration +import java.util.Optional +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executor +import java.util.concurrent.Flow +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLParameters + +class MurphyHttpClient( + private val delegate: HttpClient, + private val scenario: MurphyScenario, +) : HttpClient() { + + override fun send( + request: HttpRequest, + responseBodyHandler: HttpResponse.BodyHandler + ): HttpResponse { + val context = request.toMurphyContext() + val rule = scenario.findRule(context) + + rule?.effects?.forEach { effect -> + val murphyResponse = effect.apply(context) + if (murphyResponse != null) { + return murphyResponse.toHttpResponse(request = request, responseBodyHandler = responseBodyHandler) + } + } + + return delegate.send(request, responseBodyHandler) + } + + override fun sendAsync( + request: HttpRequest, + responseBodyHandler: HttpResponse.BodyHandler + ): CompletableFuture> { + val context = request.toMurphyContext() + val rule = scenario.findRule(context) ?: return delegate.sendAsync(request, responseBodyHandler) + + var future = CompletableFuture.completedFuture(null) + + rule.effects.forEach { effect -> + future = future.thenCompose { murphyResponse -> + murphyResponse?.let { CompletableFuture.completedFuture(it) } + ?: applyEffectAsync(effect = effect, context = context) + } + } + + return future.thenCompose { murphyResponse -> + murphyResponse?.toHttpResponseAsync(request, responseBodyHandler) + ?: delegate.sendAsync(request, responseBodyHandler) + } + } + + override fun sendAsync( + request: HttpRequest, + responseBodyHandler: HttpResponse.BodyHandler, + pushPromiseHandler: HttpResponse.PushPromiseHandler, + ): CompletableFuture> { + val context = request.toMurphyContext() + if (scenario.findRule(context) != null) { + return sendAsync(request = request, responseBodyHandler = responseBodyHandler) + } + return delegate.sendAsync(request, responseBodyHandler, pushPromiseHandler) + } + + override fun cookieHandler(): Optional = delegate.cookieHandler() + override fun connectTimeout(): Optional = delegate.connectTimeout() + override fun followRedirects(): Redirect = delegate.followRedirects() + override fun proxy(): Optional = delegate.proxy() + override fun sslContext(): SSLContext = delegate.sslContext() + override fun sslParameters(): SSLParameters = delegate.sslParameters() + override fun authenticator(): Optional = delegate.authenticator() + override fun version(): Version = delegate.version() + override fun executor(): Optional = delegate.executor() + + private fun applyEffectAsync( + effect: Effect, + context: MurphyContext, + ): CompletableFuture { + if (effect is DelayEffect) { + val d = effect.duration + if (d <= 0) return CompletableFuture.completedFuture(null) + + val delayedExecutor = executor() + .map { CompletableFuture.delayedExecutor(d, TimeUnit.MILLISECONDS, it) } + .orElseGet { CompletableFuture.delayedExecutor(d, TimeUnit.MILLISECONDS) } + + return CompletableFuture.supplyAsync({ null }, delayedExecutor) + } + + return executor() + .map { CompletableFuture.supplyAsync({ effect.apply(context) }, it) } + .orElseGet { CompletableFuture.supplyAsync { effect.apply(context) } } + } + + private fun HttpRequest.toMurphyContext(): MurphyContext { + return MurphyContext( + url = uri().toString(), + path = uri().path, + method = method(), + headers = headers().map(), + ) + } + + private fun MurphyResponse.toHttpResponse( + request: HttpRequest, + responseBodyHandler: HttpResponse.BodyHandler, + ): MurphyHttpResponse { + val responseInfo = MurphyResponseInfo(this) + val subscriber = responseBodyHandler.apply(responseInfo) + subscriber.onSubscribe(MurphySubscription(subscriber = subscriber, body = body)) + + return MurphyHttpResponse( + murphyResponse = this, + request = request, + body = subscriber.body.toCompletableFuture().join(), + ) + } + + private fun MurphyResponse.toHttpResponseAsync( + request: HttpRequest, + responseBodyHandler: HttpResponse.BodyHandler, + ): CompletableFuture> { + val responseInfo = MurphyResponseInfo(this) + val subscriber = responseBodyHandler.apply(responseInfo) + subscriber.onSubscribe(MurphySubscription(subscriber = subscriber, body = body)) + + return subscriber.body.toCompletableFuture().thenApply { body -> + MurphyHttpResponse( + murphyResponse = this, + request = request, + body = body, + ) + } + } + + private class MurphyResponseInfo( + private val response: MurphyResponse, + ) : HttpResponse.ResponseInfo { + override fun statusCode(): Int = response.code + override fun headers(): HttpHeaders = HttpHeaders.of(response.headers) { _, _ -> true } + override fun version(): Version = Version.HTTP_1_1 + } + + private class MurphySubscription( + private val subscriber: Flow.Subscriber>, + private val body: ByteArray, + ) : Flow.Subscription { + private var completed = false + + override fun request(n: Long) { + if (n > 0 && !completed) { + completed = true + if (body.isNotEmpty()) { + subscriber.onNext(listOf(ByteBuffer.wrap(body))) + } + subscriber.onComplete() + } + } + + override fun cancel() { + completed = true + } + } + + companion object { + @JvmStatic + fun decorate(client: HttpClient, scenario: MurphyScenario): MurphyHttpClient { + return MurphyHttpClient(delegate = client, scenario = scenario) + } + } +} diff --git a/murphy-http/src/main/kotlin/io/murphy/http/MurphyHttpResponse.kt b/murphy-http/src/main/kotlin/io/murphy/http/MurphyHttpResponse.kt new file mode 100644 index 0000000..36d6b6a --- /dev/null +++ b/murphy-http/src/main/kotlin/io/murphy/http/MurphyHttpResponse.kt @@ -0,0 +1,35 @@ +package io.murphy.http + +import io.murphy.core.MurphyResponse +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpHeaders +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.util.Optional +import javax.net.ssl.SSLSession + +class MurphyHttpResponse( + private val murphyResponse: MurphyResponse, + private val request: HttpRequest, + private val body: T, +) : HttpResponse { + + override fun statusCode(): Int = murphyResponse.code + + override fun request(): HttpRequest = request + + override fun previousResponse(): Optional> = Optional.empty() + + override fun headers(): HttpHeaders { + return HttpHeaders.of(murphyResponse.headers) { _, _ -> true } + } + + override fun body(): T = body + + override fun sslSession(): Optional = Optional.empty() + + override fun uri(): URI = request.uri() + + override fun version(): HttpClient.Version = HttpClient.Version.HTTP_1_1 +} diff --git a/murphy-http/src/test/kotlin/io/murphy/http/MurphyHttpClientTest.kt b/murphy-http/src/test/kotlin/io/murphy/http/MurphyHttpClientTest.kt new file mode 100644 index 0000000..0b49736 --- /dev/null +++ b/murphy-http/src/test/kotlin/io/murphy/http/MurphyHttpClientTest.kt @@ -0,0 +1,229 @@ +package io.murphy.http + +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 okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MurphyHttpClientTest { + + private lateinit var server: MockWebServer + private lateinit var baseClient: HttpClient + + @BeforeEach + fun setup() { + server = MockWebServer() + server.start() + baseClient = HttpClient.newHttpClient() + } + + @AfterEach + fun teardown() { + server.shutdown() + } + + private fun buildClient(scenario: MurphyScenario): HttpClient { + return MurphyHttpClient.decorate(baseClient, scenario) + } + + @Test + fun `client blocks request and returns response`() { + val json = """{"message": "I'm a teapot"}""" + val rule = MurphyRule.builder() + .matches(Matchers.always()) + .causes(Effects.json(code = 418, json)) + .build() + + val client = buildClient(MurphyScenario.from(rule)) + val request = HttpRequest.newBuilder() + .uri(server.url("/test").toUri()) + .GET() + .build() + + val response = client.send(request, HttpResponse.BodyHandlers.ofString()) + + assertEquals(418, response.statusCode()) + assertEquals("application/json", response.headers().firstValue("Content-Type").orElse(null)) + assertEquals(json, response.body()) + assertEquals(0, server.requestCount) + } + + @Test + fun `client applies delay before proceeding to network`() { + val delayMs = 100L + val rule = MurphyRule.builder() + .matches(Matchers.always()) + .causes(Effects.latency(delayMs)) + .build() + + val client = buildClient(MurphyScenario.from(rule)) + val request = HttpRequest.newBuilder() + .uri(server.url("/test").toUri()) + .GET() + .build() + + server.enqueue(MockResponse().setResponseCode(200).setBody("Mock Server Response")) + + val start = System.currentTimeMillis() + val response = client.send(request, HttpResponse.BodyHandlers.ofString()) + val elapsed = System.currentTimeMillis() - start + + assertEquals(200, response.statusCode()) + assertEquals("Mock Server Response", response.body()) + assertTrue(elapsed >= delayMs) + assertEquals(1, server.requestCount) + } + + @Test + fun `client applies delay before returning response`() { + val delayMs = 100L + val rule = MurphyRule.builder() + .matches(Matchers.always()) + .causes( + Effects.latency(delayMs), + Effects.status(202), + ) + .build() + + val client = buildClient(MurphyScenario.from(rule)) + val request = HttpRequest.newBuilder() + .uri(server.url("/test").toUri()) + .GET() + .build() + + val start = System.currentTimeMillis() + val response = client.send(request, HttpResponse.BodyHandlers.ofString()) + val elapsed = System.currentTimeMillis() - start + + assertEquals(202, response.statusCode()) + assertTrue(elapsed >= delayMs) + assertEquals(0, server.requestCount) + } + + @Test + fun `client proceeds to network if matcher does not match`() { + val rule = MurphyRule.builder() + .matches(Matchers.method("POST")) + .causes(Effects.status(500)) + .build() + + val client = buildClient(MurphyScenario.from(rule)) + val request = HttpRequest.newBuilder() + .uri(server.url("/test").toUri()) + .GET() + .build() + + server.enqueue(MockResponse().setResponseCode(200).setBody("Mock Server Response")) + + val response = client.send(request, HttpResponse.BodyHandlers.ofString()) + + assertEquals(200, response.statusCode()) + assertEquals("Mock Server Response", response.body()) + assertEquals(1, server.requestCount) + } + + @Test + fun `client proceeds to network if probability is not met`() { + val rule = MurphyRule.builder() + .matches(Matchers.always()) + .causes(Effects.status(500).withProbability(0.0)) + .build() + + val client = buildClient(MurphyScenario.from(rule)) + val request = HttpRequest.newBuilder() + .uri(server.url("/test").toUri()) + .GET() + .build() + + server.enqueue(MockResponse().setResponseCode(200).setBody("Mock Server Response")) + + val response = client.send(request, HttpResponse.BodyHandlers.ofString()) + + assertEquals(200, response.statusCode()) + assertEquals("Mock Server Response", response.body()) + assertEquals(1, server.requestCount) + } + + @Test + fun `scenario with multiple rules - first match wins`() { + 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() + ) + + val client = buildClient(scenario) + + val request1 = HttpRequest.newBuilder().uri(server.url("/api/foo").toUri()).GET().build() + val response1 = client.send(request1, HttpResponse.BodyHandlers.ofString()) + assertEquals(201, response1.statusCode()) + + val request2 = HttpRequest.newBuilder().uri(server.url("/api/bar").toUri()).GET().build() + val response2 = client.send(request2, HttpResponse.BodyHandlers.ofString()) + assertEquals(500, response2.statusCode()) + } + + @Test + fun `client blocks async request and returns response`() { + val json = """{"message": "I'm a teapot"}""" + val rule = MurphyRule.builder() + .matches(Matchers.always()) + .causes(Effects.json(code = 418, json)) + .build() + + val client = buildClient(MurphyScenario.from(rule)) + val request = HttpRequest.newBuilder() + .uri(server.url("/test").toUri()) + .GET() + .build() + + val response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join() + + assertEquals(418, response.statusCode()) + assertEquals("application/json", response.headers().firstValue("Content-Type").orElse(null)) + assertEquals(json, response.body()) + assertEquals(0, server.requestCount) + } + + @Test + fun `client applies delay to async request`() { + val delayMs = 100L + val rule = MurphyRule.builder() + .matches(Matchers.always()) + .causes( + Effects.latency(delayMs), + Effects.status(202) + ) + .build() + + val client = buildClient(MurphyScenario.from(rule)) + val request = HttpRequest.newBuilder() + .uri(server.url("/test").toUri()) + .GET() + .build() + + val start = System.currentTimeMillis() + val response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join() + val elapsed = System.currentTimeMillis() - start + + assertEquals(202, response.statusCode()) + assertTrue(elapsed >= delayMs, "Elapsed time $elapsed should be >= $delayMs") + assertEquals(0, server.requestCount) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 4d12454..8496ca1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,6 +5,7 @@ plugins { rootProject.name = "murphy" include("murphy-core") +include("murphy-http") include("murphy-ktor") include("murphy-okhttp") include("murphy-spring")