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

io.specmatic.stub.HttpStub.kt Maven / Gradle / Ivy

Go to download

Turn your contracts into executable specifications. Contract Driven Development - Collaboratively Design & Independently Deploy MicroServices & MicroFrontends.

There is a newer version: 2.0.37
Show newest version
package io.specmatic.stub

import com.fasterxml.jackson.databind.ObjectMapper
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.cors.*
import io.ktor.server.plugins.doublereceive.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.util.*
import io.specmatic.core.*
import io.specmatic.core.log.*
import io.specmatic.core.pattern.ContractException
import io.specmatic.core.pattern.IgnoreUnexpectedKeys
import io.specmatic.core.pattern.parsedValue
import io.specmatic.core.route.modules.HealthCheckModule.Companion.configureHealthCheckModule
import io.specmatic.core.route.modules.HealthCheckModule.Companion.isHealthCheckRequest
import io.specmatic.core.utilities.*
import io.specmatic.core.value.*
import io.specmatic.mock.*
import io.specmatic.stub.report.StubEndpoint
import io.specmatic.stub.report.StubUsageReport
import io.specmatic.test.HttpClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.Writer
import java.nio.charset.Charset
import java.util.*
import kotlin.text.toCharArray

class HttpStub(
    private val features: List,
    rawHttpStubs: List = emptyList(),
    host: String = "127.0.0.1",
    port: Int = 9000,
    private val log: (event: LogMessage) -> Unit = dontPrintToConsole,
    private val strictMode: Boolean = false,
    keyData: KeyData? = null,
    val passThroughTargetBase: String = "",
    val httpClientFactory: HttpClientFactory = HttpClientFactory(),
    val workingDirectory: WorkingDirectory? = null,
    val specmaticConfigPath: String? = null,
    private val timeoutMillis: Long = 0,
) : ContractStub {
    constructor(
        feature: Feature,
        scenarioStubs: List = emptyList(),
        host: String = "localhost",
        port: Int = 9000,
        log: (event: LogMessage) -> Unit = dontPrintToConsole
    ) : 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: LogMessage) -> Unit = dontPrintToConsole
    ) : this(parseGherkinStringToFeature(gherkinData), scenarioStubs, host, port, log)

    companion object {
        const val JSON_REPORT_PATH = "./build/reports/specmatic"
        const val JSON_REPORT_FILE_NAME = "stub_usage_report.json"

        fun setExpectation(
            stub: ScenarioStub,
            feature: Feature,
            mismatchMessages: MismatchMessages = ContractAndStubMismatchMessages
        ): Pair>?, NoMatchingScenario?> {
            try {
                val tier1Match = feature.matchingStub(
                    stub,
                    mismatchMessages
                )

                val matchedScenario = tier1Match.scenario ?: throw ContractException("Expected scenario after stub matched for:${System.lineSeparator()}${stub.toJSON()}")

                val stubWithSubstitutionsResolved = stub.resolveDataSubstitutions(matchedScenario).map { scenarioStub ->
                    feature.matchingStub(scenarioStub, ContractAndStubMismatchMessages)
                }

                val stubData: List = stubWithSubstitutionsResolved.map {
                    softCastResponseToXML(
                        it
                    )
                }

                return Pair(Pair(Result.Success(), stubData), null)
            } catch (e: NoMatchingScenario) {
                return Pair(null, e)
            }
        }
    }

    private val specmaticConfig: SpecmaticConfig =
        if(specmaticConfigPath != null && File(specmaticConfigPath).exists())
            loadSpecmaticConfig(specmaticConfigPath)
        else
            SpecmaticConfig()

    private val threadSafeHttpStubs = ThreadSafeListOfStubs(staticHttpStubData(rawHttpStubs))

    private val requestHandlers: MutableList = mutableListOf()

    fun registerHandler(requestHandler: RequestHandler) {
        requestHandlers.add(requestHandler)
    }

    private fun staticHttpStubData(rawHttpStubs: List): MutableList {
        val staticStubs = rawHttpStubs.filter { it.stubToken == null }

        val stubsFromSpecificationExamples: List = features.map { feature ->
            feature.stubsFromExamples.entries.map { (exampleName, examples) ->
                examples.mapNotNull { (request, response) ->
                    try {
                        val stubData: HttpStubData =
                            feature.matchingStub(request, response, ExamplesAsExpectationsMismatch(exampleName))

                        if (stubData.matchFailure) {
                            logger.newLine()
                            logger.log(stubData.response.body.toStringLiteral())
                            null
                        } else {
                            stubData
                        }
                    } catch (e: Throwable) {
                        logger.newLine()

                        when (e) {
                            is ContractException -> {
                                logger.log(e)
                                null
                            }
                            is NoMatchingScenario -> {
                                logger.log(e, "[Example $exampleName]")
                                null
                            }
                            else -> {
                                logger.log(e, "[Example $exampleName]")
                                throw e
                            }
                        }
                    }
                }
            }
        }.flatten().flatten()

        return staticStubs.plus(stubsFromSpecificationExamples).toMutableList()
    }

    private val threadSafeHttpStubQueue =
        ThreadSafeListOfStubs(rawHttpStubs.filter { it.stubToken != null }.reversed().toMutableList())

    private val _logs: MutableList = Collections.synchronizedList(ArrayList())
    private val _allEndpoints: List = extractALlEndpoints()

    val logs: List get() = _logs.toList()
    val allEndpoints: List get() = _allEndpoints.toList()


    val stubCount: Int
        get() {
            return threadSafeHttpStubs.size
        }

    val transientStubCount: Int
        get() {
            return threadSafeHttpStubQueue.size
        }

    val endPoint = endPointFromHostAndPort(host, port, keyData)

    override val client = HttpClient(this.endPoint)

    private val sseBuffer: SSEBuffer = SSEBuffer()

    private val broadcastChannels: Vector> = Vector(50, 10)

    private val requestInterceptors: MutableList = mutableListOf()

    fun registerRequestInterceptor(requestInterceptor: RequestInterceptor) {
        requestInterceptors.add(requestInterceptor)
    }

    private val environment = applicationEngineEnvironment {
        module {
            install(DoubleReceive)

            install(CORS) {
                allowMethod(HttpMethod.Options)
                allowMethod(HttpMethod.Get)
                allowMethod(HttpMethod.Post)
                allowMethod(HttpMethod.Put)
                allowMethod(HttpMethod.Delete)
                allowMethod(HttpMethod.Patch)

                allowHeaders {
                    true
                }

                allowCredentials = true
                allowNonSimpleContentTypes = true

                anyHost()
            }

            intercept(ApplicationCallPipeline.Call) {
                val httpLogMessage = HttpLogMessage()

                try {
                    val rawHttpRequest = ktorHttpRequestToHttpRequest(call)
                    httpLogMessage.addRequest(rawHttpRequest)

                    if(rawHttpRequest.isHealthCheckRequest()) return@intercept

                    val httpRequest = requestInterceptors.fold(rawHttpRequest) { request, requestInterceptor ->
                        requestInterceptor.interceptRequest(request) ?: request
                    }


                    val responseFromRequestHandler = requestHandlers.map { it.handleRequest(httpRequest) }.firstOrNull()

                    val httpStubResponse: HttpStubResponse = when {
                        isFetchLogRequest(httpRequest) -> handleFetchLogRequest()
                        isFetchLoadLogRequest(httpRequest) -> handleFetchLoadLogRequest()
                        isFetchContractsRequest(httpRequest) -> handleFetchContractsRequest()
                        responseFromRequestHandler != null -> responseFromRequestHandler
                        isExpectationCreation(httpRequest) -> handleExpectationCreationRequest(httpRequest)
                        isSseExpectationCreation(httpRequest) -> handleSseExpectationCreationRequest(httpRequest)
                        isStateSetupRequest(httpRequest) -> handleStateSetupRequest(httpRequest)
                        isFlushTransientStubsRequest(httpRequest) -> handleFlushTransientStubsRequest(httpRequest)
                        else -> serveStubResponse(httpRequest, specmaticConfig)
                    }

                    if (httpRequest.path!!.startsWith("""/features/default""")) {
                        logger.log("Incoming subscription on URL path ${httpRequest.path} ")
                        val channel: Channel = Channel(10, BufferOverflow.DROP_OLDEST)
                        val broadcastChannel: BroadcastChannel = channel.broadcast()
                        broadcastChannels.add(broadcastChannel)

                        val events: ReceiveChannel = broadcastChannel.openSubscription()

                        try {
                            call.respondSse(events, sseBuffer, httpRequest)

                            broadcastChannels.remove(broadcastChannel)

                            close(
                                events,
                                channel,
                                "Events handle was already closed after handling all events",
                                "Channel was already handled after handling all events"
                            )
                        } catch (e: Throwable) {
                            logger.log(e, "Exception in the SSE module")

                            broadcastChannels.remove(broadcastChannel)

                            close(
                                events,
                                channel,
                                "Events handle threw an exception on closing",
                                "Channel through an exception on closing"
                            )
                        }
                    } else {
                        respondToKtorHttpResponse(call, httpStubResponse.response, httpStubResponse.delayInMilliSeconds, specmaticConfig)
                        httpLogMessage.addResponse(httpStubResponse)
                    }
                } catch (e: ContractException) {
                    val response = badRequest(e.report())
                    httpLogMessage.addResponse(response)
                    respondToKtorHttpResponse(call, response)
                } catch (e: CouldNotParseRequest) {
                    httpLogMessage.addRequest(defensivelyExtractedRequestForLogging(call))

                    val response = badRequest("Could not parse request")
                    httpLogMessage.addResponse(response)

                    respondToKtorHttpResponse(call, response)
                } catch (e: Throwable) {
                    val response = internalServerError(exceptionCauseMessage(e) + "\n\n" + e.stackTraceToString())
                    httpLogMessage.addResponse(response)

                    respondToKtorHttpResponse(call, response)
                }

                log(httpLogMessage)
            }

            configureHealthCheckModule()
        }

        when (keyData) {
            null -> connector {
                this.host = host
                this.port = port
            }

            else -> sslConnector(
                keyStore = keyData.keyStore,
                keyAlias = keyData.keyAlias,
                privateKeyPassword = { keyData.keyPassword.toCharArray() },
                keyStorePassword = { keyData.keyPassword.toCharArray() }) {
                this.host = host
                this.port = port
            }
        }
    }

    private fun handleFlushTransientStubsRequest(httpRequest: HttpRequest): HttpStubResponse {
        val token = httpRequest.path?.removePrefix("/_specmatic/$TRANSIENT_MOCK/")

        threadSafeHttpStubQueue.removeWithToken(token)

        return HttpStubResponse(HttpResponse.OK)
    }

    private fun isFlushTransientStubsRequest(httpRequest: HttpRequest): Boolean {
        return httpRequest.method?.toLowerCasePreservingASCIIRules() == "delete" && httpRequest.path?.startsWith("/_specmatic/$TRANSIENT_MOCK/") == true
    }

    private fun close(
        events: ReceiveChannel,
        channel: Channel,
        eventsError: String,
        channelError: String
    ) {
        try {
            events.cancel()
        } catch (e: Throwable) {
            logger.log("$eventsError (${exceptionCauseMessage(e)})")
        }

        try {
            channel.cancel()
        } catch (e: Throwable) {
            logger.log("$channelError (${exceptionCauseMessage(e)}")
        }
    }

    private suspend fun defensivelyExtractedRequestForLogging(call: ApplicationCall): HttpRequest {
        val request = HttpRequest().let {
            try {
                it.copy(method = call.request.httpMethod.toString())
            } catch (e: Throwable) {
                it
            }
        }.let {
            try {
                it.copy(path = call.request.path())
            } catch (e: Throwable) {
                it
            }
        }.let { request ->
            val requestHeaders = call.request.headers.toMap().mapValues { it.value[0] }
            request.copy(headers = requestHeaders)
        }.let {
            val queryParams = toParams(call.request.queryParameters)
            it.copy(queryParams = QueryParameters(paramPairs = queryParams))
        }.let {
            val bodyOrError = try {
                receiveText(call)
            } catch (e: Throwable) {
                "Could not get body. Got exception: ${exceptionCauseMessage(e)}\n\n${e.stackTraceToString()}"
            }

            it.copy(body = StringValue(bodyOrError))
        }
        return request
    }

    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, specmaticConfig: SpecmaticConfig): HttpStubResponse {
        val result: StubbedResponseResult = getHttpResponse(
            httpRequest,
            features,
            threadSafeHttpStubs,
            threadSafeHttpStubQueue,
            strictMode,
            passThroughTargetBase,
            httpClientFactory,
            specmaticConfig
        )

        result.log(_logs, httpRequest)

        return result.response
    }

    private fun handleExpectationCreationRequest(httpRequest: HttpRequest): HttpStubResponse {
        return try {
            if (httpRequest.body.toStringLiteral().isEmpty())
                throw ContractException("Expectation payload was empty")

            val mock: ScenarioStub = stringToMockScenario(httpRequest.body)
            val stub: HttpStubData = setExpectation(mock).first()

            HttpStubResponse(HttpResponse.OK, contractPath = stub.contractPath)
        } catch (e: ContractException) {
            HttpStubResponse(
                HttpResponse(
                    status = 400,
                    headers = mapOf(SPECMATIC_RESULT_HEADER to "failure"),
                    body = StringValue(e.report())
                )
            )
        } catch (e: NoMatchingScenario) {
            HttpStubResponse(
                HttpResponse(
                    status = 400,
                    headers = mapOf(SPECMATIC_RESULT_HEADER to "failure"),
                    body = StringValue(e.report(httpRequest))
                )
            )
        } catch (e: Throwable) {
            HttpStubResponse(
                HttpResponse(
                    status = 400,
                    headers = mapOf(SPECMATIC_RESULT_HEADER to "failure"),
                    body = StringValue(e.localizedMessage ?: e.message ?: e.javaClass.name)
                )
            )
        }
    }

    private suspend fun handleSseExpectationCreationRequest(httpRequest: HttpRequest): HttpStubResponse {
        return try {
            val sseEvent: SseEvent? = ObjectMapper().readValue(httpRequest.bodyString, SseEvent::class.java)

            if (sseEvent == null) {
                logger.debug("No Sse Event was found in the request:\n${httpRequest.toLogString("  ")}")
            } else if (sseEvent.bufferIndex == null) {
                logger.debug("Broadcasting event: $sseEvent")

                for (channel in broadcastChannels) {
                    channel.send(sseEvent)
                }
            } else {
                logger.debug("Adding event to buffer: $sseEvent")
                sseBuffer.add(sseEvent)
            }

            HttpStubResponse(HttpResponse.OK, contractPath = "")
        } catch (e: ContractException) {
            HttpStubResponse(
                HttpResponse(
                    status = 400,
                    headers = mapOf(SPECMATIC_RESULT_HEADER to "failure"),
                    body = exceptionCauseMessage(e)
                )
            )
        } catch (e: Throwable) {
            HttpStubResponse(
                HttpResponse(
                    status = 500,
                    headers = mapOf(SPECMATIC_RESULT_HEADER to "failure"),
                    body = exceptionCauseMessage(e) + "\n\n" + e.stackTraceToString()
                )
            )
        }
    }

    // Java helper
    override fun setExpectation(json: String) {
        val mock = stringToMockScenario(StringValue(json))
        setExpectation(mock)
    }

    fun setExpectation(stub: ScenarioStub): List {
        val results = features.asSequence().map { feature -> setExpectation(stub, feature) }

        val result: Pair>?, NoMatchingScenario?>? = results.find { it.first != null }
        val firstResult: Pair>? = result?.first

        when (firstResult) {
            null -> {
                val failures = results.map {
                    it.second?.results?.withoutFluff()?.results ?: emptyList()
                }.flatten().toList()

                val failureResults = Results(failures).withoutFluff()
                throw NoMatchingScenario(failureResults, cachedMessage = failureResults.report(stub.request))
            }

            else -> {
                val requestBodyRegex = parseRegex(stub.requestBodyRegex)
                val stubData = firstResult.second.map { it.copy(requestBodyRegex = requestBodyRegex) }
                val resultWithRequestBodyRegex = stubData.map { Pair(firstResult.first, it) }

                if (stub.stubToken != null) {
                    resultWithRequestBodyRegex.forEach {
                        threadSafeHttpStubQueue.addToStub(it, stub)
                    }

                } else {
                    resultWithRequestBodyRegex.forEach {
                        threadSafeHttpStubs.addToStub(it, stub)
                    }
                }
            }
        }

        return firstResult.second
    }

    private fun parseRegex(regex: String?): Regex? {
        return regex?.let {
            try {
                Regex(it)
            } catch (e: Throwable) {
                throw ContractException("Couldn't parse regex $regex", exceptionCause = e)
            }
        }
    }

    override fun close() {
        server.stop(gracePeriodMillis = timeoutMillis, timeoutMillis = timeoutMillis)
        printUsageReport()
    }

    private fun handleStateSetupRequest(httpRequest: HttpRequest): HttpStubResponse {
        val body = httpRequest.body
        val serverState = toMap(body)

        features.forEach { feature ->
            feature.setServerState(serverState)
        }

        return HttpStubResponse(HttpResponse.OK)
    }

    init {
        server.start()
    }

    private fun extractALlEndpoints(): List {
        return features.map {
            it.scenarios.map { scenario ->
                if (scenario.isA2xxScenario()) {
                    StubEndpoint(
                        scenario.path,
                        scenario.method,
                        scenario.status,
                        scenario.sourceProvider,
                        scenario.sourceRepository,
                        scenario.sourceRepositoryBranch,
                        scenario.specification,
                        scenario.serviceType
                    )
                } else {
                    null
                }
            }
        }.flatten().filterNotNull()
    }

    private fun printUsageReport() {
        specmaticConfigPath?.let {
            val stubUsageReport = StubUsageReport(specmaticConfigPath, _allEndpoints, _logs)
            println("Saving Stub Usage Report json to $JSON_REPORT_PATH ...")
            val json = Json {
                encodeDefaults = false
            }
            val reportJson = json.encodeToString(stubUsageReport.generate())
            saveJsonFile(reportJson, JSON_REPORT_PATH, JSON_REPORT_FILE_NAME)
        }
    }
}

class CouldNotParseRequest(innerException: Throwable) : Exception(exceptionCauseMessage(innerException))

internal suspend fun ktorHttpRequestToHttpRequest(call: ApplicationCall): HttpRequest {
    try {
        val (body, formFields, multiPartFormData) = bodyFromCall(call)

        val requestHeaders = call.request.headers.toMap().mapValues { it.value[0] }

        return HttpRequest(
            method = call.request.httpMethod.value,
            path = urlDecodePathSegments(call.request.path()),
            headers = requestHeaders,
            body = body,
            queryParams = QueryParameters(paramPairs = toParams(call.request.queryParameters)),
            formFields = formFields,
            multiPartFormData = multiPartFormData
        )
    } catch (e: Throwable) {
        throw CouldNotParseRequest(e)
    }
}

private suspend fun bodyFromCall(call: ApplicationCall): Triple, List> {
    return when {
        call.request.httpMethod == HttpMethod.Get -> if(call.request.headers.contains("Content-Type")) {
            Triple(parsedValue(receiveText(call)), emptyMap(), emptyList())
        } else {
            Triple(NoBodyValue, 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 = it.provider().asStream().use { inputStream ->
                            MultiPartContent(inputStream.readBytes())
                        }
                        MultiPartFileValue(
                            it.name ?: "",
                            it.originalFileName ?: "",
                            it.contentType?.let { contentType -> "${contentType.contentType}/${contentType.contentSubtype}" },
                            null,
                            content,
                            boundary
                        )
                    }

                    is PartData.FormItem -> {
                        MultiPartContentValue(
                            it.name ?: "",
                            StringValue(it.value),
                            boundary,
                            specifiedContentType = it.contentType?.let { contentType -> "${contentType.contentType}/${contentType.contentSubtype}" }
                        )
                    }

                    is PartData.BinaryItem -> {
                        val content = it.provider().asStream().use { input ->
                            val output = ByteArrayOutputStream()
                            input.copyTo(output)
                            output.toString()
                        }

                        MultiPartContentValue(
                            it.name ?: "",
                            StringValue(content),
                            boundary,
                            specifiedContentType = it.contentType?.let { contentType -> "${contentType.contentType}/${contentType.contentSubtype}" }
                        )
                    }

                    else -> {
                        throw UnsupportedOperationException("Unhandled PartData")
                    }
                }
            }

            Triple(EmptyString, emptyMap(), parts)
        }

        else -> {
            if(call.request.headers.contains("Content-Type"))
                Triple(parsedValue(receiveText(call)), emptyMap(), emptyList())
            else
                Triple(NoBodyValue, emptyMap(), emptyList())
        }
    }
}

suspend fun receiveText(call: ApplicationCall): String {
    return if (call.request.contentCharset() == null) {
        val byteArray: ByteArray = call.receive()
        String(byteArray, Charset.forName("UTF-8"))
    } else {
        call.receiveText()
    }
}

//internal fun toParams(queryParameters: Parameters) = queryParameters.toMap().mapValues { it.value.first() }

internal fun toParams(queryParameters: Parameters): List> =
    queryParameters.toMap().flatMap { (parameterName, parameterValues) ->
        parameterValues.map {
            parameterName to it
        }
    }

internal suspend fun respondToKtorHttpResponse(
    call: ApplicationCall,
    httpResponse: HttpResponse,
    delayInMilliSeconds: Long? = null,
    specmaticConfig: SpecmaticConfig? = null
) {
    val headersControlledByEngine = listOfExcludedHeaders().map { it.lowercase() }
    for ((name, value) in httpResponse.headers.filterNot { it.key.lowercase() in headersControlledByEngine }) {
        call.response.headers.append(name, value)
    }

    val delayInMs = delayInMilliSeconds ?: specmaticConfig?.stub?.delayInMilliseconds
    if (delayInMs != null) {
        delay(delayInMs)
    }

    val contentType = httpResponse.headers["Content-Type"] ?: httpResponse.body.httpContentType
    val responseBody = httpResponse.body.toStringLiteral()
    val status = HttpStatusCode.fromValue(httpResponse.status)

    if (contentType.isBlank()) {
        call.respond(object : OutgoingContent.NoContent() {
            override val status: HttpStatusCode = HttpStatusCode.fromValue(httpResponse.status)
        })
        return
    }

    call.respond(TextContent(responseBody, ContentType.parse(contentType), status))
}

fun getHttpResponse(
    httpRequest: HttpRequest,
    features: List,
    threadSafeStubs: ThreadSafeListOfStubs,
    threadSafeStubQueue: ThreadSafeListOfStubs,
    strictMode: Boolean,
    passThroughTargetBase: String = "",
    httpClientFactory: HttpClientFactory? = null,
    specmaticConfig: SpecmaticConfig = SpecmaticConfig()
): StubbedResponseResult {
    return try {
        val (matchResults, matchingStubResponse) = stubbedResponse(threadSafeStubs, threadSafeStubQueue, httpRequest)

        if(matchingStubResponse != null) {
            val (httpStubResponse, httpStubData) = matchingStubResponse
            FoundStubbedResponse(httpStubResponse.resolveSubstitutions(
                httpRequest,
                if(httpStubData.partial != null) httpStubData.partial.request else httpStubData.originalRequest ?: httpRequest,
                httpStubData.data,
            ))
        }
        else if (httpClientFactory != null && passThroughTargetBase.isNotBlank())
            NotStubbed(passThroughResponse(httpRequest, passThroughTargetBase, httpClientFactory))
        else if (strictMode) {
            NotStubbed(HttpStubResponse(strictModeHttp400Response(httpRequest, matchResults)))
        } else {
            fakeHttpResponse(features, httpRequest, specmaticConfig)
        }
    } finally {
        features.forEach { feature ->
            feature.clearServerState()
        }
    }
}

const val SPECMATIC_SOURCE_HEADER = "X-$APPLICATION_NAME-Source"

fun passThroughResponse(
    httpRequest: HttpRequest,
    passThroughUrl: String,
    httpClientFactory: HttpClientFactory
): HttpStubResponse {
    val response = httpClientFactory.client(passThroughUrl).execute(httpRequest)
    return HttpStubResponse(response.copy(headers = response.headers.plus(SPECMATIC_SOURCE_HEADER to "proxy")))
}

object StubAndRequestMismatchMessages : MismatchMessages {
    override fun mismatchMessage(expected: String, actual: String): String {
        return "Stub expected $expected but request contained $actual"
    }

    override fun unexpectedKey(keyLabel: String, keyName: String): String {
        return "${keyLabel.lowercase().capitalizeFirstChar()} named $keyName in the request was not in the stub"
    }

    override fun expectedKeyWasMissing(keyLabel: String, keyName: String): String {
        return "${keyLabel.lowercase().capitalizeFirstChar()} named $keyName in the stub was not found in the request"
    }
}

private fun stubbedResponse(
    threadSafeStubs: ThreadSafeListOfStubs,
    threadSafeStubQueue: ThreadSafeListOfStubs,
    httpRequest: HttpRequest
): Pair>, Pair?> {

    val (mock, matchResults) = stubThatMatchesRequest(threadSafeStubQueue, threadSafeStubs, httpRequest)

    val stubResponse = mock?.let {
        val softCastResponse = it.softCastResponseToXML(httpRequest).response
        HttpStubResponse(
            softCastResponse,
            it.delayInMilliseconds,
            it.contractPath,
            scenario = mock.scenario,
            feature = mock.feature,
            examplePath = it.examplePath
        ) to it
    }

    return Pair(matchResults, stubResponse)
}

private fun stubThatMatchesRequest(
    transientStubs: ThreadSafeListOfStubs,
    nonTransientStubs: ThreadSafeListOfStubs,
    httpRequest: HttpRequest
): Pair>> {
    val queueMatchResults: List> = transientStubs.matchResults { stubs ->
        stubs.map {
            val (requestPattern, _, resolver) = it
            Pair(
                requestPattern.matches(
                    httpRequest,
                    resolver.disableOverrideUnexpectedKeycheck()
                        .copy(mismatchMessages = StubAndRequestMismatchMessages),
                    requestBodyReqex = it.requestBodyRegex
                ), it
            )
        }
    }

    val queueMock = queueMatchResults.findLast { (result, _) -> result is Result.Success }
    if (queueMock != null) {
        transientStubs.remove(queueMock.second)
        return Pair(queueMock.second, queueMatchResults)
    }

    val listMatchResults: List> = nonTransientStubs.matchResults { httpStubData ->
        val nonPartialMatchResults = httpStubData.filter { it.partial == null }.map {
            val (requestPattern, _, resolver) = it
            Pair(
                requestPattern.matches(
                    httpRequest,
                    resolver.disableOverrideUnexpectedKeycheck()
                        .copy(mismatchMessages = StubAndRequestMismatchMessages),
                    requestBodyReqex = it.requestBodyRegex
                ), it
            )
        }

        val partialMatchResults = httpStubData
            .map { it.partial?.let { partial -> it to partial } }
            .filterNotNull()
            .map { (stubData, partial) ->
                val (requestPattern, _, resolver) = stubData

                val partialRequest = requestPattern.generate(partial.request, resolver)

                val partialResolver = resolver.copy(
                    findKeyErrorCheck = KeyCheck(unexpectedKeyCheck = IgnoreUnexpectedKeys)
                )

                val partialResult = partialRequest.matches(httpRequest, partialResolver, partialResolver)

                if(!partialResult.isSuccess())
                    partialResult to stubData
                else
                    Pair(
                        requestPattern.matches(
                            httpRequest,
                            resolver.disableOverrideUnexpectedKeycheck()
                                .copy(mismatchMessages = StubAndRequestMismatchMessages),
                            requestBodyReqex = stubData.requestBodyRegex
                        ), stubData
                    )
            }

        partialMatchResults + nonPartialMatchResults
    }

    val mock = listMatchResults.map {
        val (result, stubData) = it

        if(result is Result.Success) {
            val response = if(stubData.partial != null)
                stubData.responsePattern.generateResponse(stubData.partial.response, stubData.dictionary, stubData.resolver)
            else
                stubData.response

            val stubResponse = HttpStubResponse(
                response,
                stubData.delayInMilliseconds,
                stubData.contractPath,
                scenario = stubData.scenario,
                feature = stubData.feature,
                dictionary = stubData.dictionary
            )

            try {
                val originalRequest =
                    if(stubData.partial != null)
                        stubData.partial.request
                    else
                        stubData.originalRequest

                    stubResponse.resolveSubstitutions(httpRequest, originalRequest ?: httpRequest, it.second.data)

                result to stubData.copy(response = response)
            } catch(e: ContractException) {
                if(isMissingData(e))
                    Pair(e.failure(), stubData)
                else
                    throw e
            }
        } else
            it
    }.find { (result, _) -> result is Result.Success }

    return Pair(mock?.second, listMatchResults)
}

fun isMissingData(e: Throwable?): Boolean {
    return when (e) {
        null -> false
        is MissingDataException -> true
        is ContractException -> isMissingData(e.exceptionCause)
        else -> false
    }
}

object ContractAndRequestsMismatch : MismatchMessages {
    override fun mismatchMessage(expected: String, actual: String): String {
        return "Contract expected $expected but request contained $actual"
    }

    override fun unexpectedKey(keyLabel: String, keyName: String): String {
        return "${keyLabel.lowercase().capitalizeFirstChar()} named $keyName in the request was not in the contract"
    }

    override fun expectedKeyWasMissing(keyLabel: String, keyName: String): String {
        return "${
            keyLabel.lowercase().capitalizeFirstChar()
        } named $keyName in the contract was not found in the request"
    }
}

private fun fakeHttpResponse(features: List, httpRequest: HttpRequest, specmaticConfig: SpecmaticConfig = SpecmaticConfig()): StubbedResponseResult {
    data class ResponseDetails(val feature: Feature, val successResponse: ResponseBuilder?, val results: Results)

    if (features.isEmpty())
       return NotStubbed(HttpStubResponse(HttpResponse(400, "No valid API specifications loaded")))

    val responses: List = features.asSequence().map { feature ->
        feature.stubResponse(httpRequest, ContractAndRequestsMismatch).let {
            ResponseDetails(feature, it.first, it.second)
        }
    }.toList()

    return when (val fakeResponse = responses.find { it.successResponse != null }) {
        null -> {
            val failureResults = responses.filter { it.successResponse == null }.map { it.results }

            val combinedFailureResult = failureResults.reduce { first, second ->
                first.plus(second)
            }.withoutFluff()

            val firstScenarioWith400Response = failureResults.flatMap { it.results }.filter {
                it is Result.Failure
                    && it.failureReason == null
                    && it.scenario?.let { it.status == 400 || it.status == 422 } == true
            }.map { it.scenario!! }.firstOrNull()

            if(firstScenarioWith400Response != null && specmaticConfig.stub.generative == true) {
                val httpResponse = (firstScenarioWith400Response as Scenario).generateHttpResponse(emptyMap())
                val updatedResponse: HttpResponse = dumpIntoFirstAvailableStringField(httpResponse, combinedFailureResult.report())

                FoundStubbedResponse(
                    HttpStubResponse(
                        updatedResponse,
                        contractPath = "",
                        feature = fakeResponse?.feature,
                        scenario = fakeResponse?.successResponse?.scenario
                    )
                )
            } else {
                val httpFailureResponse = combinedFailureResult.generateErrorHttpResponse(httpRequest)

                NotStubbed(HttpStubResponse(httpFailureResponse))
            }
        }

        else -> FoundStubbedResponse(
            HttpStubResponse(
                fakeResponse.successResponse?.build(RequestContext(httpRequest))?.withRandomResultHeader()!!,
                contractPath = fakeResponse.feature.path,
                feature = fakeResponse.feature,
                scenario = fakeResponse.successResponse.scenario
            )
        )
    }
}

fun dumpIntoFirstAvailableStringField(httpResponse: HttpResponse, stringValue: String): HttpResponse {
    val responseBody = httpResponse.body

    if(responseBody !is JSONObjectValue)
        return httpResponse

    val newBody = dumpIntoFirstAvailableStringField(responseBody, stringValue)

    return httpResponse.copy(body = newBody)
}

fun dumpIntoFirstAvailableStringField(jsonObjectValue: JSONObjectValue, stringValue: String): JSONObjectValue {
    val key = jsonObjectValue.jsonObject.keys.find { key ->
        key == "message" && jsonObjectValue.jsonObject[key] is StringValue
    } ?: jsonObjectValue.jsonObject.keys.find { key ->
        jsonObjectValue.jsonObject[key] is StringValue
    }

    if(key != null)
        return jsonObjectValue.copy(
            jsonObject = jsonObjectValue.jsonObject.plus(
                key to StringValue(stringValue)
            )
        )

    val newMap = jsonObjectValue.jsonObject.mapValues { (key, value) ->
        if(value is JSONObjectValue) {
            dumpIntoFirstAvailableStringField(value, stringValue)
        } else if(value is JSONArrayValue) {
            dumpIntoFirstAvailableStringField(value, stringValue)
        } else {
            value
        }
    }

    return jsonObjectValue.copy(newMap)
}

fun dumpIntoFirstAvailableStringField(jsonArrayValue: JSONArrayValue, stringValue: String): JSONArrayValue {
    val indexOfFirstStringValue = jsonArrayValue.list.indexOfFirst { it is StringValue }

    if(indexOfFirstStringValue >= 0) {
        val mutableList = jsonArrayValue.list.toMutableList()
        mutableList.add(indexOfFirstStringValue, StringValue(stringValue))

        return jsonArrayValue.copy(
            list = mutableList
        )
    }

    val newList = jsonArrayValue.list.map { value ->
        if(value is JSONObjectValue) {
            dumpIntoFirstAvailableStringField(value, stringValue)
        } else if(value is JSONArrayValue) {
            dumpIntoFirstAvailableStringField(value, stringValue)
        } else {
            value
        }
    }

    return jsonArrayValue.copy(list = newList)
}

private fun strictModeHttp400Response(
    httpRequest: HttpRequest,
    matchResults: List>
): HttpResponse {
    val failureResults = matchResults.map { it.first }

    val results = Results(failureResults).withoutFluff()
    return HttpResponse(
        400,
        headers = mapOf(SPECMATIC_RESULT_HEADER to "failure"),
        body = StringValue("STRICT MODE ON${System.lineSeparator()}${System.lineSeparator()}${results.strictModeReport(httpRequest)}")
    )
}

fun stubResponse(
    httpRequest: HttpRequest,
    contractInfo: List>>,
    stubs: StubDataItems
): HttpResponse {
    return try {
        when (val mock = stubs.http.find { (requestPattern, _, resolver) ->
            requestPattern.matches(httpRequest, resolver.disableOverrideUnexpectedKeycheck()) is Result.Success
        }) {
            null -> {
                val responses = contractInfo.asSequence().map { (feature, _) ->
                    feature.lookupResponse(httpRequest)
                }

                responses.firstOrNull {
                    it.headers.getOrDefault(SPECMATIC_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, examples) ->
        examples.map { example ->
            feature.matchingStub(example, ContractAndStubMismatchMessages) to example
        }.flatMap { (stubData, example) ->
            val examplesWithDataSubstitutionsResolved = try {
                example.resolveDataSubstitutions(stubData.scenario!!)
            } catch(e: Throwable) {
                println()
                logger.log("    Error resolving template data for example ${example.filePath}")
                logger.log("    " + exceptionCauseMessage(e))
                throw e
            }

            examplesWithDataSubstitutionsResolved.map {
                feature.matchingStub(it, ContractAndStubMismatchMessages)
            }
        }
    }
}

fun badRequest(errorMessage: String?): HttpResponse {
    return HttpResponse(HttpStatusCode.BadRequest.value, errorMessage, mapOf(SPECMATIC_RESULT_HEADER to "failure"))
}

fun internalServerError(errorMessage: String?): HttpResponse {
    return HttpResponse(
        HttpStatusCode.InternalServerError.value,
        errorMessage,
        mapOf(SPECMATIC_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?, keyData: KeyData?): String {
    val protocol = when (keyData) {
        null -> "http"
        else -> "https"
    }

    val computedPortString = when (port) {
        80, null -> ""
        else -> ":$port"
    }

    return "$protocol://$host$computedPortString"
}

internal fun isPath(path: String?, lastPart: String): Boolean {
    return path == "/_$APPLICATION_NAME_LOWER_CASE/$lastPart"
}

internal fun isFetchLogRequest(httpRequest: HttpRequest): Boolean =
    isPath(httpRequest.path, "log") && httpRequest.method == "GET"

internal fun isFetchContractsRequest(httpRequest: HttpRequest): Boolean =
    isPath(httpRequest.path, "contracts") && httpRequest.method == "GET"

internal fun isFetchLoadLogRequest(httpRequest: HttpRequest): Boolean =
    isPath(httpRequest.path, "load_log") && httpRequest.method == "GET"

internal fun isExpectationCreation(httpRequest: HttpRequest) =
    isPath(httpRequest.path, "expectations") && httpRequest.method == "POST"

internal fun isSseExpectationCreation(httpRequest: HttpRequest) =
    isPath(httpRequest.path, "sse-expectations") && httpRequest.method == "POST"

internal fun isStateSetupRequest(httpRequest: HttpRequest): Boolean =
    isPath(httpRequest.path, "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 {
            toXMLNode(body.string)
        } catch (e: Throwable) {
            body
        }

        else -> body
    }
}

fun stringToMockScenario(text: Value): ScenarioStub {
    val mockSpec: Map =
        jsonStringToValueMap(text.toStringLiteral()).also {
            validateMock(it)
        }

    return mockFromJSON(mockSpec)
}

data class SseEvent(
    val data: String? = "",
    val event: String? = null,
    val id: String? = null,
    val bufferIndex: Int? = null
)

suspend fun ApplicationCall.respondSse(
    events: ReceiveChannel,
    sseBuffer: SSEBuffer,
    httpRequest: HttpRequest
) {
    response.cacheControl(CacheControl.NoCache(null))

    respondTextWriter(contentType = ContentType.Text.EventStream) {
        logger.log("Writing out an initial response for subscription to ${httpRequest.path!!}")
        withContext(Dispatchers.IO) {
            write("\n")
            flush()
        }

        logger.log("Writing out buffered events for subscription to ${httpRequest.path}")
        sseBuffer.write(this)

        logger.log("Awaiting events...")
        for (event in events) {
            sseBuffer.add(event)
            logger.log("Writing out event for subscription to ${httpRequest.path}")
            logger.log("Event details: $event")

            writeEvent(event, this)
        }
    }
}

fun writeEvent(event: SseEvent, writer: Writer) {
    if (event.id != null) {
        writer.write("id: ${event.id}\n")
    }
    if (event.event != null) {
        writer.write("event: ${event.event}\n")
    }
    if (event.data != null) {
        for (dataLine in event.data.lines()) {
            writer.write("data: $dataLine\n")
        }
    }

    writer.write("\n")
    writer.flush()
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy