All Downloads are FREE. Search and download functionalities are using the official Maven repository.

run.qontract.stub.HttpStub.kt Maven / Gradle / Ivy

Go to download

A Contract Testing Tool that leverages Gherkin to describe APIs in a human readable and machine enforceable manner

There is a newer version: 0.23.1
Show newest version
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)
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy