Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ data class BasicAuthSecurityScheme(private val token: String? = null) : OpenAPIS
private fun getTokenFromDictionary(resolver: Resolver): ReturnValue<String>? {
val updatedResolver = resolver.updateLookupForParam(BreadCrumb.HEADER.value)
val dictionaryValue = updatedResolver.dictionary.getValueFor(AUTHORIZATION, StringPattern(), updatedResolver)
val authHeader = dictionaryValue?.unwrapOrContractException() ?: return null
val authHeader = dictionaryValue ?: return null

if (authHeader !is StringValue) return HasFailure(Result.Failure(
breadCrumb = BreadCrumb.HEADER.with(AUTHORIZATION),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ class OpenApiSpecification(
private val specificationPath: String? = null,
private val securityConfiguration: SecurityConfiguration? = null,
private val specmaticConfig: SpecmaticConfig = SpecmaticConfig(),
private val dictionary: Dictionary = loadDictionary(openApiFilePath, specmaticConfig.getStubDictionary()),
private val strictMode: Boolean = false
private val strictMode: Boolean = false,
private val dictionary: Dictionary = loadDictionary(openApiFilePath, specmaticConfig.getStubDictionary(), strictMode),
) : IncludedSpecification, ApiSpecification {
init {
StringProviders // Trigger early initialization of StringProviders to ensure all providers are loaded at startup
Expand Down Expand Up @@ -268,9 +268,9 @@ class OpenApiSpecification(
)
}

fun loadDictionary(openApiFilePath: String, dictionaryPathFromConfig: String?): Dictionary {
fun loadDictionary(openApiFilePath: String, dictionaryPathFromConfig: String?, strictMode: Boolean = false): Dictionary {
val dictionaryFile = getDictionaryFile(File(openApiFilePath), dictionaryPathFromConfig)
return if (dictionaryFile != null) Dictionary.from(dictionaryFile) else Dictionary.empty()
return if (dictionaryFile != null) Dictionary.from(dictionaryFile, strictMode) else Dictionary.empty(strictMode)
}

private fun getDictionaryFile(openApiFile: File, dictionaryPathFromConfig: String?): File? {
Expand Down
114 changes: 98 additions & 16 deletions core/src/main/kotlin/io/specmatic/core/Dictionary.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import io.specmatic.test.ExampleProcessor.toFactStore
import io.specmatic.test.asserts.WILDCARD_INDEX
import java.io.File

data class Dictionary(private val data: Map<String, Value>, private val focusedData: Map<String, Value> = emptyMap()) {
data class Dictionary(
private val data: Map<String, Value>,
private val focusedData: Map<String, Value> = emptyMap(),
private val strictMode: Boolean = false
) {
private val defaultData: Map<String, Value> = data.filterKeys(::isPatternToken)

fun plus(other: Map<String, Value>): Dictionary {
Expand Down Expand Up @@ -50,13 +54,31 @@ data class Dictionary(private val data: Map<String, Value>, private val focusedD
val resolved = resolvedHop(pattern, resolver)
val lookupKey = withPatternDelimiters(resolved.typeName)
val defaultValue = defaultData[lookupKey] ?: return null
return getReturnValueFor(lookupKey, defaultValue, resolved, resolver)?.withDefault(null) { it }
return getReturnValueFor(lookupKey, defaultValue, resolved, resolver)?.realise(
hasValue = { it, _ -> it },
orFailure = { f ->
logger.debug(f.toFailure().reportString()); null
},
orException = { e ->
logger.debug(e.toHasFailure().toFailure().reportString()); null
},
)
}

fun getValueFor(lookup: String, pattern: Pattern, resolver: Resolver): ReturnValue<Value>? {
fun getValueFor(lookup: String, pattern: Pattern, resolver: Resolver): Value? {
val tailEndKey = lookup.tailEndKey()
val dictionaryValue = focusedData[tailEndKey] ?: return null
return getReturnValueFor(lookup, dictionaryValue, pattern, resolver)
return getReturnValueFor(lookup, dictionaryValue, pattern, resolver)?.realise(
hasValue = { it, _ -> it },
orFailure = { f ->
if (strictMode) f.toFailure().throwOnFailure()
else logger.debug(f.toFailure().reportString()); null
},
orException = { e ->
if (strictMode) e.toHasFailure().toFailure().throwOnFailure()
else logger.debug(e.toHasFailure().toFailure().reportString()); null
},
)
}

private fun focusInto(
Expand All @@ -71,8 +93,8 @@ data class Dictionary(private val data: Map<String, Value>, private val focusedD
}

private fun getReturnValueFor(lookup: String, value: Value, pattern: Pattern, resolver: Resolver): ReturnValue<Value>? {
val valueToMatch = getValueToMatch(value, pattern, resolver) ?: return null
return runCatching {
val valueToMatch = getValueToMatch(value, pattern, resolver) ?: return null
val result = pattern.fillInTheBlanks(valueToMatch, resolver.copy(isNegative = false), removeExtraKeys = true)
if (result is ReturnFailure && resolver.isNegative) return null
result.addDetails("Invalid Dictionary value at \"$lookup\"", breadCrumb = "")
Expand All @@ -82,13 +104,13 @@ data class Dictionary(private val data: Map<String, Value>, private val focusedD
private fun getValueToMatch(value: Value, pattern: Pattern, resolver: Resolver, overrideNestedCheck: Boolean = false): Value? {
if (value !is JSONArrayValue) return value.takeIf { pattern.isScalar(resolver) || overrideNestedCheck }
if (pattern !is SequenceType) {
return if (overrideNestedCheck) value else value.list.randomOrNull()
return if (overrideNestedCheck) value else selectValue(pattern, value.list, resolver)
}

val patternDepth = calculateDepth<Pattern>(pattern) { (resolvedHop(it, resolver) as? SequenceType)?.memberList?.patternList() }
val valueDepth = calculateDepth<Value>(value) { (it as? JSONArrayValue)?.list }
return when {
valueDepth > patternDepth -> value.list.randomOrNull()
valueDepth > patternDepth -> selectValue(pattern, value.list, resolver)
else -> value
}
}
Expand All @@ -105,15 +127,75 @@ data class Dictionary(private val data: Map<String, Value>, private val focusedD

private fun Pattern.isScalar(resolver: Resolver): Boolean {
val resolved = resolvedHop(this, resolver)
return resolved is ScalarType || resolved is URLPathSegmentPattern || resolved is QueryParameterScalarPattern
return resolved is ScalarType || resolved is URLPathSegmentPattern
}

private fun resetFocus(): Dictionary = copy(focusedData = emptyMap())

private fun selectValue(pattern: Pattern, values: List<Value>, resolver: Resolver): Value? {
val lenientlySelectedValue = selectValueLenient(pattern, values, resolver)

if (strictMode && lenientlySelectedValue == null) {
throw ContractException(
errorMessage =
"""
None of the dictionary values matched the schema.
This could happen due to conflicts in the dictionary at the same json path, due to conflicting dataTypes at the same json path between multiple payloads
strictMode enforces the presence of matching values in the dictionary if the json-path is present
Either ensure that a matching value exists in the dictionary or disable strictMode
""".trimIndent(),
)
}

return lenientlySelectedValue
}

private val dictionaryMismatchMessages = object : MismatchMessages {
override fun mismatchMessage(expected: String, actual: String): String {
return "Expected $expected but got $actual in the dictionary"
}

override fun unexpectedKey(keyLabel: String, keyName: String): String {
return "Unexpected $keyLabel $keyName in the dictionary"
}

override fun expectedKeyWasMissing(keyLabel: String, keyName: String): String {
return "Expected $keyLabel $keyName was missing in the dictionary"
}
}

private fun selectValueLenient(pattern: Pattern, values: List<Value>, resolver: Resolver): Value? {
val dictionaryBreadcrumbs = ">> DICTIONARY.${resolver.dictionaryLookupPath}"

val updatedResolver = resolver.copy(
findKeyErrorCheck = noPatternKeyCheckDictionary,
mismatchMessages = dictionaryMismatchMessages,
)

return values.shuffled().firstOrNull { value ->
runCatching {
val result = pattern.matches(value, updatedResolver)

if (result is Result.Failure) {
logger.debug(dictionaryBreadcrumbs)
logger.debug(result.reportString())
logger.debug(System.lineSeparator())
}
result.isSuccess()
}.getOrElse { e ->
logger.debug(">> DICTIONARY.${resolver.dictionaryLookupPath}")
logger.debug(e, "Failed to select value $value from dictionary for ${pattern.typeName}")
logger.debug(System.lineSeparator())
false
}
}
}

companion object {
private const val SPECMATIC_CONSTANTS = "SPECMATIC_CONSTANTS"
private val noPatternKeyCheckDictionary = KeyCheck(noPatternKeyCheck, IgnoreUnexpectedKeys)

fun from(file: File): Dictionary {
fun from(file: File, strictMode: Boolean = false): Dictionary {
if (!file.exists()) throw ContractException(
breadCrumb = file.path,
errorMessage = "Expected dictionary file at ${file.path}, but it does not exist"
Expand All @@ -127,7 +209,7 @@ data class Dictionary(private val data: Map<String, Value>, private val focusedD
return runCatching {
logger.log("Using dictionary file ${file.path}")
val dictionary = readValueAs<JSONObjectValue>(file).resolveConstants()
from(data = dictionary.jsonObject)
from(data = dictionary.jsonObject, strictMode)
}.getOrElse { e ->
logger.debug(e)
throw ContractException(
Expand All @@ -137,11 +219,11 @@ data class Dictionary(private val data: Map<String, Value>, private val focusedD
}
}

fun fromYaml(content: String): Dictionary {
fun fromYaml(content: String, strictMode: Boolean = false): Dictionary {
return runCatching {
val value = yamlStringToValue(content)
if (value !is JSONObjectValue) throw ContractException("Expected dictionary file to be a YAML object")
from(value.jsonObject)
from(value.jsonObject, strictMode)
}.getOrElse { e ->
throw ContractException(
breadCrumb = "Error while parsing YAML dictionary content",
Expand All @@ -150,12 +232,12 @@ data class Dictionary(private val data: Map<String, Value>, private val focusedD
}
}

fun from(data: Map<String, Value>): Dictionary {
return Dictionary(data = data)
fun from(data: Map<String, Value>, strictMode: Boolean = false): Dictionary {
return Dictionary(data = data, strictMode = strictMode)
}

fun empty(): Dictionary {
return Dictionary(data = emptyMap())
fun empty(strictMode: Boolean = false): Dictionary {
return Dictionary(data = emptyMap(), strictMode = strictMode)
}

private fun JSONObjectValue.resolveConstants(): JSONObjectValue {
Expand Down
16 changes: 9 additions & 7 deletions core/src/main/kotlin/io/specmatic/core/Resolver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ data class Resolver(
fun generate(pattern: Pattern): Value {
val valueFromDict = dictionary.getValueFor(dictionaryLookupPath, pattern, this)
if (valueFromDict != null) {
return valueFromDict.unwrapOrContractException()
return valueFromDict
}

val defaultValueFromDict = dictionary.getDefaultValueFor(pattern, this)
Expand Down Expand Up @@ -264,11 +264,17 @@ data class Resolver(
focusIntoProperty(keyPattern, focusKey, this@Resolver)
}

return this.copy(
val patternUpdatedResolver = this.copy(
dictionaryLookupPath = lookupPath,
lookupPathsSeenSoFar = lookupPathsSeenSoFar.plus(lookupPath),
dictionary = keyFocused
)

return if (keyWithPattern?.pattern?.typeAlias == null || builtInPatterns.contains(keyWithPattern.pattern.typeAlias)) {
patternUpdatedResolver
} else {
patternUpdatedResolver.updateLookupPath(keyWithPattern.pattern.typeAlias)
}
}

fun <T> updateLookupPath(pattern: T, childPattern: Pattern): Resolver where T: Pattern, T: SequenceType {
Expand Down Expand Up @@ -322,11 +328,7 @@ data class Resolver(
fun generateList(pattern: ListPattern): Value {
val patternFocused = updateLookupPath(pattern.typeAlias)
val valueFromDict = patternFocused.dictionary.getValueFor(patternFocused.dictionaryLookupPath, pattern, this)
if (valueFromDict != null) {
return valueFromDict.unwrapOrContractException()
}

return this.updateLookupPath(pattern, pattern.pattern).generateRandomList(pattern.pattern)
return valueFromDict ?: this.updateLookupPath(pattern, pattern.pattern).generateRandomList(pattern.pattern)
}

private fun generateRandomList(pattern: Pattern): Value {
Expand Down
1 change: 1 addition & 0 deletions core/src/main/kotlin/io/specmatic/stub/api.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1063,6 +1063,7 @@ fun loadIfSupportedAPISpecification(
contractPathData.repository,
contractPathData.branch,
contractPathData.specificationPath,
strictMode = specmaticConfig.getStubStrictMode() ?: false
).copy(specmaticConfig = specmaticConfig),
)
} catch (e: Throwable) {
Expand Down
Loading
Loading