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
6 changes: 6 additions & 0 deletions murphy-spring/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<ClientResponse> {
val headers = mutableMapOf<String, List<String>>()
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<Optional<MurphyResponse>> = 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)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String>()

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<String>()
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<String>()
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<String>()

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<String>()

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<String>()
assertEquals(201, responseFoo.statusCode.value())

val responseBar = client.get().uri("/api/bar").retrieve().awaitEntity<String>()
assertEquals(202, responseBar.statusCode.value())
}
}