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

run.qontract.core.Feature.kt Maven / Gradle / Ivy

package run.qontract.core

import io.cucumber.gherkin.GherkinDocumentBuilder
import io.cucumber.gherkin.Parser
import io.cucumber.messages.IdGenerator
import io.cucumber.messages.IdGenerator.Incrementing
import io.cucumber.messages.Messages.GherkinDocument
import run.qontract.core.pattern.*
import run.qontract.core.pattern.Examples.Companion.examplesFrom
import run.qontract.core.utilities.jsonStringToValueMap
import run.qontract.core.value.*
import run.qontract.core.value.UseExampleDeclarations
import run.qontract.mock.NoMatchingScenario
import run.qontract.mock.ScenarioStub
import run.qontract.stub.HttpStubData
import run.qontract.test.TestExecutor
import java.net.URI

fun Feature(gherkinData: String): Feature {
    val gherkinDocument = parseGherkinString(gherkinData)
    return Feature(gherkinDocument)
}

fun Feature(contractGherkinDocument: GherkinDocument): Feature {
    val (name, scenarios) = lex(contractGherkinDocument)
    return Feature(scenarios = scenarios, name = name)
}

data class Feature(val scenarios: List = emptyList(), private var serverState: Map = emptyMap(), val name: String) {
    fun lookupResponse(httpRequest: HttpRequest): HttpResponse {
        try {
            val resultList = lookupScenario(httpRequest, scenarios)
            return matchingScenario(resultList)?.generateHttpResponse(serverState) ?: Results(resultList.map { it.second }.toMutableList()).withoutFluff().generateErrorHttpResponse()
        } finally {
            serverState = emptyMap()
        }
    }

    fun stubResponse(httpRequest: HttpRequest): HttpResponse {
        try {
            val scenarioSequence = scenarios.asSequence()

            val localCopyOfServerState = serverState
            val resultList = scenarioSequence.zip(scenarioSequence.map {
                it.matchesStub(httpRequest, localCopyOfServerState)
            })
            return matchingScenario(resultList)?.generateHttpResponse(serverState) ?: Results(resultList.map { it.second }.toMutableList()).withoutFluff().generateErrorHttpResponse()
        } finally {
            serverState = emptyMap()
        }
    }

    fun lookupScenario(httpRequest: HttpRequest): List =
        try {
            val resultList = lookupScenario(httpRequest, scenarios)
            val matchingScenarios = matchingScenarios(resultList)

            val firstRealResult = resultList.filterNot { isURLPathMismatch(it.second) }.firstOrNull()
            val resultsExist = resultList.firstOrNull() != null

            when {
                matchingScenarios.isNotEmpty() -> matchingScenarios
                firstRealResult != null -> throw ContractException(resultReport(firstRealResult.second))
                resultsExist -> throw ContractException(PATH_NOT_RECOGNIZED_ERROR)
                else -> throw ContractException("The contract is empty.")
            }
        } finally {
            serverState = emptyMap()
        }

    private fun matchingScenarios(resultList: Sequence>): List {
        return resultList.filter {
            it.second is Result.Success
        }.map { it.first }.toList()
    }

    private fun matchingScenario(resultList: Sequence>): Scenario? {
        return resultList.find {
            it.second is Result.Success
        }?.first
    }

    private fun lookupScenario(httpRequest: HttpRequest, scenarios: List): Sequence> {
        val scenarioSequence = scenarios.asSequence()

        val localCopyOfServerState = serverState
        return scenarioSequence.zip(scenarioSequence.map {
            it.matches(httpRequest, localCopyOfServerState)
        })
    }

    fun executeTests(testExecutorFn: TestExecutor, suggestions: List = emptyList()): Results =
            generateTestScenarios(suggestions).fold(Results()) { results, scenario ->
                Results(results = results.results.plus(executeTest(scenario, testExecutorFn)).toMutableList())
            }

    fun setServerState(serverState: Map) {
        this.serverState = this.serverState.plus(serverState)
    }

    fun matches(request: HttpRequest, response: HttpResponse): Boolean {
        return scenarios.firstOrNull { it.matches(request, serverState) is Result.Success }?.matches(response) is Result.Success
    }

    fun matchingStub(request: HttpRequest, response: HttpResponse): HttpStubData {
        try {
            val results = scenarios.map { scenario ->
                try {
                    when(val matchResult = scenario.matchesMock(request, response)) {
                        is Result.Success -> Pair(scenario.resolverAndResponseFrom(response).let { (resolver, response) ->
                            val newRequestType = scenario.httpRequestPattern.generate(request, resolver)
                            val requestTypeWithAncestors =
                                    newRequestType.copy(headersPattern = newRequestType.headersPattern.copy(ancestorHeaders = scenario.httpRequestPattern.headersPattern.pattern))
                            HttpStubData(response = response, resolver = resolver, requestType = requestTypeWithAncestors)
                        }, Result.Success())
                        is Result.Failure -> {
                            Pair(null, matchResult.updateScenario(scenario))
                        }
                    }
                } catch (contractException: ContractException) {
                    Pair(null, contractException.failure())
                }
            }

            return results.find {
                it.first != null
            }?.let { it.first as HttpStubData } ?: throw NoMatchingScenario(failureResults(results).withoutFluff().report())
        } finally {
            serverState = emptyMap()
        }
    }

    fun failureResults(results: List>): Results =
            Results(results.map { it.second }.filter { it is Result.Failure }.toMutableList())

    fun generateTestScenarios(suggestions: List): List =
        scenarios.map { it.newBasedOn(suggestions) }.flatMap { it.generateTestScenarios() }

    fun generateTestScenarios(): List =
        scenarios.flatMap { scenario ->
            scenario.copy(examples = emptyList()).generateTestScenarios()
        }

    fun assertMatchesMockKafkaMessage(kafkaMessage: KafkaMessage) {
        val result = matchesMockKafkaMessage(kafkaMessage)
        if (result is Result.Failure)
            throw NoMatchingScenario(resultReport(result))
    }

    fun matchesMockKafkaMessage(kafkaMessage: KafkaMessage): Result {
        val results = scenarios.asSequence().map {
            it.matchesMock(kafkaMessage)
        }

        return results.find { it is Result.Success } ?: results.firstOrNull() ?: Result.Failure("No match found, couldn't check the message")
    }

    fun matchingStub(scenarioStub: ScenarioStub): HttpStubData =
            matchingStub(scenarioStub.request, scenarioStub.response).copy(delayInSeconds = scenarioStub.delayInSeconds)

    fun clearServerState() {
        serverState = emptyMap()
    }

    fun lookupKafkaScenario(olderKafkaMessagePattern: KafkaMessagePattern, olderResolver: Resolver): Sequence> {
        try {
            return scenarios.asSequence()
                    .filter { it.kafkaMessagePattern != null }
                    .map { newerScenario ->
                        Pair(newerScenario, olderKafkaMessagePattern.encompasses(newerScenario.kafkaMessagePattern as KafkaMessagePattern, newerScenario.resolver, olderResolver))
                    }
        } finally {
            serverState = emptyMap()
        }
    }
}

private fun toFixtureInfo(rest: String): Pair {
    val fixtureTokens = breakIntoPartsMaxLength(rest.trim(), 2)

    if(fixtureTokens.size != 2)
        throw ContractException("Couldn't parse fixture data: $rest")

    return Pair(fixtureTokens[0], toFixtureData(fixtureTokens[1]))
}

private fun toFixtureData(rawData: String): Value = parsedJSON(rawData)

internal fun stringOrDocString(string: String?, step: StepInfo): String {
    val trimmed = string?.trim() ?: ""
    return trimmed.ifEmpty { step.docString }
}
private fun toPatternInfo(step: StepInfo, rowsList: List): Pair {
    val tokens = breakIntoPartsMaxLength(step.rest, 2)

    val patternName = withPatternDelimiters(tokens[0])

    val patternDefinition = stringOrDocString(tokens.getOrNull(1), step)

    val pattern = when {
        patternDefinition.isEmpty() -> rowsToTabularPattern(rowsList, typeAlias = patternName)
        else -> parsedPattern(patternDefinition, typeAlias = patternName)
    }

    return Pair(patternName, pattern)
}

private fun toFacts(rest: String, fixtures: Map): Map {
    return try {
        jsonStringToValueMap(rest)
    } catch (notValidJSON: Exception) {
        val factTokens = breakIntoPartsMaxLength(rest, 2)
        val name = factTokens[0]
        val data = factTokens.getOrNull(1)?.let { StringValue(it) } ?: fixtures.getOrDefault(name, True)

        mapOf(name to data)
    }
}

private fun lexScenario(steps: List, examplesList: List, featureTags: List, backgroundScenarioInfo: ScenarioInfo): ScenarioInfo {
    val filteredSteps = steps.map { StepInfo(it.text, it.dataTable.rowsList, it) }.filterNot { it.isEmpty }

    val parsedScenarioInfo = filteredSteps.fold(backgroundScenarioInfo) { scenarioInfo, step ->
        when(step.keyword) {
            in HTTP_METHODS -> {
                step.words.getOrNull(1)?.let {
                    val urlMatcher = try {
                        toURLMatcherWithOptionalQueryParams(URI.create(step.rest))
                    } catch (e: Throwable) {
                        throw Exception("Could not parse the contract URL \"${step.rest}\" in scenario \"${scenarioInfo.scenarioName}\"", e)
                    }

                    scenarioInfo.copy(httpRequestPattern = scenarioInfo.httpRequestPattern.copy(urlMatcher = urlMatcher, method = step.keyword.toUpperCase()))
                } ?: throw ContractException("Line ${step.line}: $step.text")
            }
            "REQUEST-HEADER" ->
                scenarioInfo.copy(httpRequestPattern = scenarioInfo.httpRequestPattern.copy(headersPattern = plusHeaderPattern(step.rest, scenarioInfo.httpRequestPattern.headersPattern)))
            "RESPONSE-HEADER" ->
                scenarioInfo.copy(httpResponsePattern = scenarioInfo.httpResponsePattern.copy(headersPattern = plusHeaderPattern(step.rest, scenarioInfo.httpResponsePattern.headersPattern)))
            "STATUS" ->
                scenarioInfo.copy(httpResponsePattern = scenarioInfo.httpResponsePattern.copy(status = Integer.valueOf(step.rest)))
            "REQUEST-BODY" ->
                scenarioInfo.copy(httpRequestPattern = scenarioInfo.httpRequestPattern.copy(body = toPattern(step)))
            "RESPONSE-BODY" ->
                scenarioInfo.copy(httpResponsePattern = scenarioInfo.httpResponsePattern.bodyPattern(toPattern(step)))
            "FACT" ->
                scenarioInfo.copy(expectedServerState = scenarioInfo.expectedServerState.plus(toFacts(step.rest, scenarioInfo.fixtures)))
            "TYPE", "PATTERN", "JSON" ->
                scenarioInfo.copy(patterns = scenarioInfo.patterns.plus(toPatternInfo(step, step.rowsList)))
            "FIXTURE" ->
                scenarioInfo.copy(fixtures = scenarioInfo.fixtures.plus(toFixtureInfo(step.rest)))
            "FORM-FIELD" ->
                scenarioInfo.copy(httpRequestPattern = scenarioInfo.httpRequestPattern.copy(formFieldsPattern = plusFormFields(scenarioInfo.httpRequestPattern.formFieldsPattern, step.rest, step.rowsList)))
            "REQUEST-PART" ->
                scenarioInfo.copy(httpRequestPattern = scenarioInfo.httpRequestPattern.copy(multiPartFormDataPattern = scenarioInfo.httpRequestPattern.multiPartFormDataPattern.plus(toFormDataPart(step))))
            "KAFKA-MESSAGE" ->
                scenarioInfo.copy(kafkaMessage = toAsyncMessage(step))
            else -> {
                val location = when {
                    step.raw.hasLocation() -> " at line ${step.raw.location.line}"
                    else -> ""
                }

                throw ContractException("""Invalid syntax$location: ${step.raw.keyword.trim()} ${step.raw.text} -> keyword "${step.originalKeyword}" not recognised.""")
            }
        }
    }

    val tags = featureTags.map { tag -> tag.name }
    val ignoreFailure = when {
        tags.asSequence().map { it.toUpperCase() }.contains("@WIP") -> true
        else -> false
    }

    return parsedScenarioInfo.copy(examples = backgroundScenarioInfo.examples.plus(examplesFrom(examplesList)), ignoreFailure = ignoreFailure)
}

fun toAsyncMessage(step: StepInfo): KafkaMessagePattern {
    val parts = breakIntoPartsMaxLength(step.rest, 3)

    return when (parts.size) {
        2 -> {
            val (name, type) = parts
            KafkaMessagePattern(name, value = parsedPattern(type))
        }
        3 -> {
            val (name, key, contentType) = parts
            KafkaMessagePattern(name, parsedPattern(key), parsedPattern(contentType))
        }
        else -> throw ContractException("The message keyword must have either 2 params (topic, value) or 3 (topic, key, value)")
    }
}

fun toFormDataPart(step: StepInfo): MultiPartFormDataPattern {
    val parts = breakIntoPartsMaxLength(step.rest, 4)

    if(parts.size < 2)
        throw ContractException("There must be at least 2 words after request-part in $step.line")

    val (name, content) = parts.slice(0..1)

    return when {
        content.startsWith("@") -> {
            val contentType = parts.getOrNull(2)
            val contentEncoding = parts.getOrNull(3)

            MultiPartFilePattern(name, content, contentType, contentEncoding)
        }
        isPatternToken(content) -> {
            MultiPartContentPattern(name, parsedPattern(content))
        }
        else -> {
            MultiPartContentPattern(name, ExactValuePattern(parsedValue(content)))
        }
    }
}

fun toPattern(step: StepInfo): Pattern {
    return when(val stringData = stringOrDocString(step.rest, step)) {
        "" -> {
            if(step.rowsList.isEmpty()) throw ContractException("Not enough information to describe a type in $step")
            rowsToTabularPattern(step.rowsList)
        }
        else -> parsedPattern(stringData)
    }
}

fun plusFormFields(formFields: Map, rest: String, rowsList: List): Map =
    formFields.plus(when(rowsList.size) {
        0 -> toQueryParams(rest).map { (key, value) -> key to value }
        else -> rowsList.map { row -> row.cellsList[0].value to row.cellsList[1].value }
    }.map { (key, value) -> key to parsedPattern(value) }.toMap())

private fun toQueryParams(rest: String) = rest.split("&")
        .map { breakIntoPartsMaxLength(it, 2) }

fun plusHeaderPattern(rest: String, headersPattern: HttpHeadersPattern): HttpHeadersPattern {
    val parts = breakIntoPartsMaxLength(rest, 2)

    return when (parts.size) {
        2 -> headersPattern.copy(pattern = headersPattern.pattern.plus(toPatternPair(parts[0], parts[1])))
        1 -> throw ContractException("Header $parts[0] should have a value")
        else -> throw ContractException("Unrecognised header params $rest")
    }
}

fun toPatternPair(key: String, value: String): Pair = key to parsedPattern(value)

fun breakIntoPartsMaxLength(whole: String, partCount: Int) = whole.split("\\s+".toRegex(), partCount)

private val HTTP_METHODS = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")

internal fun parseGherkinString(gherkinData: String): GherkinDocument {
    val idGenerator: IdGenerator = Incrementing()
    val parser = Parser(GherkinDocumentBuilder(idGenerator))
    return parser.parse(gherkinData).build()
}

internal fun lex(gherkinDocument: GherkinDocument): Pair> =
        Pair(gherkinDocument.feature.name, lex(gherkinDocument.feature.childrenList))

internal fun lex(featureChildren: List): List =
    lex(featureChildren, lexBackground(featureChildren))

internal fun lex(featureChildren: List, backgroundInfo: ScenarioInfo): List =
    scenarios(featureChildren).map { featureChild ->
        if(featureChild.scenario.name.isBlank())
            throw ContractException("Error at line ${featureChild.scenario.location.line}: scenario name must not be empty")

        val backgroundInfoCopy = backgroundInfo.copy(scenarioName = featureChild.scenario.name)
        lexScenario(featureChild.scenario.stepsList, featureChild.scenario.examplesList, featureChild.scenario.tagsList, backgroundInfoCopy)
    }.map { scenarioInfo ->
        Scenario(scenarioInfo.scenarioName, scenarioInfo.httpRequestPattern, scenarioInfo.httpResponsePattern, scenarioInfo.expectedServerState, scenarioInfo.examples, scenarioInfo.patterns, scenarioInfo.fixtures, scenarioInfo.kafkaMessage, scenarioInfo.ignoreFailure)
    }

private fun lexBackground(featureChildren: List): ScenarioInfo =
    background(featureChildren)?.let { feature ->
        lexScenario(feature.background.stepsList, listOf(), emptyList(), ScenarioInfo())
    } ?: ScenarioInfo()

private fun background(featureChildren: List) =
    featureChildren.firstOrNull { it.valueCase.name == "BACKGROUND" }

private fun scenarios(featureChildren: List) =
        featureChildren.filter { it.valueCase.name != "BACKGROUND" }

fun toGherkinFeature(stub: NamedStub): String = toGherkinFeature(stub.name, stubToClauses(stub))

private fun stubToClauses(namedStub: NamedStub): Pair, ExampleDeclarations> {
    return when (namedStub.stub.kafkaMessage) {
        null -> {
            val (requestClauses, typesFromRequest, examples) = toGherkinClauses(namedStub.stub.request)

            for(message in examples.messages) {
                println(message)
            }

            val (responseClauses, allTypes, _) = toGherkinClauses(namedStub.stub.response, typesFromRequest)
            val typeClauses = toGherkinClauses(allTypes)
            Pair(typeClauses.plus(requestClauses).plus(responseClauses), examples)
        }
        else -> Pair(toGherkinClauses(namedStub.stub.kafkaMessage), UseExampleDeclarations())
    }
}

fun toGherkinFeature(name: String, stubs: List): String {
    val scenarioStrings = stubs.map { stub ->
        toGherkinScenario(stub.name, stubToClauses(stub)).trim()
    }

    return withFeatureClause(name, scenarioStrings.joinToString("\n\n"))
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy