run.qontract.stub.HttpStub.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of qontract-core Show documentation
Show all versions of qontract-core Show documentation
A Contract Testing Tool that leverages Gherkin to describe APIs in a human readable and machine enforceable manner
package run.qontract.stub
import io.ktor.application.*
import io.ktor.features.*
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.request.*
import io.ktor.response.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.util.KtorExperimentalAPI
import io.ktor.util.asStream
import io.ktor.util.toMap
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import run.qontract.LogTail
import run.qontract.core.*
import run.qontract.core.pattern.ContractException
import run.qontract.core.pattern.parsedValue
import run.qontract.core.pattern.withoutOptionality
import run.qontract.core.utilities.exceptionCauseMessage
import run.qontract.core.utilities.jsonStringToValueMap
import run.qontract.core.utilities.toMap
import run.qontract.core.utilities.valueMapToPlainJsonString
import run.qontract.core.value.*
import run.qontract.mock.NoMatchingScenario
import run.qontract.mock.ScenarioStub
import run.qontract.mock.mockFromJSON
import run.qontract.mock.validateMock
import run.qontract.nullLog
import java.io.ByteArrayOutputStream
import java.util.*
import kotlin.text.isEmpty
import kotlin.text.toCharArray
import kotlin.text.toLowerCase
data class HttpStubResponse(val response: HttpResponse, val responseLog: String, val delayInSeconds: Int? = null) {
constructor(httpResponse: HttpResponse): this (httpResponse, httpResponseLog(httpResponse))
constructor(httpResponse: HttpResponse, delay: Int?): this(httpResponse, httpResponseLog(httpResponse), delay)
}
class ThreadSafeListOfStubs(private val httpStubs: MutableList) {
@Synchronized
fun listOfStubs(fn: (List) -> ReturnType): ReturnType {
return fn(httpStubs.toList())
}
@Synchronized
fun addToStub(result: Pair, stub: ScenarioStub) {
result.second.let {
when (it) {
null -> Unit
else -> httpStubs.add(0, it.copy(delayInSeconds = stub.delayInSeconds))
}
}
}
}
class HttpStub(private val features: List, _httpStubs: List = emptyList(), host: String = "127.0.0.1", port: Int = 9000, private val log: (event: String) -> Unit = nullLog, private val strictMode: Boolean = false, keyStoreData: KeyStoreData? = null, val passThroughTargetBase: String = "", val httpClientFactory: HttpClientFactory = HttpClientFactory()) : ContractStub {
constructor(feature: Feature, scenarioStubs: List = emptyList(), host: String = "localhost", port: Int = 9000, log: (event: String) -> Unit = nullLog) : this(listOf(feature), contractInfoToHttpExpectations(listOf(Pair(feature, scenarioStubs))), host, port, log)
constructor(gherkinData: String, scenarioStubs: List = emptyList(), host: String = "localhost", port: Int = 9000, log: (event: String) -> Unit = nullLog) : this(Feature(gherkinData), scenarioStubs, host, port, log)
private val threadSafeHttpStubs = ThreadSafeListOfStubs(_httpStubs.toMutableList())
val endPoint = endPointFromHostAndPort(host, port, keyStoreData)
private val environment = applicationEngineEnvironment {
module {
install(CORS) {
method(HttpMethod.Options)
method(HttpMethod.Get)
method(HttpMethod.Post)
method(HttpMethod.Put)
method(HttpMethod.Delete)
method(HttpMethod.Patch)
header(HttpHeaders.Authorization)
allowCredentials = true
allowNonSimpleContentTypes = true
features.flatMap { feature ->
feature.scenarios.flatMap { scenario ->
scenario.httpRequestPattern.headersPattern.pattern.keys.map { withoutOptionality(it) }
}
}.forEach { header(it) }
anyHost()
}
intercept(ApplicationCallPipeline.Call) {
val logs = mutableListOf()
try {
val httpRequest = ktorHttpRequestToHttpRequest(call)
logs.add(httpRequestLog(httpRequest))
val httpStubResponse: HttpStubResponse = when {
isFetchLogRequest(httpRequest) -> handleFetchLogRequest()
isFetchLoadLogRequest(httpRequest) -> handleFetchLoadLogRequest()
isFetchContractsRequest(httpRequest) -> handleFetchContractsRequest()
isExpectationCreation(httpRequest) -> handleExpectationCreationRequest(httpRequest)
isStateSetupRequest(httpRequest) -> handleStateSetupRequest(httpRequest)
else -> serveStubResponse(httpRequest)
}
respondToKtorHttpResponse(call, httpStubResponse.response, httpStubResponse.delayInSeconds)
logs.add(httpStubResponse.responseLog)
log(logs.joinToString(System.lineSeparator()))
}
catch(e: ContractException) {
val response = badRequest(e.report())
logs.add(httpResponseLog(response))
respondToKtorHttpResponse(call, response)
log(logs.joinToString(System.lineSeparator()))
}
catch(e: Throwable) {
val response = badRequest(exceptionCauseMessage(e))
logs.add(httpResponseLog(response))
respondToKtorHttpResponse(call, response)
log(logs.joinToString(System.lineSeparator()))
}
}
}
when (keyStoreData) {
null -> connector {
this.host = host
this.port = port
}
else -> sslConnector(keyStore = keyStoreData.keyStore, keyAlias = keyStoreData.keyAlias, privateKeyPassword = { keyStoreData.keyPassword.toCharArray() }, keyStorePassword = { keyStoreData.keyPassword.toCharArray() }) {
this.host = host
this.port = port
}
}
}
private val server: ApplicationEngine = embeddedServer(Netty, environment, configure = {
this.callGroupSize = 20
})
private fun handleFetchLoadLogRequest(): HttpStubResponse =
HttpStubResponse(HttpResponse.OK(StringValue(LogTail.getSnapshot())), "")
private fun handleFetchContractsRequest(): HttpStubResponse =
HttpStubResponse(HttpResponse.OK(StringValue(features.joinToString("\n") { it.name })))
private fun handleFetchLogRequest(): HttpStubResponse =
HttpStubResponse(HttpResponse.OK(StringValue(LogTail.getString())), "")
private fun serveStubResponse(httpRequest: HttpRequest): HttpStubResponse =
getHttpResponse(httpRequest, features, threadSafeHttpStubs, strictMode, passThroughTargetBase, httpClientFactory)
private fun handleExpectationCreationRequest(httpRequest: HttpRequest): HttpStubResponse {
return try {
if(httpRequest.body.toStringValue().isEmpty())
throw ContractException("Expectation payload was empty")
val mock = stringToMockScenario(httpRequest.body)
createStub(mock)
HttpStubResponse(HttpResponse.OK)
}
catch(e: ContractException) {
HttpStubResponse(HttpResponse(status = 400, headers = mapOf(QONTRACT_RESULT_HEADER to "failure"), body = StringValue(e.report())))
}
catch (e: Exception) {
HttpStubResponse(HttpResponse(status = 400, headers = mapOf(QONTRACT_RESULT_HEADER to "failure"), body = StringValue(e.localizedMessage ?: e.message ?: e.javaClass.name)))
}
}
// For use from Karate
fun createStub(json: String) {
val mock = stringToMockScenario(StringValue(json))
createStub(mock)
}
fun createStub(stub: ScenarioStub) {
if (stub.kafkaMessage != null) throw ContractException("Mocking Kafka messages over HTTP is not supported right now")
val results = features.asSequence().map { feature ->
try {
val mockResponse = softCastResponseToXML(feature.matchingStub(stub.request, stub.response))
Pair(Result.Success(), mockResponse)
} catch (e: NoMatchingScenario) {
Pair(Result.Failure(e.localizedMessage), null)
}
}
val result = results.find { it.first is Result.Success }
when (result?.first) {
is Result.Success -> threadSafeHttpStubs.addToStub(result, stub)
else -> throw NoMatchingScenario(Results(results.map { it.first }.toMutableList()).report())
}
}
// @Synchronized
// private fun addToStub(result: Pair, stub: ScenarioStub) {
// threadSafeHttpStubs.insertElementAt(result.second?.copy(delayInSeconds = stub.delayInSeconds), 0)
// }
override fun close() {
server.stop(0, 5000)
}
private fun handleStateSetupRequest(httpRequest: HttpRequest): HttpStubResponse {
val body = httpRequest.body
val serverState = toMap(body)
val stateRequestLog = "# >> Request Sent At ${Date()}\n${startLinesWith(valueMapToPlainJsonString(serverState), "# ")}"
features.forEach { feature ->
feature.setServerState(serverState)
}
val completeLog = "$stateRequestLog\n# << Complete At ${Date()}"
return HttpStubResponse(HttpResponse.OK, completeLog)
}
init {
server.start()
}
}
internal suspend fun ktorHttpRequestToHttpRequest(call: ApplicationCall): HttpRequest {
val(body, formFields, multiPartFormData) = bodyFromCall(call)
val requestHeaders = call.request.headers.toMap().mapValues { it.value[0] }
return HttpRequest(method = call.request.httpMethod.value,
path = call.request.path(),
headers = requestHeaders,
body = body,
queryParams = toParams(call.request.queryParameters),
formFields = formFields,
multiPartFormData = multiPartFormData)
}
@OptIn(KtorExperimentalAPI::class)
private suspend fun bodyFromCall(call: ApplicationCall): Triple, List> {
return when {
call.request.httpMethod == HttpMethod.Get -> Triple(EmptyString, emptyMap(), emptyList())
call.request.contentType().match(ContentType.Application.FormUrlEncoded) -> Triple(EmptyString, call.receiveParameters().toMap().mapValues { (_, values) -> values.first() }, emptyList())
call.request.isMultipart() -> {
val multiPartData = call.receiveMultipart()
val boundary = call.request.contentType().parameter("boundary") ?: "boundary"
val parts = multiPartData.readAllParts().map {
when (it) {
is PartData.FileItem -> {
val content: String = it.provider().asStream().use { inputStream ->
inputStream.bufferedReader().readText()
}
MultiPartFileValue(it.name ?: "", it.originalFileName ?: "", "${it.contentType?.contentType}/${it.contentType?.contentSubtype}", null, content, boundary)
}
is PartData.FormItem -> {
MultiPartContentValue(it.name ?: "", StringValue(it.value), boundary)
}
is PartData.BinaryItem -> {
val content = it.provider().asStream().use { input ->
val output = ByteArrayOutputStream()
input.copyTo(output)
output.toString()
}
MultiPartContentValue(it.name ?: "", StringValue(content), boundary)
}
}
}
Triple(EmptyString, emptyMap(), parts)
}
else -> Triple(parsedValue(call.receiveText()), emptyMap(), emptyList())
}
}
internal fun toParams(queryParameters: Parameters) = queryParameters.toMap().mapValues { it.value.first() }
internal fun respondToKtorHttpResponse(call: ApplicationCall, httpResponse: HttpResponse, delayInSeconds: Int? = null) {
val headerString = httpResponse.headers["Content-Type"] ?: httpResponse.body.httpContentType
val textContent = TextContent(httpResponse.body.toStringValue(), ContentType.parse(headerString), HttpStatusCode.fromValue(httpResponse.status))
val headersControlledByEngine = HttpHeaders.UnsafeHeadersList.map { it.toLowerCase() }
for ((name, value) in httpResponse.headers.filterNot { it.key.toLowerCase() in headersControlledByEngine }) {
call.response.headers.append(name, value)
}
runBlocking {
if(delayInSeconds != null) {
delay(delayInSeconds * 1000L)
}
call.respond(textContent)
}
}
fun getHttpResponse(httpRequest: HttpRequest, features: List, threadSafeStubs: ThreadSafeListOfStubs, strictMode: Boolean, passThroughTargetBase: String = "", httpClientFactory: HttpClientFactory? = null): HttpStubResponse {
return try {
val (matchResults, stubResponse) = stubbedResponse(threadSafeStubs, httpRequest)
val response = stubResponse ?: if (strictMode)
HttpStubResponse(http400Response(matchResults))
else
HttpStubResponse(fakeHttpResponse(features, httpRequest))
if(response.response.headers["X-Qontract-Empty"] == "true" && httpClientFactory != null && passThroughTargetBase.isNotBlank())
passThroughResponse(httpRequest, passThroughTargetBase, httpClientFactory)
else
response
} finally {
features.forEach { feature ->
feature.clearServerState()
}
}
}
fun passThroughResponse(httpRequest: HttpRequest, passThroughUrl: String, httpClientFactory: HttpClientFactory): HttpStubResponse {
val response = httpClientFactory.client(passThroughUrl).execute(httpRequest)
return HttpStubResponse(response)
}
private fun stubbedResponse(
threadSafeStubs: ThreadSafeListOfStubs,
httpRequest: HttpRequest
): Pair>, HttpStubResponse?> {
val matchResults = threadSafeStubs.listOfStubs { stubs ->
stubs.map {
val (requestPattern, _, resolver) = it
Pair(requestPattern.matches(httpRequest, resolver.copy(findMissingKey = checkAllKeys)), it)
}
}
val mock = matchResults.find { (result, _) -> result is Result.Success }?.second
val stubResponse = mock?.let {
HttpStubResponse(it.softCastResponseToXML().response, it.delayInSeconds)
}
return Pair(matchResults, stubResponse)
}
private fun fakeHttpResponse(features: List, httpRequest: HttpRequest): HttpResponse {
val responses = features.asSequence().map {
it.stubResponse(httpRequest)
}.toList()
return when (val successfulResponse = responses.firstOrNull { it.headers.getOrDefault(QONTRACT_RESULT_HEADER, "none") != "failure" }) {
null -> {
val (headers, body) = when {
responses.all { it.headers.getOrDefault("X-Qontract-Empty", "none") == "true" } -> {
Pair(mapOf("X-Qontract-Empty" to "true"), StringValue(PATH_NOT_RECOGNIZED_ERROR))
}
else -> Pair(emptyMap(), StringValue(responses.map {
it.body
}.filter { it != EmptyString }.joinToString("\n\n")))
}
HttpResponse(400, headers = headers.plus(mapOf(QONTRACT_RESULT_HEADER to "failure")), body = body)
}
else -> successfulResponse
}
}
private fun http400Response(matchResults: List>): HttpResponse {
val failureResults = matchResults.map { it.first }
val results = Results(failureResults.toMutableList()).withoutFluff()
return HttpResponse(400, headers = mapOf(QONTRACT_RESULT_HEADER to "failure"), body = StringValue("STRICT MODE ON\n\n${results.report()}"))
}
fun stubResponse(httpRequest: HttpRequest, contractInfo: List>>, stubs: StubDataItems): HttpResponse {
return try {
when (val mock = stubs.http.find { (requestPattern, _, resolver) ->
requestPattern.matches(httpRequest, resolver.copy(findMissingKey = checkAllKeys)) is Result.Success
}) {
null -> {
val responses = contractInfo.asSequence().map { (feature, _) ->
feature.lookupResponse(httpRequest)
}
responses.firstOrNull {
it.headers.getOrDefault(QONTRACT_RESULT_HEADER, "none") != "failure"
} ?: HttpResponse(400, responses.map {
it.body
}.filter { it != EmptyString }.joinToString("\n\n"))
}
else -> mock.response
}
} finally {
contractInfo.forEach { (feature, _) ->
feature.clearServerState()
}
}
}
fun contractInfoToHttpExpectations(contractInfo: List>>): List {
return contractInfo.flatMap { (feature, mocks) ->
mocks.filter { it.kafkaMessage == null }.map { mock ->
feature.matchingStub(mock)
}
}
}
fun badRequest(errorMessage: String?): HttpResponse {
return HttpResponse(HttpStatusCode.BadRequest.value, errorMessage, mapOf(QONTRACT_RESULT_HEADER to "failure"))
}
internal fun httpResponseLog(response: HttpResponse): String =
"${response.toLogString("<- ")}\n<< Response At ${Date()} == "
internal fun httpRequestLog(httpRequest: HttpRequest): String =
">> Request Start At ${Date()}\n${httpRequest.toLogString("-> ")}"
fun endPointFromHostAndPort(host: String, port: Int?, keyStoreData: KeyStoreData?): String {
val protocol = when(keyStoreData) {
null -> "http"
else -> "https"
}
val computedPortString = when(port) {
80, null -> ""
else -> ":$port"
}
return "$protocol://$host$computedPortString"
}
internal fun isFetchLogRequest(httpRequest: HttpRequest): Boolean =
httpRequest.path == "/_qontract/log" && httpRequest.method == "GET"
internal fun isFetchContractsRequest(httpRequest: HttpRequest): Boolean =
httpRequest.path == "/_qontract/contracts" && httpRequest.method == "GET"
internal fun isFetchLoadLogRequest(httpRequest: HttpRequest): Boolean =
httpRequest.path == "/_qontract/load_log" && httpRequest.method == "GET"
internal fun isExpectationCreation(httpRequest: HttpRequest) =
httpRequest.path == "/_qontract/expectations" && httpRequest.method == "POST"
internal fun isStateSetupRequest(httpRequest: HttpRequest): Boolean =
httpRequest.path == "/_qontract/state" && httpRequest.method == "POST"
fun softCastResponseToXML(mockResponse: HttpStubData): HttpStubData =
mockResponse.copy(response = mockResponse.response.copy(body = softCastValueToXML(mockResponse.response.body)))
fun softCastValueToXML(body: Value): Value {
return when(body) {
is StringValue -> try {
XMLNode(body.string)
} catch (e: Throwable) {
body
}
else -> body
}
}
fun stringToMockScenario(text: Value): ScenarioStub {
val mockSpec =
jsonStringToValueMap(text.toStringValue()).also {
validateMock(it)
}
return mockFromJSON(mockSpec)
}