Skip to content

Commit

Permalink
Merge pull request #1435 from znsio/mandatory-pattern-validation
Browse files Browse the repository at this point in the history
Ensure that all properties show up when generating an example of an API
  • Loading branch information
joelrosario authored Nov 20, 2024
2 parents afc03f3 + 8ff8e8c commit 1fe8ff2
Show file tree
Hide file tree
Showing 14 changed files with 901 additions and 72 deletions.
4 changes: 3 additions & 1 deletion core/src/main/kotlin/io/specmatic/core/Feature.kt
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,9 @@ data class Feature(
}

private fun positiveTestScenarios(suggestions: List<Scenario>, fn: (Scenario, Row) -> Scenario = { s, _ -> s }): Sequence<Pair<Scenario, ReturnValue<Scenario>>> =
scenarios.asSequence().filter { it.isA2xxScenario() || it.examples.isNotEmpty() || it.isGherkinScenario }.map {
scenarios.asSequence().filter {
it.isA2xxScenario() || it.examples.isNotEmpty() || it.isGherkinScenario
}.map {
it.newBasedOn(suggestions)
}.flatMap { originalScenario ->
val resolverStrategies = if(originalScenario.isA2xxScenario())
Expand Down
3 changes: 2 additions & 1 deletion core/src/main/kotlin/io/specmatic/core/FlagsBased.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.specmatic.core

import io.specmatic.core.pattern.IgnoreUnexpectedKeys
import io.specmatic.core.utilities.Flags
import io.specmatic.core.utilities.Flags.Companion.SCHEMA_EXAMPLE_DEFAULT
import io.specmatic.core.utilities.Flags.Companion.getBooleanValue

Expand Down Expand Up @@ -60,5 +61,5 @@ val DefaultStrategies = FlagsBased (
null,
"",
"",
false
Flags.getBooleanValue(Flags.ALL_PATTERNS_MANDATORY, false)
)
5 changes: 2 additions & 3 deletions core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -500,9 +500,8 @@ data class HttpRequestPattern(
private fun HttpRequest.generateAndUpdateBody(resolver: Resolver, body: Pattern): HttpRequest {
return attempt(breadCrumb = "BODY") {
resolver.withCyclePrevention(body) {cyclePreventedResolver ->
body.generate(cyclePreventedResolver).let { value ->
this.updateBody(value)
}
val generatedValue = body.generate(cyclePreventedResolver)
this.updateBody(generatedValue)
}
}
}
Expand Down
99 changes: 88 additions & 11 deletions core/src/main/kotlin/io/specmatic/core/Resolver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ data class Resolver(
val dictionaryLookupPath: String = "",
val jsonObjectResolver: JSONObjectResolver = JSONObjectResolver(),
val allPatternsAreMandatory: Boolean = false,
val patternsSeenSoFar: Set<String> = setOf()
val patternsSeenSoFar: Set<String> = setOf(),
val lookupPathsSeenSoFar: Set<String> = setOf(),
val cycleMarker: String = "",
) {
constructor(facts: Map<String, Value> = emptyMap(), mockMode: Boolean = false, newPatterns: Map<String, Pattern> = emptyMap()) : this(CheckFacts(facts), mockMode, newPatterns)
constructor() : this(emptyMap(), false)
Expand Down Expand Up @@ -126,11 +128,42 @@ data class Resolver(
return withCyclePrevention(pattern, false, toResult)!!
}

fun <T> withCyclePrevention(pattern: Pattern, key: String, returnNullOnCycle: Boolean = false, toResult: (r: Resolver) -> T) : T? {
if(!allPatternsAreMandatory)
return withCyclePrevention(pattern, returnNullOnCycle, toResult)

val lookupPath = lookupPath(pattern.typeAlias, key)

return try {
if (key.isNotBlank() && lookupPathSeen(lookupPath) && cycleMarker.isEmpty()) {
// Terminate what would otherwise be an infinite cycle.
throw ContractException("Invalid pattern cycle: $lookupPath", isCycle = true)
} else if (key.isNotBlank() && lookupPathSeen(lookupPath) && cycleMarker.isNotEmpty()) {
toResult(this.clearCycleMarker())
} else {
toResult(this)
}
} catch (e: ContractException) {
if (!e.isCycle || !returnNullOnCycle)
throw e

// Returns null if (and only if) a cycle has been detected and returnNullOnCycle=true
null
}
}

private fun clearCycleMarker(): Resolver {
return this.copy(cycleMarker = "")
}

/**
* Returns non-null if no cycle. If there is a cycle then ContractException(cycle=true) is thrown - unless
* returnNullOnCycle=true in which case null is returned. Null is never returned if returnNullOnCycle=false.
*/
fun <T> withCyclePrevention(pattern: Pattern, returnNullOnCycle: Boolean = false, toResult: (r: Resolver) -> T) : T? {
if(allPatternsAreMandatory)
return withCyclePrevention(pattern, "", returnNullOnCycle, toResult)

val count = cyclePreventionStack.filter { it == pattern }.size
val newCyclePreventionStack = cyclePreventionStack.plus(pattern)

Expand Down Expand Up @@ -174,24 +207,58 @@ data class Resolver(
if (factStore.has(lookupKey))
return generate(lookupKey, pattern)

val lookupPath = if(typeAlias.isNullOrBlank()) {
if(lookupKey.isBlank())
val updatedResolver = updateLookupPath(typeAlias, lookupKey)

return updatedResolver.generate(pattern)
}

fun updateLookupPath(typeAlias: String?, lookupKey: String): Resolver {
val lookupPath = lookupPath(typeAlias, lookupKey)

val updatedResolver = if (lookupPath.isNotBlank()) {
val updatedLookupPathsSeenSoFar =
if(lookupKey.isNotBlank())
lookupPathsSeenSoFar.plus(lookupPath)
else
lookupPathsSeenSoFar

this.copy(dictionaryLookupPath = lookupPath, lookupPathsSeenSoFar = updatedLookupPathsSeenSoFar)
} else {
this
}

return updatedResolver
}

private fun lookupPath(typeAlias: String?, lookupKey: String): String {
val lookupPath = if (typeAlias.isNullOrBlank()) {
if (lookupKey.isBlank())
""
else if(lookupKey == "[*]")
else if (lookupKey == "[*]")
"$dictionaryLookupPath$lookupKey"
else
"$dictionaryLookupPath.$lookupKey"
} else {
"${withoutPatternDelimiters(typeAlias)}.$lookupKey"
if (lookupKey.isBlank())
"${withoutPatternDelimiters(typeAlias)}"
else
"${withoutPatternDelimiters(typeAlias)}.$lookupKey"
}
return lookupPath
}

val updatedResolver = if(lookupPath.isNotBlank()) {
this.copy(dictionaryLookupPath = lookupPath)
} else {
this
}
fun lookupPathSeen(lookupPath: String): Boolean {
if(lookupPath.isBlank())
return false

return updatedResolver.generate(pattern)
if(lookupPathsSeenSoFar.contains(lookupPath))
return true

val dotTerminatedPath = "$lookupPath."
if(lookupPathsSeenSoFar.any { it.startsWith(dotTerminatedPath) })
return true

return false
}

fun generateList(pattern: Pattern): Value {
Expand Down Expand Up @@ -318,11 +385,21 @@ ${matchResult.reportString()}
return patternsSeenSoFar.contains(pattern.typeAlias)
}

fun hasSeenLookupPath(pattern: Pattern, key: String): Boolean {
val lookupPath = lookupPath(pattern.typeAlias, key)

return lookupPathSeen(lookupPath)
}

fun addPatternAsSeen(pattern: Pattern): Resolver {
return this.copy(
patternsSeenSoFar = pattern.typeAlias?.let { patternsSeenSoFar.plus(it) } ?: patternsSeenSoFar
)
}

fun cyclePast(jsonPattern: Pattern, key: String): Resolver {
return this.copy(cycleMarker = lookupPath(jsonPattern.typeAlias, key))
}
}

private fun ExactValuePattern.hasPatternToken(): Boolean {
Expand Down
50 changes: 43 additions & 7 deletions core/src/main/kotlin/io/specmatic/core/pattern/AnyPattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ data class AnyPattern(

override fun removeKeysNotPresentIn(keys: Set<String>, resolver: Resolver): Pattern {
if(keys.isEmpty()) return this
if(this.hasNoAmbiguousPatterns().not()) return this

val pattern = this.pattern.first { it !is NullPattern }
if(pattern is PossibleJsonObjectPatternContainer) return pattern.removeKeysNotPresentIn(keys, resolver)
return this
return this.copy(pattern = this.pattern.map {
if (it !is PossibleJsonObjectPatternContainer) return@map it
it.removeKeysNotPresentIn(keys, resolver)
})
}

override fun eliminateOptionalKey(value: Value, resolver: Resolver): Value {
Expand Down Expand Up @@ -155,7 +155,8 @@ data class AnyPattern(
}

override fun generate(resolver: Resolver): Value {
return resolver.resolveExample(example, pattern) ?: generateValue(resolver)
return resolver.resolveExample(example, pattern)
?: generateValue(resolver)
}

override fun newBasedOn(row: Row, resolver: Resolver): Sequence<ReturnValue<Pattern>> {
Expand Down Expand Up @@ -299,9 +300,44 @@ data class AnyPattern(
.generate(resolver)
}

val updatedPatterns = discriminator?.updatePatternsWithDiscriminator(pattern, resolver)?.listFold()?.value ?: pattern
val updatedPatterns =
if(discriminator != null)
discriminator.updatePatternsWithDiscriminator(pattern, resolver).listFold().value
else
pattern

val chosenByDiscriminator = getDiscriminatorBasedPattern(updatedPatterns, discriminatorValue)
if(chosenByDiscriminator != null)
return generate(resolver, chosenByDiscriminator)

data class GenerationResult(val value: Value? = null, val exception: Throwable? = null) {
val isCycle = exception is ContractException && exception.isCycle
}

val generationResults = updatedPatterns.asSequence().map { chosenPattern ->
try {
GenerationResult(value = generate(resolver, chosenPattern))
} catch (e: Throwable) {
GenerationResult(exception = e)
}
}

val successfulGeneration = generationResults.map { it.value }.filterNotNull().firstOrNull()

if(successfulGeneration != null)
return successfulGeneration

val chosenPattern = getDiscriminatorBasedPattern(updatedPatterns, discriminatorValue) ?: updatedPatterns.random()
val cycle = generationResults.filter { it.isCycle }.map { it.exception }.firstOrNull()
if(cycle != null)
throw cycle

throw generationResults.firstOrNull { it.exception != null }?.exception ?: ContractException("Could not generate value")
}

private fun generate(
resolver: Resolver,
chosenPattern: Pattern
): Value {
val isNullable = pattern.any { it is NullPattern }
return resolver.withCyclePrevention(chosenPattern, isNullable) { cyclePreventedResolver ->
when (key) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ import io.specmatic.core.utilities.exceptionCauseMessage

data class HasException<T>(val t: Throwable, val message: String = "", val breadCrumb: String? = null) : ReturnValue<T>, ReturnFailure {
fun toHasFailure(): HasFailure<T> {
val failure: Result.Failure = toFailure()
return HasFailure(failure, message)
}

override fun toFailure(): Result.Failure {
val failure: Result.Failure = Result.Failure(
message = exceptionCauseMessage(t),
breadCrumb = breadCrumb ?: ""
)
return HasFailure(failure, message)
return failure
}

override fun <U> withDefault(default: U, fn: (T) -> U): U {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ data class HasFailure<T>(val failure: Result.Failure, val message: String = "")
return cast()
}

private fun toFailure(): Result.Failure {
override fun toFailure(): Result.Failure {
return Result.Failure(message, failure)
}

Expand Down
59 changes: 48 additions & 11 deletions core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,17 @@ data class JSONObjectPattern(
return JSONArrayValue(valueList)
}

private fun shouldMakePropertyMandatory(pattern: Pattern, resolver: Resolver): Boolean {
if (!resolver.allPatternsAreMandatory) return false

val patternToCheck = when(pattern) {
is ListPattern -> pattern.typeAlias?.let { pattern } ?: pattern.pattern
else -> pattern.typeAlias?.let { pattern } ?: this
}

return !resolver.hasSeenPattern(patternToCheck)
}

override fun matches(sampleData: Value?, resolver: Resolver): Result {
val resolverWithNullType = withNullPattern(resolver)
if (sampleData !is JSONObjectValue)
Expand All @@ -252,9 +263,11 @@ data class JSONObjectPattern(
else
emptyList()

val adjustedPattern = if (resolverWithNullType.allPatternsAreMandatory && !resolverWithNullType.hasSeenPattern(this)) {
pattern.mapKeys { withoutOptionality(it.key) }
} else pattern
val adjustedPattern = pattern.mapKeys {
if (shouldMakePropertyMandatory(it.value, resolver)) {
withoutOptionality(it.key)
} else it.key
}

val keyErrors: List<Result.Failure> =
resolverWithNullType.findKeyErrorList(adjustedPattern, sampleData.jsonObject).map {
Expand All @@ -267,7 +280,8 @@ data class JSONObjectPattern(

val resultsWithDiscriminator: List<ResultWithDiscriminatorStatus> =
mapZip(pattern, sampleData.jsonObject).map { (key, patternValue, sampleValue) ->
val result = updatedResolver.matchesPattern(key, patternValue, sampleValue).breadCrumb(key)
val innerResolver = updatedResolver.addPatternAsSeen(patternValue)
val result = innerResolver.matchesPattern(key, patternValue, sampleValue).breadCrumb(key)

val isDiscrimintor = patternValue.isDiscriminator()

Expand Down Expand Up @@ -313,7 +327,7 @@ data class JSONObjectPattern(
generate(
selectPropertiesWithinMaxAndMin(pattern, minProperties, maxProperties),
withNullPattern(resolver),
typeAlias
this
)
)
}
Expand Down Expand Up @@ -395,19 +409,42 @@ data class JSONObjectPattern(
override val typeName: String = "json object"
}

fun generate(jsonPattern: Map<String, Pattern>, resolver: Resolver, typeAlias: String?): Map<String, Value> {
fun generate(jsonPatternMap: Map<String, Pattern>, resolver: Resolver, jsonPattern: JSONObjectPattern): Map<String, Value> {
val resolverWithNullType = withNullPattern(resolver)

val optionalProps = jsonPattern.keys.filter { isOptional(it) }.map { withoutOptionality(it) }
val optionalProps = jsonPatternMap.keys.filter { isOptional(it) }.map { withoutOptionality(it) }

return jsonPattern
return jsonPatternMap
.mapKeys { entry -> withoutOptionality(entry.key) }
.mapValues { (key, pattern) ->
attempt(breadCrumb = key) {
// Handle cycle (represented by null value) by marking this property as removable
Optional.ofNullable(resolverWithNullType.withCyclePrevention(pattern, optionalProps.contains(key)) {
it.generate(typeAlias, key, pattern)
})
val canBeOmitted = optionalProps.contains(key)

val value = Optional.ofNullable(
resolverWithNullType.withCyclePrevention(
jsonPattern,
key,
canBeOmitted
) {
it.generate(jsonPattern.typeAlias, key, pattern)
})

if (value.isPresent || resolverWithNullType.hasSeenLookupPath(jsonPattern, key))
return@attempt value

val resolverWithCycleMarker = resolverWithNullType.cyclePast(jsonPattern, key)

val valueWithOneCycle = Optional.ofNullable(
resolverWithCycleMarker.withCyclePrevention(
jsonPattern,
key,
canBeOmitted
) {
it.generate(jsonPattern.typeAlias, key, pattern)
})

valueWithOneCycle
}
}
.filterValues { it.isPresent }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ data class ListPattern(
return Result.Failure(message = "List cannot be empty")
}

val updatedResolver = resolverWithEmptyType.addPatternAsSeen(this)
val updatedResolver = resolverWithEmptyType.addPatternAsSeen(this.typeAlias?.let { this } ?: this.pattern)
val failures: List<Result.Failure> = sampleData.list.map {
updatedResolver.matchesPattern(null, pattern, it)
}.mapIndexed { index, result ->
Expand Down
Loading

0 comments on commit 1fe8ff2

Please sign in to comment.