Skip to content

Commit

Permalink
Merge pull request #1249 from znsio/stub_value_substitution
Browse files Browse the repository at this point in the history
Stub value substitution using a template
  • Loading branch information
joelrosario authored Aug 16, 2024
2 parents 3ad12a8 + 3065233 commit ae44363
Show file tree
Hide file tree
Showing 64 changed files with 2,023 additions and 75 deletions.
2 changes: 2 additions & 0 deletions core/src/main/kotlin/io/specmatic/core/HttpPathPattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import java.net.URI

val OMIT = listOf("(OMIT)", "(omit)")

val EMPTY_PATH= HttpPathPattern(emptyList(), "")

data class HttpPathPattern(
val pathSegmentPatterns: List<URLPathSegmentPattern>,
val path: String
Expand Down
19 changes: 15 additions & 4 deletions core/src/main/kotlin/io/specmatic/core/HttpRequest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,13 @@ data class HttpRequest(
val urlParam = URI(path)
updateWith(urlParam)
} catch (e: URISyntaxException) {
copy(path = path)
val pieces = path.split("?", limit = 2)
updateWithPathAndQuery(pieces.get(0), pieces.getOrNull(1))
// copy(path = path)
} catch (e: UnsupportedEncodingException) {
copy(path = path)
val pieces = path.split("?", limit = 2)
updateWithPathAndQuery(pieces.get(0), pieces.getOrNull(1))
// copy(path = path)
}
}

Expand All @@ -81,8 +85,15 @@ data class HttpRequest(
fun updateBody(body: String?): HttpRequest = copy(body = parsedValue(body))

fun updateWith(url: URI): HttpRequest {
val path = url.path
val queryParams = parseQuery(url.query)
// val path = url.path
// val queryParams = parseQuery(url.query)
// return copy(path = path, queryParams = QueryParameters(queryParams))

return updateWithPathAndQuery(url.path, url.query)
}

fun updateWithPathAndQuery(path: String, query: String?): HttpRequest {
val queryParams = parseQuery(query)
return copy(path = path, queryParams = QueryParameters(queryParams))
}

Expand Down
4 changes: 4 additions & 0 deletions core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,10 @@ data class HttpRequestPattern(
} ?: row
}

fun getSubstitution(request: HttpRequest, resolver: Resolver): Substitution {
return Substitution(request, httpPathPattern ?: HttpPathPattern(emptyList(), ""), headersPattern, httpQueryParamPattern, body, resolver)
}

}

fun missingParam(missingValue: String): ContractException {
Expand Down
2 changes: 1 addition & 1 deletion core/src/main/kotlin/io/specmatic/core/HttpResponse.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package io.specmatic.core

import io.ktor.http.*
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.parsedValue
import io.specmatic.core.value.*
import io.ktor.http.*

private const val SPECMATIC_HEADER_PREFIX = "X-$APPLICATION_NAME-"
const val SPECMATIC_RESULT_HEADER = "${SPECMATIC_HEADER_PREFIX}Result"
Expand Down
10 changes: 10 additions & 0 deletions core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,16 @@ data class HttpResponsePattern(
response.body.exactMatchElseType()
)
}

fun resolveSubstitutions(substitution: Substitution, response: HttpResponse): HttpResponse {
val substitutedHeaders = substitution.resolveHeaderSubstitutions(response.headers, headersPattern.pattern).breadCrumb("RESPONSE.HEADERS").value
val substitutedBody = body.resolveSubstitutions(substitution, response.body, substitution.resolver).breadCrumb("RESPONSE.BODY").value

return response.copy(
headers = substitutedHeaders,
body = substitutedBody
)
}
}

private val valueMismatchMessages = object : MismatchMessages {
Expand Down
5 changes: 5 additions & 0 deletions core/src/main/kotlin/io/specmatic/core/Scenario.kt
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,11 @@ data class Scenario(
&& operationId.responseStatus == status
&& httpRequestPattern.matchesPath(operationId.requestPath, resolver).isSuccess()
}

fun resolveSubtitutions(request: HttpRequest, response: HttpResponse): HttpResponse {
val substitution = httpRequestPattern.getSubstitution(request, resolver)
return httpResponsePattern.resolveSubstitutions(substitution, response)
}
}

fun newExpectedServerStateBasedOn(
Expand Down
1 change: 1 addition & 0 deletions core/src/main/kotlin/io/specmatic/core/SpecmaticConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ fun loadSpecmaticConfig(configFileName: String? = null): SpecmaticConfig {
logger.log(e, "A dependency version conflict has been detected. If you are using Spring in a maven project, a common resolution is to set the property <kotlin.version></kotlin.version> to your pom project.")
throw e
} catch (e: Throwable) {
logger.log(e, "Your configuration file may have some missing configuration sections. Please ensure that the $configFileName file adheres to the schema described at: https://specmatic.io/documentation/specmatic_json.html")
throw Exception("Your configuration file may have some missing configuration sections. Please ensure that the $configFileName file adheres to the schema described at: https://specmatic.io/documentation/specmatic_json.html", e)
}
}
146 changes: 146 additions & 0 deletions core/src/main/kotlin/io/specmatic/core/Substitution.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package io.specmatic.core

import io.specmatic.core.pattern.*
import io.specmatic.core.value.JSONArrayValue
import io.specmatic.core.value.JSONObjectValue
import io.specmatic.core.value.StringValue
import io.specmatic.core.value.Value

class Substitution(
val request: HttpRequest,
val httpPathPattern: HttpPathPattern,
val headersPattern: HttpHeadersPattern,
val httpQueryParamPattern: HttpQueryParamPattern,
val body: Pattern,
val resolver: Resolver
) {
fun resolveSubstitutions(value: Value): Value {
return when(value) {
is JSONObjectValue -> resolveSubstitutions(value)
is JSONArrayValue -> resolveSubstitutions(value)
is StringValue -> {
if(value.string.startsWith("{{") && value.string.endsWith("}}"))
StringValue(substitute(value.string))
else
value
}
else -> value
}
}

fun substitute(string: String): String {
val expressionPath = string.removeSurrounding("{{", "}}")

val parts = expressionPath.split(".")

val area = parts.firstOrNull() ?: throw ContractException("The expression $expressionPath was empty")

return if(area.uppercase() == "REQUEST") {
val requestPath = parts.drop(1)

val requestPart = requestPath.firstOrNull() ?: throw ContractException("The expression $expressionPath does not include anything after REQUEST to say what has to be substituted")
val payloadPath = requestPath.drop(1)

val payloadKey = payloadPath.joinToString(".")

when (requestPart.uppercase()) {
"BODY" -> {
val requestJSONBody = request.body as? JSONObjectValue
?: throw ContractException("Substitution $string cannot be resolved as the request body is not an object")
requestJSONBody.findFirstChildByPath(payloadPath)?.toStringLiteral()
?: throw ContractException("Could not find $string in the request body")
}

"HEADERS" -> {
val requestHeaders = request.headers
val requestHeaderName = payloadKey
requestHeaders[requestHeaderName]
?: throw ContractException("Substitution $string cannot be resolved as the request header $requestHeaderName cannot be found")
}

"QUERY-PARAMS" -> {
val requestQueryParams = request.queryParams
val requestQueryParamName = payloadKey
val queryParamPair = requestQueryParams.paramPairs.find { it.first == requestQueryParamName }
?: throw ContractException("Substitution $string cannot be resolved as the request query param $requestQueryParamName cannot be found")

queryParamPair.second
}

"PATH" -> {
val indexOfPathParam = httpPathPattern.pathSegmentPatterns.indexOfFirst { it.key == payloadKey }

if (indexOfPathParam < 0) throw ContractException("Could not find path param named $string")

(request.path ?: "").split("/").let {
if (it.firstOrNull() == "")
it.drop(1)
else
it
}.get(indexOfPathParam)
}

else -> string
}
}
else
string
}

private fun resolveSubstitutions(value: JSONObjectValue): Value {
return value.copy(
value.jsonObject.mapValues { entry ->
resolveSubstitutions(entry.value)
}
)
}

private fun resolveSubstitutions(value: JSONArrayValue): Value {
return value.copy(
value.list.map {
resolveSubstitutions(it)
}
)
}

fun resolveHeaderSubstitutions(map: Map<String, String>, patternMap: Map<String, Pattern>): ReturnValue<Map<String, String>> {
return map.mapValues { (key, value) ->
val string = substitute(value)

val returnValue = (patternMap.get(key) ?: patternMap.get("$key?"))?.let { pattern ->
try {
HasValue(pattern.parse(string, resolver).toStringLiteral())
} catch(e: Throwable) {
HasException(e)
}
} ?: HasValue(value)

returnValue.breadCrumb(key)
}.mapFold()
}

fun substitute(value: Value, pattern: Pattern): ReturnValue<Value> {
return try {
if(value !is StringValue || !hasTemplate(value.string))
return HasValue(value)

val updatedString = substitute(value.string)
HasValue(pattern.parse(updatedString, resolver))
} catch(e: Throwable) {
HasException(e)
}
}

private fun hasTemplate(string: String): Boolean {
return string.startsWith("{{") && string.endsWith("}}")
}

fun substitute(string: String, pattern: Pattern): ReturnValue<Value> {
return try {
val updatedString = substitute(string)
HasValue(pattern.parse(updatedString, resolver))
} catch(e: Throwable) {
HasException(e)
}
}
}
41 changes: 37 additions & 4 deletions core/src/main/kotlin/io/specmatic/core/pattern/AnyPattern.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
package io.specmatic.core.pattern

import io.specmatic.core.MismatchMessages
import io.specmatic.core.Resolver
import io.specmatic.core.Result
import io.specmatic.core.mismatchResult
import io.specmatic.core.*
import io.specmatic.core.pattern.config.NegativePatternConfiguration
import io.specmatic.core.value.EmptyString
import io.specmatic.core.value.NullValue
Expand All @@ -22,6 +19,42 @@ data class AnyPattern(

data class AnyPatternMatch(val pattern: Pattern, val result: Result)

override fun resolveSubstitutions(
substitution: Substitution,
value: Value,
resolver: Resolver
): ReturnValue<Value> {
val options = pattern.map {
it.resolveSubstitutions(substitution, value, resolver)
}

val hasValue = options.find { it is HasValue }

if(hasValue != null)
return hasValue

val failures = options.map {
it.realise(
hasValue = { _, _ ->
throw NotImplementedError()
},
orFailure = { failure -> failure.failure },
orException = { exception -> exception.toHasFailure().failure }
)
}

return HasFailure<Value>(Result.Failure.fromFailures(failures))
}

override fun getTemplateTypes(key: String, value: Value, resolver: Resolver): ReturnValue<Map<String, Pattern>> {
val initialValue: ReturnValue<Map<String, Pattern>> = HasValue(emptyMap<String, Pattern>())

return pattern.fold(initialValue) { acc, pattern ->
val templateTypes = pattern.getTemplateTypes("", value, resolver)
acc.assimilate(templateTypes) { data, additional -> data + additional }
}
}

override fun matches(sampleData: Value?, resolver: Resolver): Result {
val matchResults = pattern.map {
AnyPatternMatch(it, resolver.matchesPattern(key, it, sampleData ?: EmptyString))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import java.util.*

data class Base64StringPattern(override val typeAlias: String? = null) : Pattern, ScalarType {
override fun matches(sampleData: Value?, resolver: Resolver): Result {
if (sampleData?.hasTemplate() == true)
return Result.Success()

return when (sampleData) {
is StringValue -> {
return if (Base64.isBase64(sampleData.string)) Result.Success() else mismatchResult("string of bytes (base64)", sampleData, resolver.mismatchMessages)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ data class BinaryPattern(
) : Pattern, ScalarType {

override fun matches(sampleData: Value?, resolver: Resolver): Result {
if (sampleData?.hasTemplate() == true)
return Result.Success()

return when (sampleData) {
is StringValue -> return Result.Success()
else -> mismatchResult("string", sampleData, resolver.mismatchMessages)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ import io.specmatic.core.value.Value
import java.util.*

data class BooleanPattern(override val example: String? = null) : Pattern, ScalarType, HasDefaultExample {
override fun matches(sampleData: Value?, resolver: Resolver): Result =
when(sampleData) {
override fun matches(sampleData: Value?, resolver: Resolver): Result {
if (sampleData?.hasTemplate() == true)
return Result.Success()

return when (sampleData) {
is BooleanValue -> Result.Success()
else -> mismatchResult("boolean", sampleData, resolver.mismatchMessages)
}
}

override fun generate(resolver: Resolver): Value =
resolver.resolveExample(example, this) ?: randomBoolean()
Expand Down
16 changes: 11 additions & 5 deletions core/src/main/kotlin/io/specmatic/core/pattern/DatePattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@ import io.specmatic.core.value.StringValue
import io.specmatic.core.value.Value

object DatePattern : Pattern, ScalarType {
override fun matches(sampleData: Value?, resolver: Resolver): Result = when (sampleData) {
is StringValue -> resultOf {
parse(sampleData.string, resolver)
Result.Success()
override fun matches(sampleData: Value?, resolver: Resolver): Result {
if (sampleData?.hasTemplate() == true)
return Result.Success()

return when (sampleData) {
is StringValue -> resultOf {
parse(sampleData.string, resolver)
Result.Success()
}

else -> Result.Failure("Date types can only be represented using strings")
}
else -> Result.Failure("Date types can only be represented using strings")
}

override fun generate(resolver: Resolver): StringValue = StringValue(RFC3339.currentDate())
Expand Down
16 changes: 11 additions & 5 deletions core/src/main/kotlin/io/specmatic/core/pattern/DateTimePattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@ import io.specmatic.core.value.Value


object DateTimePattern : Pattern, ScalarType {
override fun matches(sampleData: Value?, resolver: Resolver): Result = when (sampleData) {
is StringValue -> resultOf {
parse(sampleData.string, resolver)
Result.Success()
override fun matches(sampleData: Value?, resolver: Resolver): Result {
if (sampleData?.hasTemplate() == true)
return Result.Success()

return when (sampleData) {
is StringValue -> resultOf {
parse(sampleData.string, resolver)
Result.Success()
}

else -> Result.Failure("DateTime types can only be represented using strings")
}
else -> Result.Failure("DateTime types can only be represented using strings")
}

override fun generate(resolver: Resolver): StringValue = StringValue(RFC3339.currentDateTime())
Expand Down
Loading

0 comments on commit ae44363

Please sign in to comment.