Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Stub value substitution using a template #1249

Merged
merged 23 commits into from
Aug 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3b54ecb
Built stub value substitution with support for request body json
joelrosario Aug 13, 2024
963df3d
Update test for vanilla substitution
joelrosario Aug 13, 2024
9d64e71
Added substitution unit tests
joelrosario Aug 13, 2024
c0b12d2
Removed data substitution test
joelrosario Aug 13, 2024
d350ab5
Implemented basic data substitution of values in request and response…
joelrosario Aug 13, 2024
aef08b8
Added support for substituting data in response headers
joelrosario Aug 14, 2024
bd2bbe5
Added test for pull request headers for performing substitution
joelrosario Aug 14, 2024
712bd15
Added test for pull request query params for performing substitution
joelrosario Aug 14, 2024
6cbfd67
Removed unused code
joelrosario Aug 14, 2024
1cdf094
Cleaned up accessibility on ScenarioStub methods
joelrosario Aug 14, 2024
ba8829e
Implemented substitution of response scalars with request scalars
joelrosario Aug 15, 2024
418e3cf
Implemented typed reading of key for template substitution
joelrosario Aug 15, 2024
8f48803
Implemented data substitution in remaining request params
joelrosario Aug 15, 2024
e58ab7c
Added tests for data substitution in query, headers, path
joelrosario Aug 15, 2024
ce840b1
renamed a spec
joelrosario Aug 15, 2024
6eac499
Fixed support for templating enum strings
joelrosario Aug 16, 2024
546460b
Fixed issue with template replacement in arrays
joelrosario Aug 16, 2024
c521a1a
Improved error message when specmatic config is not well formed
joelrosario Aug 16, 2024
b57110b
Show the filename for errors when resolving a templates
joelrosario Aug 16, 2024
66dffb3
Added an error message when "data" is missing but the example has tem…
joelrosario Aug 16, 2024
d0866a4
Fixed parsing of data template value of inline query params in
joelrosario Aug 16, 2024
3c7828e
Allow references to a sub-key when dereferencing data
joelrosario Aug 16, 2024
3065233
Moved tests from FeatureTest to HttpStubTest, fixed test failing due …
joelrosario Aug 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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