Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Improvements to filtering and handling of non-patchable keys in Virtual Service #1526

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
15 changes: 12 additions & 3 deletions core/src/main/kotlin/io/specmatic/core/QueryParameters.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
package io.specmatic.core

import io.specmatic.core.pattern.Pattern
import io.specmatic.core.pattern.parsedJSONArray
import io.specmatic.core.value.Value
import io.specmatic.core.pattern.*
import io.specmatic.core.value.*

data class QueryParameters(val paramPairs: List<Pair<String, String>> = emptyList()) {

Expand Down Expand Up @@ -48,6 +47,16 @@ data class QueryParameters(val paramPairs: List<Pair<String, String>> = emptyLis
}.toMap()
}

fun asValueMap(): Map<String, Value> {
return paramPairs.groupBy { it.first }.map { (parameterName, parameterValues) ->
if (parameterValues.size > 1) {
parameterName to JSONArrayValue(parameterValues.map { parsedScalarValue(it.second) })
} else {
parameterName to parsedScalarValue(parameterValues.first().second)
}
}.toMap()
}

fun getValues(key: String): List<String> {
return paramPairs.filter { it.first == key }.map { it.second }
}
Expand Down
8 changes: 8 additions & 0 deletions core/src/main/kotlin/io/specmatic/core/pattern/AnyPattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,14 @@ data class AnyPattern(
return Result.fromFailures(failuresWithUpdatedBreadcrumbs)
}

fun getUpdatedPattern(resolver: Resolver): List<Pattern> {
return if (discriminator != null) {
discriminator.updatePatternsWithDiscriminator(pattern, resolver).listFold().takeIf {
it is HasValue<List<Pattern>>
}?.value ?: return emptyList()
} else pattern
}

override fun generate(resolver: Resolver): Value {
return resolver.resolveExample(example, pattern)
?: generateValue(resolver)
Expand Down
15 changes: 14 additions & 1 deletion core/src/main/kotlin/io/specmatic/core/pattern/Grammar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import io.specmatic.core.MismatchMessages
import io.specmatic.core.utilities.jsonStringToValueArray
import io.specmatic.core.utilities.jsonStringToValueMap
import io.specmatic.core.value.*
import javax.validation.constraints.Null

const val XML_ATTR_OPTIONAL_SUFFIX = ".opt"
const val DEFAULT_OPTIONAL_SUFFIX = "?"
Expand Down Expand Up @@ -360,4 +361,16 @@ fun parsedValue(content: String?): Value {
StringValue(it)
}
} ?: EmptyString
}
}

fun parsedScalarValue(content: String?): Value {
val trimmed = content?.trim() ?: return NullValue
return when {
trimmed.toIntOrNull() != null -> NumberValue(trimmed.toInt())
trimmed.toLongOrNull() != null -> NumberValue(trimmed.toLong())
trimmed.toFloatOrNull() != null -> NumberValue(trimmed.toFloat())
trimmed.toDoubleOrNull() != null -> NumberValue(trimmed.toDouble())
trimmed.lowercase() in setOf("true", "false") -> BooleanValue(trimmed.toBoolean())
else -> StringValue(trimmed)
}
}
112 changes: 88 additions & 24 deletions core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,31 +11,14 @@ import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.specmatic.conversions.OpenApiSpecification
import io.specmatic.conversions.OpenApiSpecification.Companion.applyOverlay
import io.specmatic.core.Feature
import io.specmatic.core.HttpRequest
import io.specmatic.core.HttpRequestPattern
import io.specmatic.core.HttpResponse
import io.specmatic.core.Resolver
import io.specmatic.core.Scenario
import io.specmatic.core.SpecmaticConfig
import io.specmatic.core.loadSpecmaticConfig
import io.specmatic.core.*
import io.specmatic.core.log.HttpLogMessage
import io.specmatic.core.log.logger
import io.specmatic.core.pattern.ContractException
import io.specmatic.core.pattern.IgnoreUnexpectedKeys
import io.specmatic.core.pattern.JSONObjectPattern
import io.specmatic.core.pattern.Pattern
import io.specmatic.core.pattern.PossibleJsonObjectPatternContainer
import io.specmatic.core.pattern.StringPattern
import io.specmatic.core.pattern.resolvedHop
import io.specmatic.core.pattern.withoutOptionality
import io.specmatic.core.pattern.*
import io.specmatic.core.route.modules.HealthCheckModule.Companion.configureHealthCheckModule
import io.specmatic.core.route.modules.HealthCheckModule.Companion.isHealthCheckRequest
import io.specmatic.core.utilities.exceptionCauseMessage
import io.specmatic.core.value.JSONArrayValue
import io.specmatic.core.value.JSONObjectValue
import io.specmatic.core.value.StringValue
import io.specmatic.core.value.Value
import io.specmatic.core.value.*
import io.specmatic.mock.ScenarioStub
import io.specmatic.stub.ContractAndRequestsMismatch
import io.specmatic.stub.ContractStub
Expand Down Expand Up @@ -181,7 +164,7 @@ class StatefulHttpStub(
fakeResponse,
httpRequest,
specmaticConfig.stub.includeMandatoryAndRequestedKeysInResponse,
responses.responseWithStatusCodeStartingWith("404")?.successResponse?.responseBodyPattern
responses
) ?: generateHttpResponseFrom(fakeResponse, httpRequest)

return FoundStubbedResponse(
Expand Down Expand Up @@ -251,7 +234,7 @@ class StatefulHttpStub(
fakeResponse: ResponseDetails,
httpRequest: HttpRequest,
includeMandatoryAndRequestedKeysInResponse: Boolean?,
notFoundResponseBodyPattern: Pattern?
responses: Map<Int, ResponseDetails> = emptyMap()
): HttpResponse? {
val scenario = fakeResponse.successResponse?.scenario

Expand All @@ -267,7 +250,7 @@ class StatefulHttpStub(
scenario?.getFieldsToBeMadeMandatoryBasedOnAttributeSelection(httpRequest.queryParams).orEmpty()

val notFoundResponse = generate4xxResponseWithMessage(
notFoundResponseBodyPattern,
responses.responseWithStatusCodeStartingWith("404")?.successResponse?.responseBodyPattern,
scenario,
message = "Resource with resourceId '$resourceId' not found",
statusCode = 404
Expand Down Expand Up @@ -296,6 +279,23 @@ class StatefulHttpStub(
}

if(method == "PATCH" && pathSegments.size > 1) {
val existingEntity = stubCache.findResponseFor(resourcePath, resourceIdKey, resourceId)?.responseBody
val result = existingEntity?.validateNonPatchableKeys(httpRequest, specmaticConfig.virtualService.nonPatchableKeys)

if (result is Result.Failure) {
val unprocessableEntity = responses.responseWithStatusCodeStartingWith("422")
val (errorStatusCode, errorResponseBodyPattern) = if (unprocessableEntity?.successResponse != null) {
Pair(422, unprocessableEntity.successResponse.responseBodyPattern)
} else Pair(409, responses.responseWithStatusCodeStartingWith("409")?.successResponse?.responseBodyPattern)

return generate4xxResponseWithMessage(
errorResponseBodyPattern,
scenario,
result.reportString(),
errorStatusCode
)
}

val responseBody =
generatePatchResponse(
httpRequest,
Expand All @@ -310,11 +310,28 @@ class StatefulHttpStub(
}

if(method == "GET" && pathSegments.size == 1) {
val result = scenario.httpResponsePattern.body.validateAttributeFilters(httpRequest, scenario.resolver)

if (result is Result.Failure) {
return generate4xxResponseWithMessage(
responses.responseWithStatusCodeStartingWith("400")?.successResponse?.responseBodyPattern,
scenario,
message = result.reportString(),
statusCode = 400
)
}

val keysToFilterOut = scenario.httpRequestPattern.httpQueryParamPattern.queryKeyNames.map {
withoutOptionality(it)
}.plus(scenario.attributeSelectionPattern.queryParamKey)

val responseBody = stubCache.findAllResponsesFor(
resourcePath,
attributeSelectionKeys,
httpRequest.queryParams.asMap()
httpRequest.queryParams.asMap(),
ifKeyNotExist = { key -> key in keysToFilterOut }
)

return generatedResponse.withUpdated(responseBody, attributeSelectionKeys)
}

Expand Down Expand Up @@ -617,4 +634,51 @@ class StatefulHttpStub(
}.toMap()
}.flatMap { map -> map.entries.map { it.toPair() } }.toMap()
}

private fun JSONObjectValue.validateNonPatchableKeys(httpRequest: HttpRequest, keysToLookFor: Set<String>): Result {
if (httpRequest.body !is JSONObjectValue) return Result.Success()

val results = keysToLookFor.filter { it in this.jsonObject.keys && it in httpRequest.body.jsonObject.keys }.mapNotNull { key ->
if (this.jsonObject.getValue(key).toStringLiteral() != httpRequest.body.jsonObject.getValue(key).toStringLiteral()) {
Result.Failure(breadCrumb = key, message = "Key ${key.quote()} is not patchable")
} else null
}

return Result.fromResults(results).breadCrumb("BODY").breadCrumb("REQUEST")
}

private fun Pattern.validateAttributeFilters(httpRequest: HttpRequest, resolver: Resolver): Result {
if (this !is PossibleJsonObjectPatternContainer) return Result.Success()

val queryParametersValue = httpRequest.queryParams.asValueMap()
val adjustedResolver = features.first().flagsBased.update(resolver).let {
it.copy(findKeyErrorCheck = it.findKeyErrorCheck.copy(patternKeyCheck = noPatternKeyCheck))
}

val results = queryParametersValue.entries.mapNotNull { (key, value) ->
val patterns = this.getKeyPattern(key, resolver).takeIf { it.isNotEmpty() } ?: return@mapNotNull null
patterns.map {
it.matches(value.getMatchingValue(it), adjustedResolver).breadCrumb(key)
}.let { Results(it).toResultIfAny() }
}

return Result.fromResults(results).breadCrumb("QUERY-PARAMS").breadCrumb("REQUEST")
}

private fun Pattern.getKeyPattern(key: String, resolver: Resolver): List<Pattern> {
return when(this) {
is DeferredPattern -> resolvedHop(this, resolver).getKeyPattern(key, resolver)
is ListPattern -> this.pattern.getKeyPattern(key, resolver)
is AnyPattern -> this.getUpdatedPattern(resolver).flatMap { it.getKeyPattern(key, resolver) }
is JSONObjectPattern -> listOfNotNull(this.pattern[key] ?: this.pattern["$key?"])
else -> emptyList()
}
}

private fun Value.getMatchingValue(pattern: Pattern): Value {
return when(pattern) {
is NumberPattern, is BooleanPattern -> this
else -> StringValue(this.toStringLiteral())
}
}
}
9 changes: 5 additions & 4 deletions core/src/main/kotlin/io/specmatic/stub/stateful/StubCache.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,13 @@ class StubCache {
fun findAllResponsesFor(
path: String,
attributeSelectionKeys: Set<String>,
filter: Map<String, String> = emptyMap()
filter: Map<String, String> = emptyMap(),
ifKeyNotExist: (String) -> Boolean = { true }
): JSONArrayValue = lock.withLock {
val responseBodies = cachedResponses.filter {
it.path == path
}.map{ it.responseBody }.filter {
it.jsonObject.satisfiesFilter(filter)
it.jsonObject.satisfiesFilter(filter, ifKeyNotExist)
}.map {
it.removeKeysNotPresentIn(attributeSelectionKeys)
}
Expand All @@ -76,11 +77,11 @@ class StubCache {
}
}

private fun Map<String, Value>.satisfiesFilter(filter: Map<String, String>): Boolean {
private fun Map<String, Value>.satisfiesFilter(filter: Map<String, String>, ifKeyNotExist: (String) -> Boolean): Boolean {
if(filter.isEmpty()) return true

return filter.all { (filterKey, filterValue) ->
if(this.containsKey(filterKey).not()) return@all true
if(this.containsKey(filterKey).not()) return@all ifKeyNotExist(filterKey)

val actualValue = this.getValue(filterKey)
actualValue.toStringLiteral() == filterValue
Expand Down
Loading
Loading