Skip to content

Commit

Permalink
Merge branch 'polymorphic-stackoverflow' into invalid_request_examples
Browse files Browse the repository at this point in the history
  • Loading branch information
joelrosario committed Mar 8, 2023
2 parents c1411ef + 786a4ac commit 540e1d8
Show file tree
Hide file tree
Showing 33 changed files with 1,371 additions and 184 deletions.
109 changes: 51 additions & 58 deletions core/src/main/kotlin/in/specmatic/conversions/OpenApiSpecification.kt
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,13 @@ class OpenApiSpecification(private val openApiFile: String, val openApi: OpenAPI
fun toSpecmaticPattern(
schema: Schema<*>, typeStack: List<String>, patternName: String = "", jsonInFormData: Boolean = false
): Pattern {
val pattern = when (schema) {
val preExistingResult = patterns.get("($patternName)")
val pattern = if (preExistingResult != null && !patternName.isNullOrBlank())
preExistingResult
else if (typeStack.filter { it == patternName }.size > 1) {
DeferredPattern("($patternName)")
}
else when (schema) {
is StringSchema -> when (schema.enum) {
null -> StringPattern(minLength = schema.minLength, maxLength = schema.maxLength)
else -> toEnum(schema, patternName) { enumValue -> StringValue(enumValue.toString()) }
Expand Down Expand Up @@ -568,19 +574,18 @@ class OpenApiSpecification(private val openApiFile: String, val openApi: OpenAPI
toSchemaProperties(schemaToProcess, requiredFields, patternName, typeStack)
}.fold(emptyMap<String, Pattern>()) { acc, entry -> acc.plus(entry) }
val jsonObjectPattern = toJSONObjectPattern(schemaProperties, "(${patternName})")
patterns["(${patternName})"] = jsonObjectPattern
jsonObjectPattern
cacheComponentPattern(patternName, jsonObjectPattern)
} else if (schema.oneOf != null) {
val nonNullableSchemaPatterns = schema.oneOf
.filter { it.nullable != true }
.map { toSpecmaticPattern(it, typeStack, patternName) }

if (nonNullableSchemaPatterns.isEmpty())
throw UnsupportedOperationException("Specmatic supports oneOf for at least one non-nullable ref")
val candidatePatterns = schema.oneOf.filterNot { nullableEmptyObject(it) } .map { componentSchema ->
val (componentName, schemaToProcess) =
if (componentSchema.`$ref` != null) resolveReferenceToSchema(componentSchema.`$ref`)
else patternName to componentSchema
toSpecmaticPattern(schemaToProcess, typeStack.plus(componentName), componentName)
}

val nullable = if(nullableOneOf(schema)) listOf(NullPattern) else emptyList()
val nullable = if(schema.oneOf.any { nullableEmptyObject(it) }) listOf(NullPattern) else emptyList()

AnyPattern(nonNullableSchemaPatterns.plus(nullable))
AnyPattern(candidatePatterns.plus(nullable))
} else if (schema.anyOf != null) {
throw UnsupportedOperationException("Specmatic does not support anyOf")
} else {
Expand All @@ -599,19 +604,13 @@ class OpenApiSpecification(private val openApiFile: String, val openApi: OpenAPI
val component: String = schema.`$ref`

val (componentName, referredSchema) = resolveReferenceToSchema(component)
val cyclicReference =
typeStack.contains(
componentName
) && referredSchema.instanceOf(ObjectSchema::class)
when {
cyclicReference -> DeferredPattern("(${patternName})")
else -> {
val componentType = resolveReference(component, typeStack)
val typeName = "(${componentNameFromReference(component)})"
patterns[typeName] = componentType
DeferredPattern(typeName)
}
val cyclicReference = typeStack.contains(componentName)
if (!cyclicReference) {
val componentPattern = toSpecmaticPattern(referredSchema,
typeStack.plus(componentName), componentName)
cacheComponentPattern(componentName, componentPattern)
}
DeferredPattern("(${componentName})")
}
}
}
Expand All @@ -636,8 +635,22 @@ class OpenApiSpecification(private val openApiFile: String, val openApi: OpenAPI
}
}

private fun nullableOneOf(schema: ComposedSchema): Boolean {
return schema.oneOf.any { it.nullable == true }
private fun <T : Pattern> cacheComponentPattern(componentName: String, pattern: T): T {
if (!componentName.isNullOrBlank() && pattern !is DeferredPattern) {
val typeName = "(${componentName})"
val prev = patterns.get(typeName)
if (pattern != prev) {
if (prev != null) {
logger.debug("Replacing cached component pattern. name=$componentName, prev=$prev, new=$pattern")
}
patterns[typeName] = pattern
}
}
return pattern
}

private fun nullableEmptyObject(schema: Schema<*>): Boolean {
return schema is ObjectSchema && schema.nullable == true
}

private fun toXMLPattern(mediaType: MediaType): Pattern {
Expand Down Expand Up @@ -749,8 +762,10 @@ class OpenApiSpecification(private val openApiFile: String, val openApi: OpenAPI

val nodeName = componentSchema.xml?.name ?: name ?: componentName

if (typeName !in typeStack) this.patterns[typeName] =
toXMLPattern(componentSchema, componentName, typeStack.plus(typeName))
if (typeName !in typeStack) {
val componentPattern = toXMLPattern(componentSchema, componentName, typeStack.plus(typeName))
cacheComponentPattern(componentName, componentPattern)
}

val xmlRefType = XMLTypeData(
nodeName, nodeName, mapOf(
Expand Down Expand Up @@ -793,36 +808,17 @@ class OpenApiSpecification(private val openApiFile: String, val openApi: OpenAPI
val requiredFields = schema.required.orEmpty()
val schemaProperties = toSchemaProperties(schema, requiredFields, patternName, typeStack)
val jsonObjectPattern = toJSONObjectPattern(schemaProperties, "(${patternName})")
patterns["(${patternName})"] = jsonObjectPattern
return jsonObjectPattern
return cacheComponentPattern(patternName, jsonObjectPattern)
}

private fun toSchemaProperties(
schema: Schema<*>, requiredFields: List<String>, patternName: String, typeStack: List<String>
) = schema.properties.orEmpty().map { (propertyName, propertyType) ->
val optional = !requiredFields.contains(propertyName)
if (patternName.isNotEmpty()) {
if (typeStack.contains(patternName) && (propertyType.`$ref`.orEmpty()
.endsWith(patternName) || (propertyType is ArraySchema && propertyType.items?.`$ref`?.endsWith(
patternName
) == true))
) toSpecmaticParamName(
optional, propertyName
) to DeferredPattern("(${patternName})")
else if (schema.discriminator?.propertyName == propertyName){
// Ensure discriminator property exactly matches component and not just any string
propertyName to ExactValuePattern(StringValue(patternName))
}
else {
val specmaticPattern = toSpecmaticPattern(
propertyType, typeStack.plus(patternName), patternName
)
toSpecmaticParamName(optional, propertyName) to specmaticPattern
}
} else {
toSpecmaticParamName(optional, propertyName) to toSpecmaticPattern(
propertyType, typeStack
)
if (schema.discriminator?.propertyName == propertyName)
propertyName to ExactValuePattern(StringValue(patternName))
else {
val optional = !requiredFields.contains(propertyName)
toSpecmaticParamName(optional, propertyName) to toSpecmaticPattern(propertyType, typeStack)
}
}.toMap()

Expand All @@ -832,18 +828,15 @@ class OpenApiSpecification(private val openApiFile: String, val openApi: OpenAPI
null -> NullPattern
else -> ExactValuePattern(toSpecmaticValue(enumValue))
}
}.toList(), typeAlias = patternName).also { if (patternName.isNotEmpty()) patterns["(${patternName})"] = it }
}.toList(), typeAlias = patternName).also {
cacheComponentPattern(patternName, it)
}

private fun toSpecmaticParamName(optional: Boolean, name: String) = when (optional) {
true -> "${name}?"
false -> name
}

private fun resolveReference(component: String, typeStack: List<String>): Pattern {
val (componentName, referredSchema) = resolveReferenceToSchema(component)
return toSpecmaticPattern(referredSchema, typeStack, patternName = componentName)
}

private fun resolveReferenceToSchema(component: String): Pair<String, Schema<Any>> {
val componentName = extractComponentName(component)
val schema = openApi.components.schemas[componentName] ?: ObjectSchema().also { it.properties = emptyMap() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ data class HttpHeadersPattern(
return attempt(breadCrumb = "HEADERS") {
pattern.mapValues { (key, pattern) ->
attempt(breadCrumb = key) {
resolver.generate(key, pattern).toStringLiteral()
resolver.withCyclePrevention(pattern) { it. generate(key, pattern)}.toStringLiteral()
}
}
}.map { (key, value) -> withoutOptionality(key) to value }.toMap()
Expand Down
41 changes: 28 additions & 13 deletions core/src/main/kotlin/in/specmatic/core/HttpRequestPattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -321,9 +321,11 @@ data class HttpRequestPattern(

val body = body
attempt(breadCrumb = "BODY") {
body.generate(resolver).let { value ->
newRequest = newRequest.updateBody(value)
newRequest = newRequest.updateHeader(CONTENT_TYPE, value.httpContentType)
resolver.withCyclePrevention(body) {cyclePreventedResolver ->
body.generate(cyclePreventedResolver).let { value ->
newRequest = newRequest.updateBody(value)
newRequest = newRequest.updateHeader(CONTENT_TYPE, value.httpContentType)
}
}
}

Expand All @@ -332,10 +334,9 @@ data class HttpRequestPattern(
val formFieldsValue = attempt(breadCrumb = "FORM FIELDS") {
formFieldsPattern.mapValues { (key, pattern) ->
attempt(breadCrumb = key) {
resolver.generate(
key,
pattern
).toString()
resolver.withCyclePrevention(pattern) { cyclePreventedResolver ->
cyclePreventedResolver.generate(key, pattern)
}.toString()
}
}
}
Expand Down Expand Up @@ -402,7 +403,9 @@ data class HttpRequestPattern(
val rowWithRequestBodyAsIs = listOf(ExactValuePattern(value))

val requestsFromFlattenedRow: List<Pattern> =
body.newBasedOn(row.flattenRequestBodyIntoRow(), resolver)
resolver.withCyclePrevention(body) { cyclePreventedResolver ->
body.newBasedOn(row.flattenRequestBodyIntoRow(), cyclePreventedResolver)
}

requestsFromFlattenedRow.plus(rowWithRequestBodyAsIs)
} else {
Expand All @@ -411,8 +414,12 @@ data class HttpRequestPattern(
} else {

if(Flags.generativeTestingEnabled()) {
val vanilla = body.newBasedOn(Row(), resolver)
val fromExamples = body.newBasedOn(row, resolver)
val vanilla = resolver.withCyclePrevention(body) { cyclePreventedResolver ->
body.newBasedOn(Row(), cyclePreventedResolver)
}
val fromExamples = resolver.withCyclePrevention(body) { cyclePreventedResolver ->
body.newBasedOn(row, cyclePreventedResolver)
}
val remainingVanilla = vanilla.filterNot { vanillaType ->
fromExamples.any { typeFromExamples ->
vanillaType.encompasses(
Expand All @@ -425,7 +432,9 @@ data class HttpRequestPattern(

fromExamples.plus(remainingVanilla)
} else {
body.newBasedOn(row, resolver)
resolver.withCyclePrevention(body) { cyclePreventedResolver ->
body.newBasedOn(row, cyclePreventedResolver)
}
}
}
}
Expand Down Expand Up @@ -477,7 +486,11 @@ data class HttpRequestPattern(
fun newBasedOn(resolver: Resolver): List<HttpRequestPattern> {
return attempt(breadCrumb = "REQUEST") {
val newURLMatchers = urlMatcher?.newBasedOn(resolver) ?: listOf<URLMatcher?>(null)
val newBodies = attempt(breadCrumb = "BODY") { body.newBasedOn(resolver) }
val newBodies = attempt(breadCrumb = "BODY") {
resolver.withCyclePrevention(body) { cyclePreventedResolver ->
body.newBasedOn(cyclePreventedResolver)
}
}
val newHeadersPattern = headersPattern.newBasedOn(resolver)
val newFormFieldsPatterns = newBasedOn(formFieldsPattern, resolver)
//TODO: Backward Compatibility
Expand Down Expand Up @@ -550,7 +563,9 @@ data class HttpRequestPattern(
listOf(ExactValuePattern(value))
}

val flattenedRequests: List<Pattern> = body.newBasedOn(row.flattenRequestBodyIntoRow(), resolver)
val flattenedRequests: List<Pattern> = resolver.withCyclePrevention(body) { cyclePreventedResolver ->
body.newBasedOn(row.flattenRequestBodyIntoRow(), cyclePreventedResolver)
}

flattenedRequests.plus(originalRequest)

Expand Down
6 changes: 4 additions & 2 deletions core/src/main/kotlin/in/specmatic/core/HttpResponsePattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ data class HttpResponsePattern(val headersPattern: HttpHeadersPattern = HttpHead

fun generateResponse(resolver: Resolver): HttpResponse {
return attempt(breadCrumb = "RESPONSE") {
val value = softCastValueToXML(body.generate(resolver))
val value = softCastValueToXML(resolver.withCyclePrevention(body, body::generate))
val headers = headersPattern.generate(resolver).plus(SPECMATIC_RESULT_HEADER to "success").let { headers ->
when {
!headers.containsKey("Content-Type") -> headers.plus("Content-Type" to value.httpContentType)
Expand Down Expand Up @@ -53,7 +53,9 @@ data class HttpResponsePattern(val headersPattern: HttpHeadersPattern = HttpHead

fun newBasedOn(row: Row, resolver: Resolver): List<HttpResponsePattern> =
attempt(breadCrumb = "RESPONSE") {
body.newBasedOn(row, resolver).flatMap { newBody ->
resolver.withCyclePrevention(body) { cyclePreventedResolver ->
body.newBasedOn(row, cyclePreventedResolver)
}.flatMap { newBody ->
headersPattern.newBasedOn(row, resolver).map { newHeadersPattern ->
HttpResponsePattern(newHeadersPattern, status, newBody)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ data class MultiPartContentPattern(override val name: String, val content: Patte
}

override fun generate(resolver: Resolver): MultiPartFormDataValue =
MultiPartContentValue(name, content.generate(resolver), specifiedContentType = contentType)
MultiPartContentValue(name, resolver.withCyclePrevention(content, content::generate), specifiedContentType = contentType)

override fun matches(value: MultiPartFormDataValue, resolver: Resolver): Result {
if(withoutOptionality(name) != value.name)
Expand Down Expand Up @@ -80,7 +80,7 @@ data class MultiPartFilePattern(override val name: String, val filename: Pattern
}

override fun generate(resolver: Resolver): MultiPartFormDataValue =
MultiPartFileValue(name, filename.generate(resolver).toStringLiteral(), contentType ?: "", contentEncoding)
MultiPartFileValue(name, resolver.withCyclePrevention(filename, filename::generate).toStringLiteral(), contentType ?: "", contentEncoding)

override fun matches(value: MultiPartFormDataValue, resolver: Resolver): Result {
return when {
Expand Down
32 changes: 30 additions & 2 deletions core/src/main/kotlin/in/specmatic/core/Resolver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ data class Resolver(
val context: Map<String, String> = emptyMap(),
val mismatchMessages: MismatchMessages = DefaultMismatchMessages,
val isNegative: Boolean = false,
val generativeTestingEnabled: Boolean = false,
val patternMatchStrategy: (resolver: Resolver, factKey: String?, pattern: Pattern, sampleValue: Value) -> Result = actualMatch,
val parseStrategy: (resolver: Resolver, pattern: Pattern, rowValue: String) -> Value = actualParse
val parseStrategy: (resolver: Resolver, pattern: Pattern, rowValue: String) -> Value = actualParse,
val generativeTestingEnabled: Boolean = false,
val cyclePreventionStack: List<Pattern> = listOf(),
) {
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 @@ -91,6 +92,33 @@ data class Resolver(
else -> throw ContractException("$patternValue is not a type")
}

fun <T> withCyclePrevention(pattern: Pattern, toResult: (r: Resolver) -> T) : T {
return withCyclePrevention(pattern, false, toResult)!!
}

/**
* 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? {
val count = cyclePreventionStack.filter { it == pattern }.size
val newCyclePreventionStack = cyclePreventionStack.plus(pattern)

try {
if (count > 1)
// Terminate what would otherwise be an infinite cycle.
throw ContractException("Invalid pattern cycle: ${newCyclePreventionStack}", isCycle = true)

return toResult(copy(cyclePreventionStack = newCyclePreventionStack))
} catch (e: ContractException) {
if (!e.isCycle || !returnNullOnCycle)
throw e

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

fun generate(factKey: String, pattern: Pattern): Value {
if (!factStore.has(factKey))
return pattern.generate(this)
Expand Down
Loading

0 comments on commit 540e1d8

Please sign in to comment.