diff --git a/core/src/main/kotlin/io/specmatic/core/HttpPathPattern.kt b/core/src/main/kotlin/io/specmatic/core/HttpPathPattern.kt index e81bc9355..6dd834c25 100644 --- a/core/src/main/kotlin/io/specmatic/core/HttpPathPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/HttpPathPattern.kt @@ -9,6 +9,8 @@ import java.net.URI val OMIT = listOf("(OMIT)", "(omit)") +val EMPTY_PATH= HttpPathPattern(emptyList(), "") + data class HttpPathPattern( val pathSegmentPatterns: List, val path: String diff --git a/core/src/main/kotlin/io/specmatic/core/HttpRequest.kt b/core/src/main/kotlin/io/specmatic/core/HttpRequest.kt index 07ddbbd8c..d99afe50d 100644 --- a/core/src/main/kotlin/io/specmatic/core/HttpRequest.kt +++ b/core/src/main/kotlin/io/specmatic/core/HttpRequest.kt @@ -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) } } @@ -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)) } diff --git a/core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt b/core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt index 9c998c3aa..4b72b7184 100644 --- a/core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt @@ -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 { diff --git a/core/src/main/kotlin/io/specmatic/core/HttpResponse.kt b/core/src/main/kotlin/io/specmatic/core/HttpResponse.kt index 425ffaa39..dcab8f304 100644 --- a/core/src/main/kotlin/io/specmatic/core/HttpResponse.kt +++ b/core/src/main/kotlin/io/specmatic/core/HttpResponse.kt @@ -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" diff --git a/core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt b/core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt index 9d33004f1..3ea95cd76 100644 --- a/core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt @@ -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 { diff --git a/core/src/main/kotlin/io/specmatic/core/Scenario.kt b/core/src/main/kotlin/io/specmatic/core/Scenario.kt index f53988e62..babf316db 100644 --- a/core/src/main/kotlin/io/specmatic/core/Scenario.kt +++ b/core/src/main/kotlin/io/specmatic/core/Scenario.kt @@ -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( diff --git a/core/src/main/kotlin/io/specmatic/core/SpecmaticConfig.kt b/core/src/main/kotlin/io/specmatic/core/SpecmaticConfig.kt index c1c6606df..4764f6335 100644 --- a/core/src/main/kotlin/io/specmatic/core/SpecmaticConfig.kt +++ b/core/src/main/kotlin/io/specmatic/core/SpecmaticConfig.kt @@ -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 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) } } \ No newline at end of file diff --git a/core/src/main/kotlin/io/specmatic/core/Substitution.kt b/core/src/main/kotlin/io/specmatic/core/Substitution.kt new file mode 100644 index 000000000..97eac7da9 --- /dev/null +++ b/core/src/main/kotlin/io/specmatic/core/Substitution.kt @@ -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, patternMap: Map): ReturnValue> { + 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 { + 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 { + return try { + val updatedString = substitute(string) + HasValue(pattern.parse(updatedString, resolver)) + } catch(e: Throwable) { + HasException(e) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/AnyPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/AnyPattern.kt index 46efaec73..bebeee0fc 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/AnyPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/AnyPattern.kt @@ -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 @@ -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 { + 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(Result.Failure.fromFailures(failures)) + } + + override fun getTemplateTypes(key: String, value: Value, resolver: Resolver): ReturnValue> { + val initialValue: ReturnValue> = HasValue(emptyMap()) + + 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)) diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/Base64StringPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/Base64StringPattern.kt index 459d1d8b6..1d4d3ac0b 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/Base64StringPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/Base64StringPattern.kt @@ -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) diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/BinaryPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/BinaryPattern.kt index 0f6e242f0..20a203c61 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/BinaryPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/BinaryPattern.kt @@ -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) diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/BooleanPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/BooleanPattern.kt index 63adfb10e..219f89331 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/BooleanPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/BooleanPattern.kt @@ -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() diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/DatePattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/DatePattern.kt index a16932ee6..ee714dea9 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/DatePattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/DatePattern.kt @@ -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()) diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/DateTimePattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/DateTimePattern.kt index 2e41bb2fc..8672b0d2d 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/DateTimePattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/DateTimePattern.kt @@ -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()) diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/DeferredPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/DeferredPattern.kt index ad15bff57..5b1d6729b 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/DeferredPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/DeferredPattern.kt @@ -2,6 +2,7 @@ package io.specmatic.core.pattern import io.specmatic.core.Resolver import io.specmatic.core.Result +import io.specmatic.core.Substitution import io.specmatic.core.pattern.config.NegativePatternConfiguration import io.specmatic.core.value.EmptyString import io.specmatic.core.value.Value @@ -52,6 +53,14 @@ data class DeferredPattern(override val pattern: String, val key: String? = null return resolver.getPattern(pattern).listOf(valueList, resolver) } + override fun resolveSubstitutions(substitution: Substitution, value: Value, resolver: Resolver): ReturnValue { + return resolvePattern(resolver).resolveSubstitutions(substitution, value, resolver) + } + + override fun getTemplateTypes(key: String, value: Value, resolver: Resolver): ReturnValue> { + return resolvePattern(resolver).getTemplateTypes(key, value, resolver) + } + override val typeAlias: String = pattern override fun parse(value: String, resolver: Resolver): Value = diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/DictionaryPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/DictionaryPattern.kt index 448806de9..749e9c6fd 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/DictionaryPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/DictionaryPattern.kt @@ -2,6 +2,7 @@ package io.specmatic.core.pattern import io.specmatic.core.Resolver import io.specmatic.core.Result +import io.specmatic.core.Substitution import io.specmatic.core.mismatchResult import io.specmatic.core.pattern.config.NegativePatternConfiguration import io.specmatic.core.value.JSONArrayValue @@ -10,6 +11,34 @@ import io.specmatic.core.value.StringValue import io.specmatic.core.value.Value data class DictionaryPattern(val keyPattern: Pattern, val valuePattern: Pattern, override val typeAlias: String? = null) : Pattern { + override fun resolveSubstitutions( + substitution: Substitution, + value: Value, + resolver: Resolver + ): ReturnValue { + if(value !is JSONObjectValue) + return HasFailure(Result.Failure("Cannot resolve substitutions, expected object but got ${value.displayableType()}")) + + val updatedMap = value.jsonObject.mapValues { (key, value) -> + valuePattern.resolveSubstitutions(substitution, value, resolver) + } + + return updatedMap.mapFold().ifValue { value.copy(it) } + } + + override fun getTemplateTypes(key: String, value: Value, resolver: Resolver): ReturnValue> { + if(value !is JSONObjectValue) + return HasFailure(Result.Failure("Cannot resolve substitutions, expected object but got ${value.displayableType()}")) + + val initialValue: ReturnValue> = HasValue(emptyMap()) + + return value.jsonObject.entries.fold(initialValue) { acc, (key, value) -> + val patternMap = valuePattern.getTemplateTypes(key, value, resolver) + + acc.assimilate(patternMap) { data, additional -> data + additional } + } + } + override fun matches(sampleData: Value?, resolver: Resolver): Result { if(sampleData !is JSONObjectValue) return mismatchResult("JSON object", sampleData, resolver.mismatchMessages) diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/EmailPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/EmailPattern.kt index 9c0bcb888..368ccc5d9 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/EmailPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/EmailPattern.kt @@ -23,6 +23,9 @@ class EmailPattern (private val stringPatternDelegate: StringPattern) : } override fun matches(sampleData: Value?, resolver: Resolver): Result { + if (sampleData?.hasTemplate() == true) + return Result.Success() + if (sampleData !is StringValue) return mismatchResult("email string", sampleData, resolver.mismatchMessages) val email = sampleData.toStringLiteral() return if (emailRegex.matches(email)) { diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/EnumPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/EnumPattern.kt index 84db30ad1..5856f9090 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/EnumPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/EnumPattern.kt @@ -1,8 +1,10 @@ package io.specmatic.core.pattern import io.specmatic.core.Resolver +import io.specmatic.core.Result import io.specmatic.core.pattern.config.NegativePatternConfiguration import io.specmatic.core.value.NullValue +import io.specmatic.core.value.StringValue import io.specmatic.core.value.Value private fun validEnumValues(values: List, key: String?, typeAlias: String?, example: String?, nullable: Boolean): AnyPattern { @@ -39,6 +41,14 @@ data class EnumPattern( nullable: Boolean = false ) : this(validEnumValues(values, key, typeAlias, example, nullable), nullable) + override fun matches(sampleData: Value?, resolver: Resolver): Result { + if(sampleData is StringValue && (sampleData.hasTemplate() || sampleData.hasDataTemplate())) { + return Result.Success() + } + + return pattern.matches(sampleData, resolver) + } + fun withExample(example: String?): EnumPattern { return this.copy(pattern = pattern.copy(example = example)) } diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/HasException.kt b/core/src/main/kotlin/io/specmatic/core/pattern/HasException.kt index 1ecea2727..afdff1bbc 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/HasException.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/HasException.kt @@ -1,6 +1,17 @@ package io.specmatic.core.pattern +import io.specmatic.core.Result +import io.specmatic.core.utilities.exceptionCauseMessage + data class HasException(val t: Throwable, val message: String = "", val breadCrumb: String? = null) : ReturnValue, ReturnFailure { + fun toHasFailure(): HasFailure { + val failure: Result.Failure = Result.Failure( + message = exceptionCauseMessage(t), + breadCrumb = breadCrumb ?: "" + ) + return HasFailure(failure, message) + } + override fun withDefault(default: U, fn: (T) -> U): U { return default } @@ -14,11 +25,11 @@ data class HasException(val t: Throwable, val message: String = "", val bread return this } - override fun assimilate(valueResult: ReturnValue, fn: (T, U) -> T): ReturnValue { + override fun assimilate(acc: ReturnValue, fn: (T, U) -> T): ReturnValue { return cast() } - override fun combine(valueResult: ReturnValue, fn: (T, U) -> V): ReturnValue { + override fun combine(acc: ReturnValue, fn: (T, U) -> V): ReturnValue { return cast() } diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/HasFailure.kt b/core/src/main/kotlin/io/specmatic/core/pattern/HasFailure.kt index 64ac9d0bf..e8828f92c 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/HasFailure.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/HasFailure.kt @@ -3,6 +3,7 @@ package io.specmatic.core.pattern import io.specmatic.core.Result data class HasFailure(val failure: Result.Failure, val message: String = "") : ReturnValue, ReturnFailure { + override fun withDefault(default: U, fn: (T) -> U): U { return default } @@ -15,11 +16,11 @@ data class HasFailure(val failure: Result.Failure, val message: String = "") return this } - override fun assimilate(valueResult: ReturnValue, fn: (T, U) -> T): ReturnValue { + override fun assimilate(acc: ReturnValue, fn: (T, U) -> T): ReturnValue { return cast() } - override fun combine(valueResult: ReturnValue, fn: (T, U) -> V): ReturnValue { + override fun combine(acc: ReturnValue, fn: (T, U) -> V): ReturnValue { return cast() } diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/HasValue.kt b/core/src/main/kotlin/io/specmatic/core/pattern/HasValue.kt index 0e8f8d64b..2dce8e3aa 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/HasValue.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/HasValue.kt @@ -39,17 +39,17 @@ data class HasValue(override val value: T, val valueDetails: List assimilate(valueResult: ReturnValue, fn: (T, U) -> T): ReturnValue { - if(valueResult is ReturnFailure) - return valueResult.cast() - else if(valueResult is HasException) - return valueResult.cast() + override fun assimilate(acc: ReturnValue, fn: (T, U) -> T): ReturnValue { + if(acc is ReturnFailure) + return acc.cast() + else if(acc is HasException) + return acc.cast() - valueResult as HasValue + acc as HasValue return try { - val newValue = fn(value, valueResult.value) - HasValue(newValue, valueDetails.plus(valueResult.valueDetails)) + val newValue = fn(value, acc.value) + HasValue(newValue, valueDetails.plus(acc.valueDetails)) } catch(t: Throwable) { HasException(t) } @@ -76,15 +76,15 @@ data class HasValue(override val value: T, val valueDetails: List combine(valueResult: ReturnValue, fn: (T, U) -> V): ReturnValue { - if(valueResult is ReturnFailure) - return valueResult.cast() + override fun combine(acc: ReturnValue, fn: (T, U) -> V): ReturnValue { + if(acc is ReturnFailure) + return acc.cast() - valueResult as HasValue + acc as HasValue return try { - val newValue = fn(value, valueResult.value) - HasValue(newValue, valueDetails.plus(valueResult.valueDetails)) + val newValue = fn(value, acc.value) + HasValue(newValue, valueDetails.plus(acc.valueDetails)) } catch(t: Throwable) { HasException(t) } diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt index a233d2b14..3d57dfd6f 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt @@ -34,6 +34,34 @@ data class JSONObjectPattern( else -> false } + override fun resolveSubstitutions(substitution: Substitution, value: Value, resolver: Resolver): ReturnValue { + if(value !is JSONObjectValue) + return HasFailure(Result.Failure("Cannot resolve substitutions, expected object but got ${value.displayableType()}")) + + val updatedMap = value.jsonObject.mapValues { (key, value) -> + val pattern = pattern.get(key) ?: pattern.getValue("$key?") + pattern.resolveSubstitutions(substitution, value, resolver).breadCrumb(key) + } + + return updatedMap.mapFold().ifValue { value.copy(it) } + } + + override fun getTemplateTypes(key: String, value: Value, resolver: Resolver): ReturnValue> { + if(value !is JSONObjectValue) + return HasFailure(Result.Failure("Cannot resolve data substitutions, expected object but got ${value.displayableType()}")) + + val initialValue: ReturnValue> = HasValue(emptyMap()) + + return pattern.mapKeys { + withoutOptionality(it.key) + }.entries.fold(initialValue) { acc, (key, valuePattern) -> + value.jsonObject.get(key)?.let { valueInObject -> + val additionalTemplateTypes = valuePattern.getTemplateTypes(key, valueInObject, resolver) + acc.assimilate(additionalTemplateTypes) { data, additional -> data + additional } + } ?: acc + } + } + override fun encompasses( otherPattern: Pattern, thisResolver: Resolver, diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt index ce9832ab8..0d39fe1bd 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt @@ -2,6 +2,7 @@ package io.specmatic.core.pattern import io.specmatic.core.Resolver import io.specmatic.core.Result +import io.specmatic.core.Substitution import io.specmatic.core.mismatchResult import io.specmatic.core.pattern.config.NegativePatternConfiguration import io.specmatic.core.value.JSONArrayValue @@ -13,6 +14,33 @@ data class ListPattern(override val pattern: Pattern, override val typeAlias: St override val memberList: MemberList get() = MemberList(emptyList(), pattern) + override fun resolveSubstitutions( + substitution: Substitution, + value: Value, + resolver: Resolver + ): ReturnValue { + if(value !is JSONArrayValue) + return HasFailure(Result.Failure("Cannot resolve substitutions, expected list but got ${value.displayableType()}")) + + val updatedList = value.list.mapIndexed { index, listItem -> + pattern.resolveSubstitutions(substitution, listItem, resolver).breadCrumb("[$index]") + }.listFold() + + return updatedList.ifValue { value.copy(list = it) } + } + + override fun getTemplateTypes(key: String, value: Value, resolver: Resolver): ReturnValue> { + if(value !is JSONArrayValue) + return HasFailure(Result.Failure("Cannot resolve data substitutions, expected list but got ${value.displayableType()}")) + + val initialValue: ReturnValue> = HasValue(emptyMap()) + return value.list.fold(initialValue) { acc, valuePattern -> + val patterns = pattern.getTemplateTypes("", valuePattern, resolver) + + acc.assimilate(patterns) { data, additional -> data + additional } + } + } + override fun matches(sampleData: Value?, resolver: Resolver): Result { if(sampleData !is ListValue) return when { diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/NullPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/NullPattern.kt index b2973c114..36f5dbb46 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/NullPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/NullPattern.kt @@ -12,12 +12,16 @@ import io.specmatic.core.value.Value const val NULL_TYPE = "(null)" object NullPattern : Pattern, ScalarType { - override fun matches(sampleData: Value?, resolver: Resolver): Result = - when { - sampleData is NullValue -> Result.Success() - sampleData is StringValue && sampleData.string.isEmpty() -> Result.Success() - else -> mismatchResult("null", sampleData, resolver.mismatchMessages) - } + override fun matches(sampleData: Value?, resolver: Resolver): Result { + if (sampleData?.hasTemplate() == true) + return Result.Success() + + return when { + sampleData is NullValue -> Result.Success() + sampleData is StringValue && sampleData.string.isEmpty() -> Result.Success() + else -> mismatchResult("null", sampleData, resolver.mismatchMessages) + } + } override fun generate(resolver: Resolver): Value = NullValue diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/NumberPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/NumberPattern.kt index b9f233ed5..06641773a 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/NumberPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/NumberPattern.kt @@ -55,6 +55,9 @@ data class NumberPattern( } override fun matches(sampleData: Value?, resolver: Resolver): Result { + if (sampleData?.hasTemplate() == true) + return Result.Success() + if (sampleData !is NumberValue) return mismatchResult("number", sampleData, resolver.mismatchMessages) diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/Pattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/Pattern.kt index 65549b19e..f6b395db2 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/Pattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/Pattern.kt @@ -1,7 +1,9 @@ package io.specmatic.core.pattern +import io.specmatic.core.HttpResponse import io.specmatic.core.Resolver import io.specmatic.core.Result +import io.specmatic.core.Substitution import io.specmatic.core.pattern.config.NegativePatternConfiguration import io.specmatic.core.value.StringValue import io.specmatic.core.value.Value @@ -66,6 +68,17 @@ interface Pattern { return AnyPattern(listOf(NullPattern, this), example = defaultValue) } + fun resolveSubstitutions(substitution: Substitution, value: Value, resolver: Resolver): ReturnValue { + return substitution.substitute(value, this) + } + + fun getTemplateTypes(key: String, value: Value, resolver: Resolver): ReturnValue> { + return if(value is StringValue && value.string.startsWith("{{@") && value.string.endsWith("}}")) + HasValue(mapOf(key to this)) + else + HasValue(emptyMap()) + } + val typeAlias: String? val typeName: String val pattern: Any diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/ReturnValue.kt b/core/src/main/kotlin/io/specmatic/core/pattern/ReturnValue.kt index 06bf308ac..524e68f46 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/ReturnValue.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/ReturnValue.kt @@ -17,8 +17,8 @@ sealed interface ReturnValue { abstract fun ifValue(fn: (T) -> U): ReturnValue abstract fun ifHasValue(fn: (HasValue) -> ReturnValue): ReturnValue fun update(fn: (T) -> T): ReturnValue - fun assimilate(valueResult: ReturnValue, fn: (T, U) -> T): ReturnValue - fun combine(valueResult: ReturnValue, fn: (T, U) -> V): ReturnValue + fun assimilate(acc: ReturnValue, fn: (T, U) -> T): ReturnValue + fun combine(acc: ReturnValue, fn: (T, U) -> V): ReturnValue fun realise(hasValue: (T, String?) -> U, orFailure: (HasFailure) -> U, orException: (HasException) -> U): U fun addDetails(message: String, breadCrumb: String): ReturnValue } @@ -47,6 +47,16 @@ fun Map>.mapFold(): ReturnValue List>.listFold(): ReturnValue> { + val initial: ReturnValue> = HasValue>(emptyList()) + + return this.fold(initial) { accR: ReturnValue>, valueR: ReturnValue -> + accR.assimilate(valueR) { acc, value -> + acc.plus(value) + } + } +} + fun Sequence>>.sequenceListFold(): Sequence>> { val data: Sequence>> = this diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/StringPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/StringPattern.kt index 53dba090d..4bb2b5fed 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/StringPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/StringPattern.kt @@ -25,6 +25,9 @@ data class StringPattern ( } override fun matches(sampleData: Value?, resolver: Resolver): Result { + if (sampleData?.hasTemplate() == true) + return Result.Success() + return when (sampleData) { is StringValue -> { if (minLength != null && sampleData.toStringLiteral().length < minLength) return mismatchResult( diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/UUIDPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/UUIDPattern.kt index 59c3d0a28..38173c87b 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/UUIDPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/UUIDPattern.kt @@ -9,13 +9,18 @@ import io.specmatic.core.value.Value import java.util.* object UUIDPattern : 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("UUID types can only be represented using strings") + else -> Result.Failure("UUID types can only be represented using strings") + } } override fun generate(resolver: Resolver): StringValue = StringValue(UUID.randomUUID().toString()) diff --git a/core/src/main/kotlin/io/specmatic/core/value/Value.kt b/core/src/main/kotlin/io/specmatic/core/value/Value.kt index a100ae2bc..e1ad58973 100644 --- a/core/src/main/kotlin/io/specmatic/core/value/Value.kt +++ b/core/src/main/kotlin/io/specmatic/core/value/Value.kt @@ -26,4 +26,18 @@ interface Value { fun typeDeclarationWithoutKey(exampleKey: String, types: Map, exampleDeclarations: ExampleDeclarations): Pair fun typeDeclarationWithKey(key: String, types: Map, exampleDeclarations: ExampleDeclarations): Pair fun listOf(valueList: List): Value + + fun hasTemplate(): Boolean { + return this is StringValue + && this.string.startsWith("{{") + && this.string.endsWith("}}") + } + + fun hasDataTemplate(): Boolean { + return this is StringValue && this.string.hasDataTemplate() + } } + +fun String.hasDataTemplate(): Boolean { + return this.startsWith("{{@") && this.endsWith("}}") +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt index 9824b48e3..60cad74fa 100644 --- a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt +++ b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt @@ -2,10 +2,12 @@ package io.specmatic.mock import io.specmatic.core.* import io.specmatic.core.pattern.ContractException +import io.specmatic.core.pattern.Pattern import io.specmatic.core.value.* import io.specmatic.stub.stringToMockScenario +import java.io.File -data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: HttpResponse = HttpResponse(0, emptyMap()), val delayInMilliseconds: Long? = null, val stubToken: String? = null, val requestBodyRegex: String? = null) { +data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: HttpResponse = HttpResponse(0, emptyMap()), val delayInMilliseconds: Long? = null, val stubToken: String? = null, val requestBodyRegex: String? = null, val data: JSONObjectValue = JSONObjectValue(), val filePath: String? = null) { fun toJSON(): JSONObjectValue { val mockInteraction = mutableMapOf() @@ -15,10 +17,264 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: return JSONObjectValue(mockInteraction) } + private fun combinations(data: Map>>): List>>> { + // Helper function to compute Cartesian product of multiple lists + fun cartesianProduct(lists: List>): List> { + return lists.fold(listOf(listOf())) { acc, list -> + acc.flatMap { item -> list.map { value -> item + value } } + } + } + + // Generate the Cartesian product of the values in the input map + val product = cartesianProduct(data.map { (key, nestedMap) -> + nestedMap.map { (nestedKey, valueMap) -> + mapOf(key to mapOf(nestedKey to valueMap)) + } + }) + + // Convert each product result into a combined map + return product.map { item -> + item.reduce { acc, map -> acc + map } + } + } + + fun findPatterns(input: String): Set { + val pattern = """\{\{(@\w+)\}\}""".toRegex() + return pattern.findAll(input).map { it.groupValues[1] }.toSet() + } + + fun dataTemplateNameOnly(wholeDataTemplateName: String): String { + return wholeDataTemplateName.split(".").first() + } + + fun requestDataTemplates(): Set { + return findPatterns(request.toLogString()).map(this::dataTemplateNameOnly).toSet() + } + + fun responseDataTemplates(): Set { + return findPatterns(response.toLogString()).map(this::dataTemplateNameOnly).toSet() + } + + fun resolveDataSubstitutions(scenario: Scenario): List { + val dataTemplates = requestDataTemplates() + responseDataTemplates() + + val missingDataTemplates = dataTemplates.filter { it !in data.jsonObject } + if(missingDataTemplates.isNotEmpty()) + throw ContractException("Could not find the following data templates defined: ${missingDataTemplates.joinToString(", ")}") + + if(data.jsonObject.isEmpty()) + return listOf(this) + + val substitutions = unwrapSubstitutions(data) + + val combinations = combinations(substitutions) + + return combinations.map { combination -> + replaceInExample(combination, scenario.httpRequestPattern.body, scenario.resolver) + } + } + + private fun unwrapSubstitutions(rawSubstitutions: JSONObjectValue): Map>> { + val substitutions = rawSubstitutions.jsonObject.mapValues { + val json = + it.value as? JSONObjectValue ?: throw ContractException("Invalid structure of data in the example file") + + json.jsonObject.mapValues { + val innerJSON = + it.value as? JSONObjectValue ?: throw ContractException("Invalid structure of data in the example file") + + innerJSON.jsonObject.mapValues { + it.value + } + } + } + return substitutions + } + + private fun replaceInRequestBody(value: JSONObjectValue, substitutions: Map>>, requestTemplatePatterns: Map, resolver: Resolver): Value { + return value.copy( + value.jsonObject.mapValues { + replaceInRequestBody(it.key, it.value, substitutions, requestTemplatePatterns, resolver) + } + ) + } + + private fun replaceInRequestBody(value: JSONArrayValue, substitutions: Map>>, requestTemplatePatterns: Map, resolver: Resolver): Value { + return value.copy( + value.list.map { + replaceInRequestBody(value, substitutions, requestTemplatePatterns, resolver) + } + ) + } + + private fun substituteStringInRequest(value: String, substitutions: Map>>): String { + return if(value.hasDataTemplate()) { + val substitutionSetName = value.removeSurrounding("{{", "}}") + val substitutionSet = substitutions[substitutionSetName] ?: throw ContractException("$substitutionSetName does not exist in the data") + + substitutionSet.keys.firstOrNull() ?: throw ContractException("$substitutionSetName in data is empty") + } else + value + } + + private fun replaceInRequestBody(key: String, value: Value, substitutions: Map>>, requestTemplatePatterns: Map, resolver: Resolver): Value { + return when(value) { + is StringValue -> { + if(value.hasDataTemplate()) { + val substitutionSetName = value.string.removeSurrounding("{{", "}}") + val substitutionSet = substitutions[substitutionSetName] ?: throw ContractException("$substitutionSetName does not exist in the data") + + val substitutionKey = substitutionSet.keys.firstOrNull() ?: throw ContractException("$substitutionSetName in data is empty") + + val pattern = requestTemplatePatterns.getValue(key) + + pattern.parse(substitutionKey, resolver) + } else + value + } + is JSONObjectValue -> { + replaceInRequestBody(value, substitutions, requestTemplatePatterns, resolver) + } + is JSONArrayValue -> { + replaceInRequestBody(value, substitutions, requestTemplatePatterns, resolver) + } + else -> value + } + } + + private fun replaceInExample(substitutions: Map>>, requestBody: Pattern, resolver: Resolver): ScenarioStub { + val requestTemplatePatterns = requestBody.getTemplateTypes("", request.body, resolver).value + + val newPath = replaceInPath(request.path ?: "", substitutions) + val newRequestHeaders = replaceInRequestHeaders(request.headers, substitutions) + val newQueryParams: Map = replaceInRequestQueryParams(request.queryParams, substitutions) + val newRequestBody = replaceInRequestBody("", request.body, substitutions, requestTemplatePatterns, resolver) + + val newRequest = request.copy( + path = newPath, + headers = newRequestHeaders, + queryParams = QueryParameters(newQueryParams), + body = newRequestBody) + + val newResponseBody = replaceInResponseBody(response.body, substitutions, "") + val newResponseHeaders = replaceInResponseHeaders(response.headers, substitutions) + val newResponse = response.copy( + headers = newResponseHeaders, + body = newResponseBody + ) + + return copy( + request = newRequest, + response = newResponse + ) + } + + private fun replaceInPath(path: String, substitutions: Map>>): String { + val rawPathSegments = path.split("/") + val pathSegments = rawPathSegments.let { if(it.firstOrNull() == "") it.drop(1) else it } + val updatedSegments = pathSegments.map { if(it.hasDataTemplate()) substituteStringInRequest(it, substitutions) else it } + val prefix = if(pathSegments.size != rawPathSegments.size) listOf("") else emptyList() + + return (prefix + updatedSegments).joinToString("/") + } + + private fun replaceInResponseHeaders( + headers: Map, + substitutions: Map>> + ): Map { + return headers.mapValues { (key, value) -> + substituteStringInResponse(value, substitutions, key) + } + } + + private fun replaceInRequestQueryParams( + queryParams: QueryParameters, + substitutions: Map>> + ): Map { + return queryParams.asMap().mapValues { (key, value) -> + substituteStringInRequest(value, substitutions) + } + } + + private fun replaceInRequestHeaders(headers: Map, substitutions: Map>>): Map { + return headers.mapValues { (key, value) -> + substituteStringInRequest(value, substitutions) + } + } + + private fun replaceInResponseBody(value: JSONObjectValue, substitutions: Map>>): Value { + return value.copy( + value.jsonObject.mapValues { + replaceInResponseBody(it.value, substitutions, it.key) + } + ) + } + + private fun replaceInResponseBody(value: JSONArrayValue, substitutions: Map>>): Value { + return value.copy( + value.list.map { item: Value -> + replaceInResponseBody(item, substitutions, "") + } + ) + } + + private fun substituteStringInResponse(value: String, substitutions: Map>>, key: String): String { + return if(value.hasDataTemplate()) { + val dataSetIdentifiers = DataSetIdentifiers(value, key) + + val substitutionSet = substitutions[dataSetIdentifiers.name] ?: throw ContractException("${dataSetIdentifiers.name} does not exist in the data") + + val substitutionValue = substitutionSet.values.first()[dataSetIdentifiers.key] ?: throw ContractException("${dataSetIdentifiers.name} does not contain a value for ${dataSetIdentifiers.key}") + + substitutionValue.toStringLiteral() + } else + value + } + + class DataSetIdentifiers(rawSetName: String, objectKey: String) { + val name: String + val key: String + + init { + val substitutionSetPieces = rawSetName.removeSurrounding("{{", "}}").split(".") + + name = substitutionSetPieces.getOrNull(0) ?: throw ContractException("Substitution set name {{}} was empty") + key = substitutionSetPieces.getOrNull(1) ?: objectKey + } + } + + private fun replaceInResponseBody(value: Value, substitutions: Map>>, key: String): Value { + return when(value) { + is StringValue -> { + if(value.hasDataTemplate()) { + val dataSetIdentifiers = DataSetIdentifiers(value.string, key) + + val substitutionSet = substitutions[dataSetIdentifiers.name] ?: throw ContractException("${dataSetIdentifiers.name} does not exist in the data") + + val substitutionValue = substitutionSet.values.first()[dataSetIdentifiers.key] ?: throw ContractException("${dataSetIdentifiers.name} does not contain a value for ${dataSetIdentifiers.key}") + + substitutionValue + } else + value + } + is JSONObjectValue -> { + replaceInResponseBody(value, substitutions) + } + is JSONArrayValue -> { + replaceInResponseBody(value, substitutions) + } + else -> value + } + } + companion object { fun parse(text: String): ScenarioStub { return stringToMockScenario(StringValue(text)) } + + fun readFromFile(file: File): ScenarioStub { + return stringToMockScenario(StringValue(file.readText(Charsets.UTF_8))).copy(filePath = file.path) + } } } @@ -44,6 +300,8 @@ fun mockFromJSON(mockSpec: Map): ScenarioStub { val mockRequest: HttpRequest = requestFromJSON(getJSONObjectValue(MOCK_HTTP_REQUEST_ALL_KEYS, mockSpec)) val mockResponse: HttpResponse = HttpResponse.fromJSON(getJSONObjectValue(MOCK_HTTP_RESPONSE_ALL_KEYS, mockSpec)) + val data = getJSONObjectValueOrNull("data", mockSpec)?.let { JSONObjectValue(it) } ?: JSONObjectValue() + val delayInSeconds: Int? = getIntOrNull(DELAY_IN_SECONDS, mockSpec) val delayInMilliseconds: Long? = getLongOrNull(DELAY_IN_MILLISECONDS, mockSpec) val delayInMs: Long? = delayInMilliseconds ?: delayInSeconds?.let { it.toLong().times(1000) } @@ -51,7 +309,7 @@ fun mockFromJSON(mockSpec: Map): ScenarioStub { val stubToken: String? = getStringOrNull(TRANSIENT_MOCK_ID, mockSpec) val requestBodyRegex: String? = getRequestBodyRegexOrNull(mockSpec) - return ScenarioStub(request = mockRequest, response = mockResponse, delayInMilliseconds = delayInMs, stubToken = stubToken, requestBodyRegex = requestBodyRegex) + return ScenarioStub(request = mockRequest, response = mockResponse, delayInMilliseconds = delayInMs, stubToken = stubToken, requestBodyRegex = requestBodyRegex, data = data) } fun getRequestBodyRegexOrNull(mockSpec: Map): String? { @@ -70,6 +328,12 @@ fun getJSONObjectValue(key: String, mapData: Map): Map): Map? { + val data = mapData[key] ?: return null + if(data !is JSONObjectValue) throw ContractException("$key should be a json object") + return data.jsonObject +} + fun getIntOrNull(key: String, mapData: Map): Int? { val data = mapData[key] diff --git a/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt index 23e560526..304cfc120 100644 --- a/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt @@ -12,11 +12,11 @@ import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.util.* import io.specmatic.core.* -import io.specmatic.core.route.modules.HealthCheckModule.Companion.configureHealthCheckModule -import io.specmatic.core.route.modules.HealthCheckModule.Companion.isHealthCheckRequest import io.specmatic.core.log.* import io.specmatic.core.pattern.ContractException 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.* @@ -36,14 +36,6 @@ import java.nio.charset.Charset import java.util.* import kotlin.text.toCharArray -data class HttpStubResponse( - val response: HttpResponse, - val delayInMilliSeconds: Long? = null, - val contractPath: String = "", - val feature: Feature? = null, - val scenario: Scenario? = null -) - class HttpStub( private val features: List, rawHttpStubs: List = emptyList(), @@ -94,7 +86,8 @@ class HttpStub( } private fun staticHttpStubData(rawHttpStubs: List): MutableList { - val staticStubs = rawHttpStubs.filter { it.stubToken == null }.toMutableList() + val staticStubs = rawHttpStubs.filter { it.stubToken == null } + val stubsFromSpecificationExamples: List = features.map { feature -> feature.stubsFromExamples.entries.map { (exampleName, examples) -> examples.mapNotNull { (request, response) -> @@ -730,7 +723,7 @@ fun getHttpResponse( val (matchResults, matchingStubResponse) = stubbedResponse(threadSafeStubs, threadSafeStubQueue, httpRequest) if(matchingStubResponse != null) - FoundStubbedResponse(matchingStubResponse) + FoundStubbedResponse(matchingStubResponse.resolveSubstitutions(httpRequest)) else if (httpClientFactory != null && passThroughTargetBase.isNotBlank()) NotStubbed(passThroughResponse(httpRequest, passThroughTargetBase, httpClientFactory)) else if (strictMode) { @@ -1016,9 +1009,22 @@ fun stubResponse( } fun contractInfoToHttpExpectations(contractInfo: List>>): List { - return contractInfo.flatMap { (feature, mocks) -> - mocks.map { mock -> - feature.matchingStub(mock, ContractAndStubMismatchMessages) + 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) + } } } } diff --git a/core/src/main/kotlin/io/specmatic/stub/HttpStubResponse.kt b/core/src/main/kotlin/io/specmatic/stub/HttpStubResponse.kt new file mode 100644 index 000000000..35307eed6 --- /dev/null +++ b/core/src/main/kotlin/io/specmatic/stub/HttpStubResponse.kt @@ -0,0 +1,22 @@ +package io.specmatic.stub + +import io.specmatic.core.Feature +import io.specmatic.core.HttpRequest +import io.specmatic.core.HttpResponse +import io.specmatic.core.Scenario + +data class HttpStubResponse( + val response: HttpResponse, + val delayInMilliSeconds: Long? = null, + val contractPath: String = "", + val feature: Feature? = null, + val scenario: Scenario? = null +) { + fun resolveSubstitutions(request: HttpRequest): HttpStubResponse { + val updatedResponse = scenario?.let { + it.resolveSubtitutions(request, response) + } ?: response + + return this.copy(response = updatedResponse) + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/specmatic/stub/StubData.kt b/core/src/main/kotlin/io/specmatic/stub/StubData.kt index 66f8a008c..4a8216cc1 100644 --- a/core/src/main/kotlin/io/specmatic/stub/StubData.kt +++ b/core/src/main/kotlin/io/specmatic/stub/StubData.kt @@ -6,6 +6,7 @@ import io.specmatic.core.pattern.ContractException import io.specmatic.core.pattern.attempt import io.specmatic.core.utilities.ExternalCommand import io.specmatic.core.utilities.jsonStringToValueMap +import io.specmatic.core.value.Value data class HttpStubData( val requestType: HttpRequestPattern, diff --git a/core/src/main/kotlin/io/specmatic/stub/api.kt b/core/src/main/kotlin/io/specmatic/stub/api.kt index b17b4cfed..7efbc123e 100644 --- a/core/src/main/kotlin/io/specmatic/stub/api.kt +++ b/core/src/main/kotlin/io/specmatic/stub/api.kt @@ -25,7 +25,7 @@ fun createStubFromContractAndData(contractGherkin: String, dataDirectory: String val mocks = (File(dataDirectory).listFiles()?.filter { it.name.endsWith(".json") } ?: emptyList()).map { file -> consoleLog(StringLog("Loading data from ${file.name}")) - stringToMockScenario(StringValue(file.readText(Charsets.UTF_8))) + ScenarioStub.readFromFile(file) .also { contractBehaviour.matchingStub(it, ContractAndStubMismatchMessages) } @@ -203,7 +203,7 @@ fun loadContractStubsFromImplicitPaths(contractPathDataList: List + val request = HttpRequest("GET", "/person", queryParametersMap = mapOf("type" to "employee")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val responseBody = response.body as JSONObjectValue + assertThat(responseBody.findFirstChildByPath("type")?.toStringLiteral()).isEqualTo("employee") + } + } + + @Test + fun `should be able to stub out enum with string type using data substitution`() { + createStubFromContracts(listOf("src/test/resources/openapi/spec_with_empoyee_enum2.yaml"), timeoutMillis = 0).use { stub -> + val request = HttpRequest("GET", "/person", queryParametersMap = mapOf("type" to "manager")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val responseBody = response.body as JSONObjectValue + assertThat(responseBody.findFirstChildByPath("type")?.toStringLiteral()).isEqualTo("manager") + assertThat(responseBody.findFirstChildByPath("name")?.toStringLiteral()).isEqualTo("Justin") + } + } + companion object { @JvmStatic fun singleFeatureContractSource(): Stream { diff --git a/core/src/test/kotlin/io/specmatic/core/SubstitutionTest.kt b/core/src/test/kotlin/io/specmatic/core/SubstitutionTest.kt new file mode 100644 index 000000000..f5c7f3c11 --- /dev/null +++ b/core/src/test/kotlin/io/specmatic/core/SubstitutionTest.kt @@ -0,0 +1,21 @@ +package io.specmatic.core + +import io.specmatic.core.pattern.parsedJSONObject +import io.specmatic.core.value.JSONObjectValue +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test + +class SubstitutionTest { + @Test + @Disabled + fun `substitution using request body value`(){ + val request = HttpRequest("POST", "/data", body = parsedJSONObject("""{"id": "abc123"}""")) + val responseValue = parsedJSONObject("""{"id": "{{REQUEST.BODY.id}}"}""") + +// val updatedVaue = Substitution(request, httpPathPattern, headersPattern, httpQueryParamPattern, body, resolver).resolveSubstitutions(responseValue) as JSONObjectValue +// +// assertThat(updatedVaue.findFirstChildByPath("id")?.toStringLiteral()).isEqualTo("abc123") + } +} \ No newline at end of file diff --git a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt index 9b2063927..23df95aac 100644 --- a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt @@ -14,7 +14,6 @@ import io.specmatic.test.HttpClient import io.specmatic.test.TestExecutor import io.mockk.every import io.mockk.mockk -import io.specmatic.stub import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Nested import org.junit.jupiter.api.RepeatedTest @@ -22,6 +21,8 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.DisabledOnOs import org.junit.jupiter.api.condition.OS import org.junit.jupiter.api.fail +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod import org.springframework.http.MediaType @@ -1832,4 +1833,475 @@ components: assertThat(responseBody.jsonObject["message"]).isInstanceOf(NumberValue::class.java) } } + + @Test + fun `stub example with substitution in response body`() { + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_substitution_in_response_body.yaml") + + createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> + val request = HttpRequest("POST", "/person", body = parsedJSONObject("""{"name": "Jane"}""")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val jsonResponse = response.body as JSONObjectValue + assertThat(jsonResponse.findFirstChildByPath("name")?.toStringLiteral()).isEqualTo("Jane") + } + } + + @Test + fun `stub example with substitution in response header`() { + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header.yaml") + + createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> + val request = HttpRequest("POST", "/person", body = parsedJSONObject("""{"name": "Jane"}""")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val responseHeaders = response.headers + assertThat(responseHeaders["X-Name"]).isEqualTo("Jane") + } + } + + @Test + fun `stub example with substitution in response using request headers`() { + val spec = """ + openapi: 3.0.0 + info: + title: Sample API + version: 0.1.9 + paths: + /data: + get: + summary: Get data + parameters: + - in: header + name: X-Trace + schema: + type: string + required: true + responses: + '200': + description: OK + headers: + X-Trace: + description: Trace id + schema: + type: string + content: + text/plain: + schema: + type: string + """.trimIndent() + + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + val exampleRequest = HttpRequest("GET", "/data", headers = mapOf("X-Trace" to "abc123")) + val exampleResponse = HttpResponse(200, mapOf("X-Trace" to "{{REQUEST.HEADERS.X-Trace}}")) + + HttpStub(feature, listOf(ScenarioStub(exampleRequest, exampleResponse))).use { stub -> + val request = HttpRequest("GET", "/data", headers = mapOf("X-Trace" to "abc123")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val responseHeaders = response.headers + assertThat(responseHeaders["X-Trace"]).isEqualTo("abc123") + } + } + + @Test + fun `stub example with substitution in response using request query params`() { + val spec = """ + openapi: 3.0.0 + info: + title: Sample API + version: 0.1.9 + paths: + /data: + get: + summary: Get data + parameters: + - in: query + name: traceId + schema: + type: string + required: true + responses: + '200': + description: OK + headers: + X-Trace: + description: Trace id + schema: + type: string + content: + text/plain: + schema: + type: string + """.trimIndent() + + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + val exampleRequest = HttpRequest("GET", "/data", queryParametersMap = mapOf("traceId" to "abc123")) + val exampleResponse = HttpResponse(200, mapOf("X-Trace" to "{{REQUEST.QUERY-PARAMS.traceId}}")) + + HttpStub(feature, listOf(ScenarioStub(exampleRequest, exampleResponse))).use { stub -> + val request = HttpRequest("GET", "/data", queryParametersMap = mapOf("traceId" to "abc123")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val responseHeaders = response.headers + assertThat(responseHeaders["X-Trace"]).isEqualTo("abc123") + } + } + + @Test + fun `stub example with substitution in response using request path param`() { + val spec = """ + openapi: 3.0.0 + info: + title: Sample API + version: 0.1.9 + paths: + /data/{id}: + get: + summary: Get data + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - id + - name + properties: + id: + type: string + """.trimIndent() + + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + val exampleRequest = HttpRequest("GET", "/data/abc123") + val exampleResponse = HttpResponse(200, headers = mapOf("Content-Type" to "application/json"), body = parsedJSONObject("""{"id": "{{REQUEST.PATH.id}}"}""")) + + HttpStub(feature, listOf(ScenarioStub(exampleRequest, exampleResponse))).use { stub -> + val request = HttpRequest("GET", "/data/abc123") + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val jsonResponseBody = response.body as JSONObjectValue + assertThat(jsonResponseBody.findFirstChildByPath("id")).isEqualTo(StringValue("abc123")) + } + } + + @Test + fun `type coersion when a stringly request param and the response value are different`() { + val spec = """ + openapi: 3.0.0 + info: + title: Sample API + version: 0.1.9 + paths: + /data/{id}: + get: + summary: Get data + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - id + - name + properties: + id: + type: integer + """.trimIndent() + + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + val exampleRequest = HttpRequest("GET", "/data/123") + val exampleResponse = HttpResponse(200, headers = mapOf("Content-Type" to "application/json"), body = parsedJSONObject("""{"id": "{{REQUEST.PATH.id}}"}""")) + + HttpStub(feature, listOf(ScenarioStub(exampleRequest, exampleResponse))).use { stub -> + val request = HttpRequest("GET", "/data/123") + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val jsonResponseBody = response.body as JSONObjectValue + assertThat(jsonResponseBody.findFirstChildByPath("id")).isEqualTo(NumberValue(123)) + } + } + + @Test + fun `type coersion when a request object field value and the response object field value are different`() { + val spec = """ + openapi: 3.0.0 + info: + title: Sample API + version: 0.1.9 + paths: + /data: + post: + summary: Get data + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - id + - name + properties: + id: + type: integer + """.trimIndent() + + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + val exampleRequest = HttpRequest("POST", "/data", body = parsedJSONObject("""{"id": "123"}""")) + val exampleResponse = HttpResponse(200, headers = mapOf("Content-Type" to "application/json"), body = parsedJSONObject("""{"id": "{{REQUEST.BODY.id}}"}""")) + + HttpStub(feature, listOf(ScenarioStub(exampleRequest, exampleResponse))).use { stub -> + val response = stub.client.execute(exampleRequest) + + assertThat(response.status).isEqualTo(200) + val jsonResponseBody = response.body as JSONObjectValue + assertThat(jsonResponseBody.findFirstChildByPath("id")).isEqualTo(NumberValue(123)) + } + } + + @Test + fun `throw an error when the value in the request body cannot be used in the body due to schema mismatch`() { + val spec = """ + openapi: 3.0.0 + info: + title: Sample API + version: 0.1.9 + paths: + /data: + post: + summary: Get data + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - id + - name + properties: + id: + type: integer + """.trimIndent() + + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + val exampleRequest = HttpRequest("POST", "/data", body = parsedJSONObject("""{"id": "abc"}""")) + val exampleResponse = HttpResponse(200, headers = mapOf("Content-Type" to "application/json"), body = parsedJSONObject("""{"id": "{{REQUEST.BODY.id}}"}""")) + + HttpStub(feature, listOf(ScenarioStub(exampleRequest, exampleResponse))).use { stub -> + val response = stub.client.execute(exampleRequest) + + assertThat(response.status).isEqualTo(400) + assertThat(response.body.toStringLiteral()).contains("RESPONSE.BODY.id") + } + } + + @Test + fun `throw an error when the value in the request header cannot be used in the body due to schema mismatch`() { + val spec = """ + openapi: 3.0.0 + info: + title: Sample API + version: 0.1.9 + paths: + /data: + post: + summary: Get data + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: string + responses: + '200': + description: OK + headers: + X-Id: + description: id from the body + schema: + type: integer + content: + text/plain: + schema: + type: string + """.trimIndent() + + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + val exampleRequest = HttpRequest("POST", "/data", body = parsedJSONObject("""{"id": "abc"}""")) + val exampleResponse = HttpResponse(200, headers = mapOf("Content-Type" to "text/plain", "X-Id" to "{{REQUEST.BODY.id}}"), body = StringValue("success")) + + HttpStub(feature, listOf(ScenarioStub(exampleRequest, exampleResponse))).use { stub -> + val response = stub.client.execute(exampleRequest) + + assertThat(response.status).isEqualTo(400) + assertThat(response.body.toStringLiteral()).contains("RESPONSE.HEADERS.X-Id") + } + } + + @ParameterizedTest + @CsvSource("engineering,Bangalore", "sales,Mumbai") + fun `stub example with data substitution`(department: String, location: String) { + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_map_substitution_in_response_body.yaml") + + createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> + val request = HttpRequest("POST", "/person", body = parsedJSONObject("""{"department": "$department"}""")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val jsonResponse = response.body as JSONObjectValue + assertThat(jsonResponse.findFirstChildByPath("location")?.toStringLiteral()).isEqualTo("$location") + } + } + + @ParameterizedTest + @CsvSource("1,Bangalore", "2,Mumbai") + fun `stub example with data substitution having integer in request`(id: String, location: String) { + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_map_substitution_with_int_in_request.yaml") + + createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> + val request = HttpRequest("POST", "/person", body = parsedJSONObject("""{"id": $id}""")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val jsonResponse = response.body as JSONObjectValue + assertThat(jsonResponse.findFirstChildByPath("location")?.toStringLiteral()).isEqualTo("$location") + } + } + + @Test + fun `data substitution involving all GET request parts and response parts`() { + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_get_sections.yaml") + + createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> + val request = HttpRequest("GET", "/data/abc", headers = mapOf("X-Routing-Token" to "AB"), queryParametersMap = mapOf("location" to "IN")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + + assertThat(response.headers["X-Region"]).isEqualTo("IN") + + val jsonResponse = response.body as JSONObjectValue + assertThat(jsonResponse.findFirstChildByPath("city")?.toStringLiteral()).isEqualTo("Mumbai") + assertThat(jsonResponse.findFirstChildByPath("currency")?.toStringLiteral()).isEqualTo("INR") + + } + } + + @Test + fun `data substitution with explicitly referenced data key in response body`() { + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_body.yaml") + + createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> + val request = HttpRequest("POST", "/person", body = parsedJSONObject("""{"department": "engineering"}""")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + + val jsonResponse = response.body as JSONObjectValue + assertThat(jsonResponse.findFirstChildByPath("location")?.toStringLiteral()).isEqualTo("Mumbai") + + } + } + + @Test + fun `data substitution with explicitly referenced data key in response header`() { + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_header.yaml") + + createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> + val request = HttpRequest("POST", "/person", body = parsedJSONObject("""{"department": "engineering"}""")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + + assertThat(response.headers["X-Location"]).isEqualTo("Mumbai") + + } + } + + @Test + fun `should log the file in which a substitution error has occurred`() { + val (output, _) = captureStandardOutput { + try { + val stub = createStubFromContracts(listOf(osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_non_existent_data_key.yaml")), timeoutMillis = 0) + stub.close() + } catch(e: Throwable) { + + } + } + + println(output) + + assertThat(output) + .contains("Error resolving template data for example") + .contains(osAgnosticPath("spec_with_non_existent_data_key_examples/substitution.json")) + .contains("@id") + } + + @Test + fun `should flag an error when data substitution keys are not found in @data`() { + val (output, _) = captureStandardOutput { + try { + val stub = createStubFromContracts(listOf(("src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section.yaml")), timeoutMillis = 0) + stub.close() + } catch(e: Throwable) { + + } + } + + assertThat(output) + .contains("@department") + } } diff --git a/core/src/test/resources/openapi/spec_with_empoyee_enum.yaml b/core/src/test/resources/openapi/spec_with_empoyee_enum.yaml new file mode 100644 index 000000000..4489e90e4 --- /dev/null +++ b/core/src/test/resources/openapi/spec_with_empoyee_enum.yaml @@ -0,0 +1,40 @@ +openapi: 3.0.3 +info: + title: Person API + description: A simple API to fetch person details. + version: 1.0.0 + +paths: + /person: + get: + summary: Fetch person details + description: Retrieve details of a person using their ID. + parameters: + - in: query + name: type + required: true + schema: + $ref: '#/components/schemas/PersonType' + description: The ID of the person to fetch. + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + required: + - name + - type + properties: + name: + type: string + type: + $ref: '#/components/schemas/PersonType' +components: + schemas: + PersonType: + type: string + enum: + - employee + - manager diff --git a/core/src/test/resources/openapi/spec_with_empoyee_enum2.yaml b/core/src/test/resources/openapi/spec_with_empoyee_enum2.yaml new file mode 100644 index 000000000..4489e90e4 --- /dev/null +++ b/core/src/test/resources/openapi/spec_with_empoyee_enum2.yaml @@ -0,0 +1,40 @@ +openapi: 3.0.3 +info: + title: Person API + description: A simple API to fetch person details. + version: 1.0.0 + +paths: + /person: + get: + summary: Fetch person details + description: Retrieve details of a person using their ID. + parameters: + - in: query + name: type + required: true + schema: + $ref: '#/components/schemas/PersonType' + description: The ID of the person to fetch. + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + required: + - name + - type + properties: + name: + type: string + type: + $ref: '#/components/schemas/PersonType' +components: + schemas: + PersonType: + type: string + enum: + - employee + - manager diff --git a/core/src/test/resources/openapi/spec_with_empoyee_enum2_examples/example.json b/core/src/test/resources/openapi/spec_with_empoyee_enum2_examples/example.json new file mode 100644 index 000000000..d3740cbe6 --- /dev/null +++ b/core/src/test/resources/openapi/spec_with_empoyee_enum2_examples/example.json @@ -0,0 +1,23 @@ +{ + "data": { + "@personType": { + "manager": { + "name": "Justin" + } + } + }, + "http-request": { + "method": "GET", + "path": "/person", + "query": { + "type": "{{@personType}}" + } + }, + "http-response": { + "status": 200, + "body": { + "name": "{{@personType}}", + "type": "manager" + } + } +} \ No newline at end of file diff --git a/core/src/test/resources/openapi/spec_with_empoyee_enum_examples/example.json b/core/src/test/resources/openapi/spec_with_empoyee_enum_examples/example.json new file mode 100644 index 000000000..b087dc6eb --- /dev/null +++ b/core/src/test/resources/openapi/spec_with_empoyee_enum_examples/example.json @@ -0,0 +1,13 @@ +{ + "http-request": { + "method": "GET", + "path": "/person?type=(PersonType)" + }, + "http-response": { + "status": 200, + "body": { + "name": "Jill", + "type": "{{REQUEST.QUERY-PARAMS.type}}" + } + } +} \ No newline at end of file diff --git a/core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section.yaml b/core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section.yaml new file mode 100644 index 000000000..a0c4045f5 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - department + properties: + department: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - location + properties: + location: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section_examples/example.json b/core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section_examples/example.json new file mode 100644 index 000000000..f9b7ab299 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section_examples/example.json @@ -0,0 +1,15 @@ +{ + "http-request": { + "method": "POST", + "path": "/person", + "body": { + "department": "{{@department}}" + } + }, + "http-response": { + "status": 200, + "body": { + "location": "{{@department}}" + } + } +} \ No newline at end of file diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_get_sections.yaml b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_get_sections.yaml new file mode 100644 index 000000000..ef666e05d --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_get_sections.yaml @@ -0,0 +1,43 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /data/{id}: + get: + summary: Fetch data + parameters: + - in: path + required: true + name: id + schema: + type: string + - in: query + name: location + required: true + schema: + type: string + - in: header + name: X-Routing-Token + required: true + schema: + type: string + responses: + '200': + description: OK + headers: + X-Region: + schema: + type: string + content: + application/json: + schema: + type: object + required: + - name + - currency + properties: + city: + type: string + currency: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_get_sections_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_get_sections_examples/substitution.json new file mode 100644 index 000000000..45d8e3ca0 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_get_sections_examples/substitution.json @@ -0,0 +1,36 @@ +{ + "data": { + "@token": { + "AB": { + "X-Region": "IN" + } + }, + "@location": { + "IN": { + "currency": "INR" + } + }, + "@id": { + "abc": { + "city": "Mumbai" + } + } + }, + "http-request": { + "method": "GET", + "path": "/data/{{@id}}?location={{@location}}", + "headers": { + "X-Routing-Token": "{{@token}}" + } + }, + "http-response": { + "status": 200, + "headers": { + "X-Region": "{{@token}}" + }, + "body": { + "city": "{{@id}}", + "currency": "{{@location}}" + } + } +} \ No newline at end of file diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_query_params.yaml b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_query_params.yaml new file mode 100644 index 000000000..ef666e05d --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_query_params.yaml @@ -0,0 +1,43 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /data/{id}: + get: + summary: Fetch data + parameters: + - in: path + required: true + name: id + schema: + type: string + - in: query + name: location + required: true + schema: + type: string + - in: header + name: X-Routing-Token + required: true + schema: + type: string + responses: + '200': + description: OK + headers: + X-Region: + schema: + type: string + content: + application/json: + schema: + type: object + required: + - name + - currency + properties: + city: + type: string + currency: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_query_params_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_query_params_examples/substitution.json new file mode 100644 index 000000000..e7403c24d --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_query_params_examples/substitution.json @@ -0,0 +1,39 @@ +{ + "data": { + "@token": { + "AB": { + "X-Region": "IN" + } + }, + "@location": { + "IN": { + "currency": "INR" + } + }, + "@id": { + "abc": { + "city": "Mumbai" + } + } + }, + "http-request": { + "method": "GET", + "path": "/data/{{@id}}", + "headers": { + "X-Routing-Token": "{{@token}}" + }, + "query": { + "location": "{{@location}}" + } + }, + "http-response": { + "status": 200, + "headers": { + "X-Region": "{{@token}}" + }, + "body": { + "city": "{{@id}}", + "currency": "{{@location}}" + } + } +} \ No newline at end of file diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_response_body.yaml b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_response_body.yaml new file mode 100644 index 000000000..a0c4045f5 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_response_body.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - department + properties: + department: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - location + properties: + location: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_response_body_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_response_body_examples/substitution.json new file mode 100644 index 000000000..e5f21cf79 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_response_body_examples/substitution.json @@ -0,0 +1,25 @@ +{ + "data": { + "@department": { + "engineering": { + "location": "Bangalore" + }, + "sales": { + "location": "Mumbai" + } + } + }, + "http-request": { + "method": "POST", + "path": "/person", + "body": { + "department": "{{@department}}" + } + }, + "http-response": { + "status": 200, + "body": { + "location": "{{@department}}" + } + } +} \ No newline at end of file diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_body.yaml b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_body.yaml new file mode 100644 index 000000000..a0c4045f5 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_body.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - department + properties: + department: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - location + properties: + location: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_body_examples/example.json b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_body_examples/example.json new file mode 100644 index 000000000..7ecf40461 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_body_examples/example.json @@ -0,0 +1,22 @@ +{ + "data": { + "@dept": { + "engineering": { + "city": "Mumbai" + } + } + }, + "http-request": { + "method": "POST", + "path": "/person", + "body": { + "department": "{{@dept}}" + } + }, + "http-response": { + "status": 200, + "body": { + "location": "{{@dept.city}}" + } + } +} \ No newline at end of file diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_header.yaml b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_header.yaml new file mode 100644 index 000000000..ee7bea502 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_header.yaml @@ -0,0 +1,35 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - department + properties: + department: + type: string + responses: + '200': + description: OK + headers: + X-Location: + schema: + type: string + content: + application/json: + schema: + type: object + required: + - location + properties: + location: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_header_examples/example.json b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_header_examples/example.json new file mode 100644 index 000000000..b021e402d --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_header_examples/example.json @@ -0,0 +1,25 @@ +{ + "data": { + "@dept": { + "engineering": { + "city": "Mumbai" + } + } + }, + "http-request": { + "method": "POST", + "path": "/person", + "body": { + "department": "{{@dept}}" + } + }, + "http-response": { + "status": 200, + "headers": { + "X-Location": "{{@dept.city}}" + }, + "body": { + "location": "{{@dept.city}}" + } + } +} \ No newline at end of file diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_int_in_request.yaml b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_int_in_request.yaml new file mode 100644 index 000000000..648073fbb --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_int_in_request.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - location + properties: + location: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_int_in_request_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_int_in_request_examples/substitution.json new file mode 100644 index 000000000..045687b81 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_int_in_request_examples/substitution.json @@ -0,0 +1,25 @@ +{ + "data": { + "@id": { + "1": { + "location": "Bangalore" + }, + "2": { + "location": "Mumbai" + } + } + }, + "http-request": { + "method": "POST", + "path": "/person", + "body": { + "id": "{{@id}}" + } + }, + "http-response": { + "status": 200, + "body": { + "location": "{{@id}}" + } + } +} \ No newline at end of file diff --git a/core/src/test/resources/openapi/substitutions/spec_with_non_existent_data_key.yaml b/core/src/test/resources/openapi/substitutions/spec_with_non_existent_data_key.yaml new file mode 100644 index 000000000..a0c4045f5 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_non_existent_data_key.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - department + properties: + department: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - location + properties: + location: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_non_existent_data_key_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_non_existent_data_key_examples/substitution.json new file mode 100644 index 000000000..4dac6358f --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_non_existent_data_key_examples/substitution.json @@ -0,0 +1,22 @@ +{ + "data": { + "id": { + "@data": { + "test": "test" + } + } + }, + "http-request": { + "method": "POST", + "path": "/person", + "body": { + "department": "{{@id}}" + } + }, + "http-response": { + "status": 200, + "body": { + "location": "{{@id}}" + } + } +} \ No newline at end of file diff --git a/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_body.yaml b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_body.yaml new file mode 100644 index 000000000..76f02c95e --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_body.yaml @@ -0,0 +1,34 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - id + - name + properties: + id: + type: number + name: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_body_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_body_examples/substitution.json new file mode 100644 index 000000000..42adfe187 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_body_examples/substitution.json @@ -0,0 +1,16 @@ +{ + "http-request": { + "method": "POST", + "path": "/person", + "body": { + "name": "Jane" + } + }, + "http-response": { + "status": 200, + "body": { + "id": 10, + "name": "{{REQUEST.BODY.name}}" + } + } +} \ No newline at end of file diff --git a/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header.yaml b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header.yaml new file mode 100644 index 000000000..403357c59 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + responses: + '200': + description: OK + headers: + X-Name: + description: ID in the header + schema: + type: string + content: + text/plain: + schema: + type: string \ No newline at end of file diff --git a/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header_examples/substitution.json new file mode 100644 index 000000000..7db6832ce --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header_examples/substitution.json @@ -0,0 +1,16 @@ +{ + "http-request": { + "method": "POST", + "path": "/person", + "body": { + "name": "Jane" + } + }, + "http-response": { + "status": 200, + "headers": { + "X-Name": "{{REQUEST.BODY.name}}" + }, + "body": "success" + } +} \ No newline at end of file diff --git a/core/src/test/resources/openapi/substitutions/spec_with_template_substitution_in_response_body.yaml b/core/src/test/resources/openapi/substitutions/spec_with_template_substitution_in_response_body.yaml new file mode 100644 index 000000000..a0c4045f5 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_template_substitution_in_response_body.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - department + properties: + department: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - location + properties: + location: + type: string diff --git a/version.properties b/version.properties index 795e9d33f..6eafb4507 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -version=2.0.10 +version=2.0.11