Skip to content

Commit

Permalink
Merge pull request #1254 from znsio/template_feature
Browse files Browse the repository at this point in the history
Fill-in-the-blanks partials
  • Loading branch information
joelrosario authored Aug 20, 2024
2 parents b364b73 + 1a79596 commit 59cae9e
Show file tree
Hide file tree
Showing 64 changed files with 2,045 additions and 82 deletions.
74 changes: 60 additions & 14 deletions core/src/main/kotlin/io/specmatic/core/Feature.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import io.cucumber.messages.IdGenerator.Incrementing
import io.cucumber.messages.types.*
import io.cucumber.messages.types.Examples
import io.specmatic.core.utilities.*
import io.specmatic.stub.stringToMockScenario
import io.swagger.v3.oas.models.*
import io.swagger.v3.oas.models.headers.Header
import io.swagger.v3.oas.models.info.Info
Expand Down Expand Up @@ -267,10 +266,11 @@ data class Feature(
fun matchingStub(
request: HttpRequest,
response: HttpResponse,
mismatchMessages: MismatchMessages = DefaultMismatchMessages
mismatchMessages: MismatchMessages = DefaultMismatchMessages,
dictionary: Map<String, Value> = emptyMap()
): HttpStubData {
try {
val results = stubMatchResult(request, response, mismatchMessages)
val results = stubMatchResult(request, response.substituteDictionaryValues(dictionary), mismatchMessages)

return results.find {
it.first != null
Expand Down Expand Up @@ -439,17 +439,63 @@ data class Feature(
fun matchingStub(
scenarioStub: ScenarioStub,
mismatchMessages: MismatchMessages = DefaultMismatchMessages
): HttpStubData =
matchingStub(
scenarioStub.request,
scenarioStub.response,
mismatchMessages
).copy(
delayInMilliseconds = scenarioStub.delayInMilliseconds,
requestBodyRegex = scenarioStub.requestBodyRegex?.let { Regex(it) },
stubToken = scenarioStub.stubToken,
data = scenarioStub.data
)
): HttpStubData {
if(scenarios.isEmpty())
throw ContractException("No scenarios found in feature $name ($path)")

return if(scenarioStub.partial != null) {
val results = scenarios.asSequence().map { scenario ->
scenario.matchesTemplate(scenarioStub.partial) to scenario
}

val matchingScenario = results.filter { it.first is Result.Success }.map { it.second }.firstOrNull()

if(matchingScenario != null) {
val requestTypeWithAncestors =
matchingScenario.httpRequestPattern.copy(
headersPattern = matchingScenario.httpRequestPattern.headersPattern.copy(
ancestorHeaders = matchingScenario.httpRequestPattern.headersPattern.pattern
)
)

val responseTypeWithAncestors =
matchingScenario.httpResponsePattern.copy(
headersPattern = matchingScenario.httpResponsePattern.headersPattern.copy(
ancestorHeaders = matchingScenario.httpResponsePattern.headersPattern.pattern
)
)

HttpStubData(
requestTypeWithAncestors,
HttpResponse(),
matchingScenario.resolver,
responsePattern = responseTypeWithAncestors,
scenario = matchingScenario,
partial = scenarioStub.partial.copy(response = scenarioStub.partial.response.substituteDictionaryValues(scenarioStub.dictionary)),
data = scenarioStub.data,
dictionary = scenarioStub.dictionary
)
}
else {
val failures = Results(results.map { it.first }.filterIsInstance<Result.Failure>().toList()).withoutFluff()

throw NoMatchingScenario(failures, msg = "Could not load partial example ${scenarioStub.filePath}")
}
} else {
matchingStub(
scenarioStub.request,
scenarioStub.response,
mismatchMessages,
scenarioStub.dictionary
).copy(
delayInMilliseconds = scenarioStub.delayInMilliseconds,
requestBodyRegex = scenarioStub.requestBodyRegex?.let { Regex(it) },
stubToken = scenarioStub.stubToken,
data = scenarioStub.data,
dictionary = scenarioStub.dictionary
)
}
}

fun clearServerState() {
serverState = emptyMap()
Expand Down
43 changes: 42 additions & 1 deletion core/src/main/kotlin/io/specmatic/core/HttpHeadersPattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ data class HttpHeadersPattern(
)
}


val headersWithRelevantKeys = when {
ancestorHeaders != null -> withoutIgnorableHeaders(headers, ancestorHeaders)
else -> withoutContentTypeGeneratedBySpecmatic(headers, pattern)
Expand Down Expand Up @@ -287,6 +286,48 @@ data class HttpHeadersPattern(

}
}

fun fillInTheBlanks(headers: Map<String, String>, dictionary: Map<String, Value>, resolver: Resolver): ReturnValue<Map<String, String>> {
val headersToConsider = ancestorHeaders?.let {
headers.filterKeys { key -> key in it || "$key?" in it }
} ?: headers

val map: Map<String, ReturnValue<String>> = headersToConsider.mapValues { (headerName, headerValue) ->
val headerPattern = pattern.get(headerName) ?: pattern.get("$headerName?") ?: return@mapValues HasFailure(Result.Failure(resolver.mismatchMessages.unexpectedKey("header", headerName)))

if(headerName in dictionary) {
val dictionaryValue = dictionary.getValue(headerName)
val matchResult = headerPattern.matches(dictionaryValue, resolver)

if(matchResult is Result.Failure)
HasFailure(matchResult)
else
HasValue(dictionaryValue.toStringLiteral())
} else {
exception { headerPattern.parse(headerValue, resolver) }?.let { return@mapValues HasException(it) }

HasValue(headerValue)
}.breadCrumb(headerName)
}

val headersInPartialR = map.mapFold()

val missingHeadersR = pattern.filterKeys { !it.endsWith("?") && it !in headers }.mapValues { (headerName, headerPattern) ->
val generatedValue = dictionary[headerName]?.let { dictionaryValue ->
val matchResult = headerPattern.matches(dictionaryValue, resolver)
if(matchResult is Result.Failure)
HasFailure(matchResult)
else
HasValue(dictionaryValue.toStringLiteral())
} ?: HasValue(headerPattern.generate(resolver).toStringLiteral())

generatedValue.breadCrumb(headerName)
}.mapFold()

return headersInPartialR.combine(missingHeadersR) { headersInPartial, missingHeaders ->
headersInPartial + missingHeaders
}
}
}

private fun parseOrString(pattern: Pattern, sampleValue: String, resolver: Resolver) =
Expand Down
16 changes: 15 additions & 1 deletion core/src/main/kotlin/io/specmatic/core/HttpRequest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.http.*
import io.ktor.http.content.*
import io.specmatic.core.utilities.Flags
import org.apache.http.client.utils.URLEncodedUtils
import org.apache.http.message.BasicNameValuePair
import java.io.File
Expand Down Expand Up @@ -157,7 +158,7 @@ data class HttpRequest(
}

else -> body.toString()
}
}.let { singleLineJsonIfFlagSet(it) }

val firstPart = listOf(firstLine, headerString).joinToString("\n").trim()
val requestString = listOf(firstPart, "", bodyString).joinToString("\n")
Expand Down Expand Up @@ -619,3 +620,16 @@ fun decodePath(path: String): String {
URLDecoder.decode(segment, StandardCharsets.UTF_8.toString())
}
}

fun singleLineJson(json: String): String {
return json
.replace(Regex("\\s*([{}\\[\\]:,])\\s*"), "$1") // Remove spaces around structural characters
.replace(Regex("\\s+"), " ") // Replace any remaining sequences of whitespace with a single space
}

fun singleLineJsonIfFlagSet(json: String): String {
return if(Flags.getBooleanValue("SPECMATIC_PRETTY_PRINT") == false)
singleLineJson(json)
else
json
}
8 changes: 5 additions & 3 deletions core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import io.specmatic.core.pattern.*
import io.specmatic.core.value.StringValue
import io.ktor.util.*
import io.specmatic.core.value.JSONObjectValue
import io.specmatic.core.value.Value

private const val MULTIPART_FORMDATA_BREADCRUMB = "MULTIPART-FORMDATA"
private const val FORM_FIELDS_BREADCRUMB = "FORM-FIELDS"
Expand Down Expand Up @@ -250,7 +251,7 @@ data class HttpRequestPattern(
resolver
)

requestPattern.copy(headersPattern = HttpHeadersPattern(headersFromRequest))
requestPattern.copy(headersPattern = HttpHeadersPattern(headersFromRequest, ancestorHeaders = this.headersPattern.pattern))
}

requestPattern = attempt(breadCrumb = "BODY") {
Expand Down Expand Up @@ -736,9 +737,10 @@ data class HttpRequestPattern(
runningRequest: HttpRequest,
originalRequest: HttpRequest,
resolver: Resolver,
data: JSONObjectValue
data: JSONObjectValue,
dictionary: Map<String, Value>
): Substitution {
return Substitution(runningRequest, originalRequest, httpPathPattern ?: HttpPathPattern(emptyList(), ""), headersPattern, httpQueryParamPattern, body, resolver, data)
return Substitution(runningRequest, originalRequest, httpPathPattern ?: HttpPathPattern(emptyList(), ""), headersPattern, httpQueryParamPattern, body, resolver, data, dictionary)
}

}
Expand Down
48 changes: 47 additions & 1 deletion core/src/main/kotlin/io/specmatic/core/HttpResponse.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import io.specmatic.conversions.guessType
import io.specmatic.core.GherkinSection.Then
import io.specmatic.core.pattern.ContractException
import io.specmatic.core.pattern.Pattern
import io.specmatic.core.pattern.isPatternToken
import io.specmatic.core.pattern.parsedValue
import io.specmatic.core.value.*

Expand Down Expand Up @@ -58,7 +59,7 @@ data class HttpResponse(

val firstPart = listOf(statusLine, headerString).joinToString("\n").trim()

val formattedBody = body.toStringLiteral()
val formattedBody = body.toStringLiteral().let { singleLineJsonIfFlagSet(it) }

val responseString = listOf(firstPart, "", formattedBody).joinToString("\n")
return startLinesWith(responseString, prefix)
Expand Down Expand Up @@ -167,8 +168,53 @@ data class HttpResponse(
}

private fun headersHasOnlyTextPlainContentTypeHeader() = headers.size == 1 && headers[CONTENT_TYPE] == "text/plain"

fun substituteDictionaryValues(value: JSONArrayValue, dictionary: Map<String, Value>): Value {
val newList = value.list.map { value ->
substituteDictionaryValues(value, dictionary)
}

return value.copy(newList)
}

fun substituteDictionaryValues(value: JSONObjectValue, dictionary: Map<String, Value>): Value {
val newMap = value.jsonObject.mapValues { (key, value) ->
if(value is StringValue && isVanillaPatternToken(value.string) && key in dictionary) {
dictionary.getValue(key)
} else value
}

return value.copy(newMap)
}

fun substituteDictionaryValues(value: Value, dictionary: Map<String, Value>): Value {
return when (value) {
is JSONObjectValue -> {
substituteDictionaryValues(value, dictionary)
}
is JSONArrayValue -> {
substituteDictionaryValues(value, dictionary)
}
else -> value
}
}

fun substituteDictionaryValues(dictionary: Map<String, Value>): HttpResponse {
val updatedHeaders = headers.mapValues { (headerName, headerValue) ->
if(isVanillaPatternToken(headerValue) && headerName in dictionary) {
dictionary.getValue(headerName).toStringLiteral()
} else headerValue
}

val updatedBody = substituteDictionaryValues(body, dictionary)

return this.copy(headers = updatedHeaders, body= updatedBody)
}

}

fun isVanillaPatternToken(token: String) = isPatternToken(token) && token.indexOf(':') < 0

fun nativeInteger(json: Map<String, Value>, key: String): Int? {
val keyValue = json[key] ?: return null

Expand Down
13 changes: 13 additions & 0 deletions core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.specmatic.core

import io.specmatic.core.pattern.*
import io.specmatic.core.value.StringValue
import io.specmatic.core.value.Value
import io.specmatic.stub.softCastValueToXML

const val DEFAULT_RESPONSE_CODE = 1000
Expand Down Expand Up @@ -167,6 +168,18 @@ data class HttpResponsePattern(
body = substitutedBody
)
}

fun generateResponse(partial: HttpResponse, dictionary: Map<String, Value>, resolver: Resolver): HttpResponse {
val headers = headersPattern.fillInTheBlanks(partial.headers, dictionary, resolver).breadCrumb("HEADERS")
val body: ReturnValue<Value> = body.fillInTheBlanks(partial.body, dictionary, resolver).breadCrumb("BODY")

return headers.combine(body) { fullHeaders, fullBody ->
partial.copy(
headers = fullHeaders,
body = fullBody
)
}.breadCrumb("RESPONSE").value
}
}

private val valueMismatchMessages = object : MismatchMessages {
Expand Down
43 changes: 41 additions & 2 deletions core/src/main/kotlin/io/specmatic/core/Scenario.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import io.specmatic.core.utilities.capitalizeFirstChar
import io.specmatic.core.utilities.mapZip
import io.specmatic.core.utilities.nullOrExceptionString
import io.specmatic.core.value.*
import io.specmatic.mock.ScenarioStub
import io.specmatic.stub.RequestContext

object ContractAndStubMismatchMessages : MismatchMessages {
Expand Down Expand Up @@ -582,10 +583,26 @@ data class Scenario(
}
}

fun resolveSubtitutions(request: HttpRequest, originalRequest: HttpRequest, response: HttpResponse, data: JSONObjectValue): HttpResponse {
val substitution = httpRequestPattern.getSubstitution(request, originalRequest, resolver, data)
fun resolveSubtitutions(
request: HttpRequest,
originalRequest: HttpRequest,
response: HttpResponse,
data: JSONObjectValue,
dictionary: Map<String, Value>
): HttpResponse {
val substitution = httpRequestPattern.getSubstitution(request, originalRequest, resolver, data, dictionary)
return httpResponsePattern.resolveSubstitutions(substitution, response)
}

fun matchesTemplate(template: ScenarioStub): Result {
val updatedResolver = resolver.copy(findKeyErrorCheck = PARTIAL_KEYCHECK, mockMode = true)

val requestMatch = httpRequestPattern.matches(template.request, updatedResolver, updatedResolver)

val responseMatch = httpResponsePattern.matchesMock(template.response, updatedResolver)

return Result.fromResults(listOf(requestMatch, responseMatch))
}
}

fun newExpectedServerStateBasedOn(
Expand Down Expand Up @@ -630,3 +647,25 @@ object ContractAndResponseMismatch : MismatchMessages {
} named $keyName in the specification was not found in the response"
}
}

val noPatternKeyCheck = object : KeyErrorCheck {
override fun validate(pattern: Map<String, Any>, actual: Map<String, Any>): KeyError? {
return null
}

override fun validateList(pattern: Map<String, Any>, actual: Map<String, Any>): List<KeyError> {
return emptyList()
}

override fun validateListCaseInsensitive(
pattern: Map<String, Pattern>,
actual: Map<String, StringValue>
): List<KeyError> {
return emptyList()
}
}

val PARTIAL_KEYCHECK = KeyCheck(
patternKeyCheck = noPatternKeyCheck,
unexpectedKeyCheck = ValidateUnexpectedKeys
)
Loading

0 comments on commit 59cae9e

Please sign in to comment.