From 9dddc20d1157edd4ccc83626f87a25b2b89b27dd Mon Sep 17 00:00:00 2001 From: Roman Konstantynovskyi Date: Sun, 18 Jan 2026 23:49:33 +0200 Subject: [PATCH] feat: Add `MurphyWebClientFilter` --- murphy-spring/build.gradle.kts | 6 + .../spring/webclient/MurphyWebClientFilter.kt | 71 ++++++++ .../webclient/MurphyWebClientFilterTest.kt | 158 ++++++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 murphy-spring/src/main/kotlin/io/murphy/spring/webclient/MurphyWebClientFilter.kt create mode 100644 murphy-spring/src/test/kotlin/io/murphy/spring/webclient/MurphyWebClientFilterTest.kt diff --git a/murphy-spring/build.gradle.kts b/murphy-spring/build.gradle.kts index 39e53a9..60511d6 100644 --- a/murphy-spring/build.gradle.kts +++ b/murphy-spring/build.gradle.kts @@ -9,7 +9,13 @@ dependencies { api(project(":murphy-core")) compileOnly("org.springframework:spring-web") + compileOnly("org.springframework:spring-webflux") + compileOnly("io.projectreactor:reactor-core") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test") testImplementation("org.springframework:spring-web") + testImplementation("org.springframework:spring-webflux") testImplementation("org.springframework:spring-test") + testImplementation(libs.okhttp3.mockwebserver) } diff --git a/murphy-spring/src/main/kotlin/io/murphy/spring/webclient/MurphyWebClientFilter.kt b/murphy-spring/src/main/kotlin/io/murphy/spring/webclient/MurphyWebClientFilter.kt new file mode 100644 index 0000000..26701d2 --- /dev/null +++ b/murphy-spring/src/main/kotlin/io/murphy/spring/webclient/MurphyWebClientFilter.kt @@ -0,0 +1,71 @@ +package io.murphy.spring.webclient + +import io.murphy.core.MurphyContext +import io.murphy.core.MurphyResponse +import io.murphy.core.MurphyScenario +import io.murphy.core.effect.DelayEffect +import org.springframework.core.io.buffer.DefaultDataBufferFactory +import org.springframework.http.HttpStatusCode +import org.springframework.web.reactive.function.client.ClientRequest +import org.springframework.web.reactive.function.client.ClientResponse +import org.springframework.web.reactive.function.client.ExchangeFilterFunction +import org.springframework.web.reactive.function.client.ExchangeFunction +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import reactor.core.scheduler.Schedulers +import java.util.Optional + +class MurphyWebClientFilter( + private val scenario: MurphyScenario, +) : ExchangeFilterFunction { + + override fun filter(request: ClientRequest, next: ExchangeFunction): Mono { + val headers = mutableMapOf>() + request.headers().forEach { name, values -> + headers[name] = values + } + + val context = MurphyContext( + url = request.url().toString(), + path = request.url().path, + method = request.method().name(), + headers = headers, + ) + + val rule = scenario.findRule(context) ?: return next.exchange(request) + + var pipeline: Mono> = Mono.just(Optional.empty()) + + rule.effects.forEach { effect -> + pipeline = pipeline.flatMap { previousResponse -> + // Any previous response short-circuits the chain + if (previousResponse.isPresent) { + Mono.just(previousResponse) + } else { + Mono.fromCallable { Optional.ofNullable(effect.apply(context)) }.let { mono -> + // Execute in a bounded elastic scheduler to avoid blocking for delay effects + if (effect is DelayEffect) { + mono.subscribeOn(Schedulers.boundedElastic()) + } else { + mono + } + } + } + } + } + + return pipeline.flatMap { optionalResponse -> + if (optionalResponse.isPresent) { + val murphyResponse = optionalResponse.get() + Mono.just( + ClientResponse.create(HttpStatusCode.valueOf(murphyResponse.code)) + .headers { h -> murphyResponse.headers.forEach { (name, values) -> h.addAll(name, values) } } + .body(Flux.just(DefaultDataBufferFactory.sharedInstance.wrap(murphyResponse.body))) + .build() + ) + } else { + next.exchange(request) + } + } + } +} diff --git a/murphy-spring/src/test/kotlin/io/murphy/spring/webclient/MurphyWebClientFilterTest.kt b/murphy-spring/src/test/kotlin/io/murphy/spring/webclient/MurphyWebClientFilterTest.kt new file mode 100644 index 0000000..0843428 --- /dev/null +++ b/murphy-spring/src/test/kotlin/io/murphy/spring/webclient/MurphyWebClientFilterTest.kt @@ -0,0 +1,158 @@ +package io.murphy.spring.webclient + +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.test.runTest +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 org.springframework.http.MediaType +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitEntity +import kotlin.test.assertEquals + +class MurphyWebClientFilterTest { + + private lateinit var server: MockWebServer + + @BeforeEach + fun setup() { + server = MockWebServer() + server.start() + } + + @AfterEach + fun teardown() { + server.shutdown() + } + + private fun buildClient(scenario: MurphyScenario): WebClient { + return WebClient.builder() + .baseUrl(server.url("/").toString()) + .filter(MurphyWebClientFilter(scenario)) + .build() + } + + @Test + fun `filter blocks request and returns response`() = runTest { + val json = """{"message": "Hello, Murphy!"}""" + val rule = MurphyRule.builder() + .matches(Matchers.always()) + .causes(Effects.json(code = 201, json)) + .build() + + val client = buildClient(MurphyScenario.from(rule)) + val response = client.get().uri("/test").retrieve().awaitEntity() + + assertEquals(201, response.statusCode.value()) + assertEquals(MediaType.APPLICATION_JSON, response.headers.contentType) + assertEquals(json, response.body) + assertEquals(0, server.requestCount) + } + + @Test + fun `filter applies delay before proceeding to network`() = runTest { + val delay = 100L + val rule = MurphyRule.builder() + .matches(Matchers.always()) + .causes(Effects.latency(delay)) + .build() + + val client = buildClient(MurphyScenario.from(rule)) + + server.enqueue(MockResponse().setResponseCode(200).setBody("Mock Server Response")) + + val start = System.currentTimeMillis() + val response = client.get().uri("/test").retrieve().awaitEntity() + val elapsed = System.currentTimeMillis() - start + + assertEquals(200, response.statusCode.value()) + assertEquals("Mock Server Response", response.body) + assert(elapsed >= delay) + assertEquals(1, server.requestCount) + } + + @Test + fun `filter applies delay before returning response`() = runTest { + val delay = 100L + val rule = MurphyRule.builder() + .matches(Matchers.always()) + .causes( + Effects.latency(delay), + Effects.status(202), + ) + .build() + + val client = buildClient(MurphyScenario.from(rule)) + + val start = System.currentTimeMillis() + val response = client.get().uri("/test").retrieve().awaitEntity() + val elapsed = System.currentTimeMillis() - start + + assertEquals(202, response.statusCode.value()) + assert(elapsed >= delay) + assertEquals(0, server.requestCount) + } + + @Test + fun `filter proceeds to network if matcher does not match`() = runTest { + val rule = MurphyRule.builder() + .matches(Matchers.method("POST")) + .causes(Effects.status(500)) + .build() + + val client = buildClient(MurphyScenario.from(rule)) + server.enqueue(MockResponse().setResponseCode(200).setBody("Mock Server Response")) + + val response = client.get().uri("/test").retrieve().awaitEntity() + + assertEquals(200, response.statusCode.value()) + assertEquals("Mock Server Response", response.body) + assertEquals(1, server.requestCount) + } + + @Test + fun `filter proceeds to network if probability is not met`() = runTest { + val rule = MurphyRule.builder() + .matches(Matchers.always()) + .causes(Effects.status(500).withProbability(0.0)) + .build() + + val client = buildClient(MurphyScenario.from(rule)) + + server.enqueue(MockResponse().setResponseCode(200).setBody("Mock Server Response")) + + val response = client.get().uri("/test").retrieve().awaitEntity() + + assertEquals(200, response.statusCode.value()) + assertEquals("Mock Server Response", response.body) + assertEquals(1, server.requestCount) + } + + @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(202)) + .build() + ) + + val client = buildClient(scenario) + + val responseFoo = client.get().uri("/api/foo").retrieve().awaitEntity() + assertEquals(201, responseFoo.statusCode.value()) + + val responseBar = client.get().uri("/api/bar").retrieve().awaitEntity() + assertEquals(202, responseBar.statusCode.value()) + } +}