From 8c1d078e33deb30063767ea53372f94b8dd579f9 Mon Sep 17 00:00:00 2001 From: Yogesh Ananda Nikam Date: Tue, 22 Oct 2024 16:19:38 +0530 Subject: [PATCH] Add support for discriminator based example generation in examples interactive server --- .../main/kotlin/io/specmatic/core/Feature.kt | 32 +++ .../io/specmatic/core/HttpRequestPattern.kt | 135 +++++++---- .../io/specmatic/core/HttpResponsePattern.kt | 35 +++ .../main/kotlin/io/specmatic/core/Scenario.kt | 16 +- .../server/ExamplesInteractiveServer.kt | 31 ++- .../io/specmatic/core/pattern/AnyPattern.kt | 227 ++++++++++-------- 6 files changed, 324 insertions(+), 152 deletions(-) diff --git a/core/src/main/kotlin/io/specmatic/core/Feature.kt b/core/src/main/kotlin/io/specmatic/core/Feature.kt index 93c8d58ad..bfc20a6ff 100644 --- a/core/src/main/kotlin/io/specmatic/core/Feature.kt +++ b/core/src/main/kotlin/io/specmatic/core/Feature.kt @@ -147,6 +147,38 @@ data class Feature( } } + fun generateRequestResponses(scenario: Scenario): List { + try { + val requests = scenario.generateHttpRequestV2() + val responses = scenario.generateHttpResponseV2(serverState) + + val generatedRequestResponses = if(requests.size > responses.size) { + requests.map { (discriminator, request) -> + val response = if(responses.containsKey(discriminator)) responses.getValue(discriminator) + else responses.values.first() + GeneratedRequestResponse(request, response, discriminator) + } + } else { + responses.map { (discriminator, response) -> + val request = if(requests.containsKey(discriminator)) requests.getValue(discriminator) + else requests.values.first() + GeneratedRequestResponse(request, response, discriminator) + } + } + + return generatedRequestResponses + } finally { + serverState = emptyMap() + } + } + + // Better name + data class GeneratedRequestResponse( + val request: HttpRequest, + val response: HttpResponse, + val requestKind: String + ) + fun stubResponse( httpRequest: HttpRequest, mismatchMessages: MismatchMessages = DefaultMismatchMessages diff --git a/core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt b/core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt index 11740a740..750d47a7f 100644 --- a/core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt @@ -439,70 +439,109 @@ data class HttpRequestPattern( } fun generate(resolver: Resolver): HttpRequest { - var newRequest = HttpRequest() - return attempt(breadCrumb = "REQUEST") { if (method == null) { throw missingParam("HTTP method") } - if (httpPathPattern == null) { - throw missingParam("URL path") - } - newRequest = newRequest.updateMethod(method) - attempt(breadCrumb = "URL") { - newRequest = newRequest.updatePath(httpPathPattern.generate(resolver)) - val queryParams = httpQueryParamPattern.generate(resolver) - for (queryParam in queryParams) { - newRequest = newRequest.updateQueryParam(queryParam.first, queryParam.second) - } - } - val headers = headersPattern.generate(resolver) + HttpRequest() + .updateMethod(method) + .generateAndUpdateURL(resolver) + .generateAndUpdateBody(resolver, body) + .generateAndUpdateHeaders(resolver) + .generateAndUpdateFormFieldsValues(resolver) + .generateAndUpdateSecuritySchemes(resolver) + .generateAndUpdateMultiPartData(resolver) + } + } - val body = body - attempt(breadCrumb = "BODY") { - resolver.withCyclePrevention(body) {cyclePreventedResolver -> - body.generate(cyclePreventedResolver).let { value -> - newRequest = newRequest.updateBody(value) - } - } + fun generateV2(resolver: Resolver): Map { + return attempt(breadCrumb = "REQUEST") { + if (method == null) { + throw missingParam("HTTP method") } + val baseRequest = HttpRequest() + .updateMethod(method) + .generateAndUpdateURL(resolver) + .generateAndUpdateHeaders(resolver) + .generateAndUpdateFormFieldsValues(resolver) + .generateAndUpdateSecuritySchemes(resolver) + .generateAndUpdateMultiPartData(resolver) + + generateDiscriminatorBasedValues(resolver, body).map { (discriminatorKey, generatedBody) -> + discriminatorKey to baseRequest.updateBody(generatedBody) + }.toMap() + } + } - newRequest = newRequest.copy(headers = headers) + private fun HttpRequest.generateAndUpdatePath(resolver: Resolver): HttpRequest { + if (httpPathPattern == null) { + throw missingParam("URL path") + } + return this.updatePath(httpPathPattern.generate(resolver)) + } - val formFieldsValue = attempt(breadCrumb = "FORM FIELDS") { - formFieldsPattern.mapValues { (key, pattern) -> - attempt(breadCrumb = key) { - resolver.withCyclePrevention(pattern) { cyclePreventedResolver -> - cyclePreventedResolver.generate(key, pattern) - }.toString() - } + private fun HttpRequest.generateAndUpdateQueryParam(resolver: Resolver): HttpRequest { + val queryParams = httpQueryParamPattern.generate(resolver) + return queryParams.fold(this) { request, queryParam -> + request.updateQueryParam(queryParam.first, queryParam.second) + } + } + + private fun HttpRequest.generateAndUpdateURL(resolver: Resolver): HttpRequest { + return attempt(breadCrumb = "URL") { + this.generateAndUpdatePath(resolver) + .generateAndUpdateQueryParam(resolver) + } + } + + 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) } } - newRequest = when (formFieldsValue.size) { - 0 -> newRequest - else -> newRequest.copy( - formFields = formFieldsValue, - headers = newRequest.headers.plus(CONTENT_TYPE to "application/x-www-form-urlencoded") - ) - } + } + } - newRequest = securitySchemes.fold(newRequest) { request, securityScheme -> - securityScheme.addTo(request, resolver) - } + private fun HttpRequest.generateAndUpdateHeaders(resolver: Resolver): HttpRequest { + return this.copy(headers = headersPattern.generate(resolver)) + } - val multipartData = attempt(breadCrumb = "MULTIPART DATA") { - multiPartFormDataPattern.mapIndexed { index, multiPartFormDataPattern -> - attempt(breadCrumb = "[$index]") { multiPartFormDataPattern.generate(resolver) } + private fun HttpRequest.generateAndUpdateFormFieldsValues(resolver: Resolver): HttpRequest { + val formFieldsValue = attempt(breadCrumb = "FORM FIELDS") { + formFieldsPattern.mapValues { (key, pattern) -> + attempt(breadCrumb = key) { + resolver.withCyclePrevention(pattern) { cyclePreventedResolver -> + cyclePreventedResolver.generate(key, pattern) + }.toString() } } - when (multipartData.size) { - 0 -> newRequest - else -> newRequest.copy( - multiPartFormData = multipartData, - headers = newRequest.headers.plus(CONTENT_TYPE to "multipart/form-data") - ) + } + if(formFieldsValue.isEmpty()) return this + return this.copy( + formFields = formFieldsValue, + headers = this.headers.plus(CONTENT_TYPE to "application/x-www-form-urlencoded") + ) + } + + private fun HttpRequest.generateAndUpdateSecuritySchemes(resolver: Resolver): HttpRequest { + return securitySchemes.fold(this) { request, securityScheme -> + securityScheme.addTo(request, resolver) + } + } + + private fun HttpRequest.generateAndUpdateMultiPartData(resolver: Resolver): HttpRequest { + val multipartData = attempt(breadCrumb = "MULTIPART DATA") { + multiPartFormDataPattern.mapIndexed { index, multiPartFormDataPattern -> + attempt(breadCrumb = "[$index]") { multiPartFormDataPattern.generate(resolver) } } } + if(multipartData.isEmpty()) return this + return this.copy( + multiPartFormData = multipartData, + headers = this.headers.plus(CONTENT_TYPE to "multipart/form-data") + ) } fun newBasedOn(row: Row, initialResolver: Resolver, status: Int = 0): Sequence> { diff --git a/core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt b/core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt index e83e9db92..9d576a94b 100644 --- a/core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt @@ -1,6 +1,8 @@ package io.specmatic.core import io.specmatic.core.pattern.* +import io.specmatic.core.value.JSONArrayValue +import io.specmatic.core.value.ListValue import io.specmatic.core.value.StringValue import io.specmatic.core.value.Value import io.specmatic.stub.softCastValueToXML @@ -28,6 +30,20 @@ data class HttpResponsePattern( } } + fun generateResponseV2(resolver: Resolver): Map { + return attempt(breadCrumb = "RESPONSE") { + generateDiscriminatorBasedValues(resolver, body).map { (discriminatorKey, value) -> + val generatedBody = softCastValueToXML(value) + val headers = headersPattern.generate(resolver).plus(SPECMATIC_RESULT_HEADER to "success").let { headers -> + if ((headers.containsKey("Content-Type").not() && generatedBody.httpContentType.isBlank().not())) + headers.plus("Content-Type" to generatedBody.httpContentType) + else headers + } + discriminatorKey to HttpResponse(status, headers, generatedBody) + }.toMap() + } + } + fun generateResponseWithAll(resolver: Resolver): HttpResponse { return attempt(breadCrumb = "RESPONSE") { val value = softCastValueToXML(body.generateWithAll(resolver)) @@ -181,6 +197,25 @@ data class HttpResponsePattern( } } +fun generateDiscriminatorBasedValues(resolver: Resolver, pattern: Pattern): Map { + return resolver.withCyclePrevention(pattern) { updatedResolver -> + val resolvedPattern = resolvedHop(pattern, updatedResolver) + + if(resolvedPattern is ListPattern) { + val listValuePattern = resolvedHop(resolvedPattern.pattern, updatedResolver) + if(listValuePattern is AnyPattern && listValuePattern.isDiscriminatorPresent()) { + val values = listValuePattern.generateForEveryDiscriminatorValue(updatedResolver) + return@withCyclePrevention values.mapValues { JSONArrayValue(listOf(it.value)) } + } + } + + if(resolvedPattern !is AnyPattern || resolvedPattern.isDiscriminatorPresent().not()) { + return@withCyclePrevention mapOf("" to resolvedPattern.generate(updatedResolver)) + } + resolvedPattern.generateForEveryDiscriminatorValue(updatedResolver) + } +} + private val valueMismatchMessages = object : MismatchMessages { override fun mismatchMessage(expected: String, actual: String): String { return "Value mismatch: Expected $expected, got value $actual" diff --git a/core/src/main/kotlin/io/specmatic/core/Scenario.kt b/core/src/main/kotlin/io/specmatic/core/Scenario.kt index 521c4778c..6728993b7 100644 --- a/core/src/main/kotlin/io/specmatic/core/Scenario.kt +++ b/core/src/main/kotlin/io/specmatic/core/Scenario.kt @@ -168,6 +168,13 @@ data class Scenario( httpResponsePattern.generateResponse(resolver.copy(factStore = CheckFacts(facts), context = requestContext)) } + fun generateHttpResponseV2(actualFacts: Map, requestContext: Context = NoContext): Map = + scenarioBreadCrumb(this) { + val facts = combineFacts(expectedFacts, actualFacts, resolver) + + httpResponsePattern.generateResponseV2(resolver.copy(factStore = CheckFacts(facts), context = requestContext)) + } + private fun combineFacts( expected: Map, actual: Map, @@ -219,7 +226,14 @@ data class Scenario( } fun generateHttpRequest(flagsBased: FlagsBased = DefaultStrategies): HttpRequest = - scenarioBreadCrumb(this) { httpRequestPattern.generate(flagsBased.update(resolver.copy(factStore = CheckFacts(expectedFacts)))) } + scenarioBreadCrumb(this) { + httpRequestPattern.generate(flagsBased.update(resolver.copy(factStore = CheckFacts(expectedFacts)))) + } + + fun generateHttpRequestV2(flagsBased: FlagsBased = DefaultStrategies): Map = + scenarioBreadCrumb(this) { + httpRequestPattern.generateV2(flagsBased.update(resolver.copy(factStore = CheckFacts(expectedFacts)))) + } fun matches(httpRequest: HttpRequest, httpResponse: HttpResponse, mismatchMessages: MismatchMessages = DefaultMismatchMessages, unexpectedKeyCheck: UnexpectedKeyCheck? = null): Result { val resolver = updatedResolver(mismatchMessages, unexpectedKeyCheck).copy(context = RequestContext(httpRequest)) diff --git a/core/src/main/kotlin/io/specmatic/core/examples/server/ExamplesInteractiveServer.kt b/core/src/main/kotlin/io/specmatic/core/examples/server/ExamplesInteractiveServer.kt index caea2fb26..d790244ab 100644 --- a/core/src/main/kotlin/io/specmatic/core/examples/server/ExamplesInteractiveServer.kt +++ b/core/src/main/kotlin/io/specmatic/core/examples/server/ExamplesInteractiveServer.kt @@ -416,7 +416,7 @@ class ExamplesInteractiveServer( return getExistingExampleFiles(scenario, examples).map { ExamplePathInfo(it.first.absolutePath, false) - }.plus(generateExampleFile(contractFile, feature, scenario)) + }.plus(generateExampleFiles(contractFile, feature, scenario)) } data class ExamplePathInfo(val path: String, val created: Boolean) @@ -442,6 +442,35 @@ class ExamplesInteractiveServer( return ExamplePathInfo(file.absolutePath, true) } + + private fun generateExampleFiles( + contractFile: File, + feature: Feature, + scenario: Scenario, + ): List { + val examplesDir = getExamplesDirPath(contractFile) + if(!examplesDir.exists()) examplesDir.mkdirs() + + val generatedRequestResponses = feature.generateRequestResponses(scenario).map { + it.copy(response = it.response.cleanup()) + } + + return generatedRequestResponses.map { (request, response, kind) -> + val scenarioStub = ScenarioStub(request, response) + val stubJSON = scenarioStub.toJSON() + val uniqueNameForApiOperation = uniqueNameForApiOperation( + scenarioStub.request, + "", + scenarioStub.response.status + ) + if (kind.isNotEmpty()) "_$kind" else "" + + val file = examplesDir.resolve("${uniqueNameForApiOperation}_${exampleFileNamePostFixCounter.incrementAndGet()}.json") + println("Writing to file: ${file.relativeTo(contractFile.canonicalFile.parentFile).path}") + file.writeText(stubJSON.toStringLiteral()) + ExamplePathInfo(file.absolutePath, true) + } + } + fun validateSingleExample(contractFile: File, exampleFile: File): Result { val feature = parseContractFileToFeature(contractFile) return validateSingleExample(feature, exampleFile) 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 e4fa03be8..25cd1465c 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/AnyPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/AnyPattern.kt @@ -13,6 +13,9 @@ data class AnyPattern( private val discriminatorProperty: String? = null, private val discriminatorValues: Set = emptySet() ) : Pattern, HasDefaultExample { + + data class AnyPatternMatch(val pattern: Pattern, val result: Result) + override fun equals(other: Any?): Boolean = other is AnyPattern && other.pattern == this.pattern override fun hashCode(): Int = pattern.hashCode() @@ -23,8 +26,6 @@ data class AnyPattern( return matchingPattern.addTypeAliasesToConcretePattern(concretePattern, resolver, this.typeAlias ?: typeAlias) } - data class AnyPatternMatch(val pattern: Pattern, val result: Result) - override fun fillInTheBlanks(value: Value, resolver: Resolver): ReturnValue { val results = pattern.asSequence().map { it.fillInTheBlanks(value, resolver) @@ -189,78 +190,8 @@ data class AnyPattern( return Result.fromFailures(failuresWithUpdatedBreadcrumbs) } - private fun discriminatorMatchFailure(pattern: Pattern) = AnyPatternMatch( - pattern, - Failure( - "Discriminator match failure", - failureReason = FailureReason.DiscriminatorMismatch - ) - ) - - private fun jsonObjectMismatchError( - resolver: Resolver, - sampleData: Value? - ) = resolver.mismatchMessages.valueMismatchFailure("json object", sampleData) - - private fun discriminatorKeyMissingFailure(discriminatorProperty: String, discriminatorCsv: String) = Failure( - "Discriminator property $discriminatorProperty is missing from the object (it's value should be $discriminatorCsv)", - breadCrumb = discriminatorProperty, - failureReason = FailureReason.DiscriminatorMismatch - ) - - private fun addTypeInfoBreadCrumbs(matchResults: List): List { - if(this.hasNoAmbiguousPatterns()) { - return matchResults.map { it.result as Failure } - } - - val failuresWithUpdatedBreadcrumbs = matchResults.map { - Pair(it.pattern, it.result as Failure) - }.mapIndexed { index, (pattern, failure) -> - val ordinal = index + 1 - - pattern.typeAlias?.let { - if (it.isBlank() || it == "()") - failure.breadCrumb("(~~~object $ordinal)") - else - failure.breadCrumb("(~~~${withoutPatternDelimiters(it)} object)") - } ?: failure - } - return failuresWithUpdatedBreadcrumbs - } - - private fun getResult(failures: List): List = when { - isNullablePattern() -> { - val index = pattern.indexOfFirst { !isEmpty(it) } - listOf(failures[index]) - } - else -> failures - } - - private fun isNullablePattern() = pattern.size == 2 && pattern.any { isEmpty(it) } - - private fun isEmpty(it: Pattern) = it.typeAlias == "(empty)" || it is NullPattern - override fun generate(resolver: Resolver): Value { - return resolver.resolveExample(example, pattern) ?: generateRandomValue(resolver) - } - - private fun generateRandomValue(resolver: Resolver): Value { - if( - pattern.size == 2 && - pattern.any { it is NullPattern} && - pattern.filterNot { it is NullPattern }.filter { it is ScalarType }.size == 1 - ) { - return pattern.filterNot { it is NullPattern }.first { it is ScalarType }.generate(resolver) - } - - val randomPattern = pattern.random() - val isNullable = pattern.any { it is NullPattern } - return resolver.withCyclePrevention(randomPattern, isNullable) { cyclePreventedResolver -> - when (key) { - null -> randomPattern.generate(cyclePreventedResolver) - else -> cyclePreventedResolver.generate(key, randomPattern) - } - } ?: NullValue // Terminates cycle gracefully. Only happens if isNullable=true so that it is contract-valid. + return resolver.resolveExample(example, pattern) ?: generateValue(resolver) } override fun newBasedOn(row: Row, resolver: Resolver): Sequence> { @@ -285,26 +216,6 @@ data class AnyPattern( return newTypesOrExceptionIfNone(patternResults, "Could not generate new tests") } - private fun newTypesOrExceptionIfNone(patternResults: Sequence>?, Throwable?>>, message: String): Sequence> { - val newPatterns: Sequence> = patternResults.mapNotNull { it.first }.flatten() - - if (!newPatterns.any() && pattern.isNotEmpty()) { - val exceptions = patternResults.mapNotNull { it.second }.map { - when (it) { - is ContractException -> it - else -> ContractException(exceptionCause = it) - } - } - - val failures = exceptions.map { it.failure() } - - val failure = Failure.fromFailures(failures.toList()) - - throw ContractException(failure.toFailureReport(message)) - } - return newPatterns - } - override fun newBasedOn(resolver: Resolver): Sequence { val isNullable = pattern.any {it is NullPattern} return pattern.asSequence().flatMap { innerPattern -> @@ -344,13 +255,6 @@ data class AnyPattern( } } - private fun distinctableValueOnlyForScalars(it: Pattern): Any { - if (it is ScalarType || it is ExactValuePattern) - return it - - return randomString(10) - } - override fun parse(value: String, resolver: Resolver): Value { val resolvedTypes = pattern.map { resolvedHop(it, resolver) } val nonNullTypesFirst = resolvedTypes.filterNot { it is NullPattern }.plus(resolvedTypes.filterIsInstance()) @@ -387,8 +291,6 @@ data class AnyPattern( compatibleResult } - private fun allValuesAreScalar() = pattern.all { it is ExactValuePattern && it.pattern is ScalarValue } - override fun listOf(valueList: List, resolver: Resolver): Value { if (pattern.isEmpty()) throw ContractException("AnyPattern doesn't have any types, so can't infer which type of list to wrap the given value in") @@ -411,9 +313,130 @@ data class AnyPattern( return this } + fun isDiscriminatorPresent() = discriminatorProperty != null && discriminatorValues.isNotEmpty() + + fun generateForEveryDiscriminatorValue(resolver: Resolver): Map { + return discriminatorValues.associateWith { discriminatorValue -> + generateValue(resolver, discriminatorValue) + } + } + + private fun generateValue(resolver: Resolver, discriminatorValue: String = ""): Value { + if (this.isScalarBasedPattern()) { + return this.pattern.filterNot { it is NullPattern }.first { it is ScalarType } + .generate(resolver) + } + + val chosenPattern = getDiscriminatorBasedPattern(discriminatorValue) ?: pattern.random() + val isNullable = pattern.any { it is NullPattern } + return resolver.withCyclePrevention(chosenPattern, isNullable) { cyclePreventedResolver -> + when (key) { + null -> chosenPattern.generate(cyclePreventedResolver) + else -> cyclePreventedResolver.generate(key, chosenPattern) + } + } ?: NullValue // Terminates cycle gracefully. Only happens if isNullable=true so that it is contract-valid. + } + + private fun isScalarBasedPattern(): Boolean { + return pattern.size == 2 && + pattern.any { it is NullPattern} && + pattern.filterNot { it is NullPattern }.filter { it is ScalarType }.size == 1 + } + + private fun getDiscriminatorBasedPattern(discriminatorValue: String): JSONObjectPattern? { + return pattern.filterIsInstance().firstOrNull { + if(it.pattern.containsKey(discriminatorProperty).not()) { + return@firstOrNull false + } + val discriminatorPattern = it.pattern[discriminatorProperty] + if(discriminatorPattern !is ExactValuePattern) return@firstOrNull false + discriminatorPattern.pattern.toStringLiteral() == discriminatorValue + } + } + + private fun newTypesOrExceptionIfNone(patternResults: Sequence>?, Throwable?>>, message: String): Sequence> { + val newPatterns: Sequence> = patternResults.mapNotNull { it.first }.flatten() + + if (!newPatterns.any() && pattern.isNotEmpty()) { + val exceptions = patternResults.mapNotNull { it.second }.map { + when (it) { + is ContractException -> it + else -> ContractException(exceptionCause = it) + } + } + + val failures = exceptions.map { it.failure() } + + val failure = Failure.fromFailures(failures.toList()) + + throw ContractException(failure.toFailureReport(message)) + } + return newPatterns + } + + private fun distinctableValueOnlyForScalars(it: Pattern): Any { + if (it is ScalarType || it is ExactValuePattern) + return it + + return randomString(10) + } + + private fun allValuesAreScalar() = pattern.all { it is ExactValuePattern && it.pattern is ScalarValue } + private fun hasNoAmbiguousPatterns(): Boolean { return this.pattern.count { it !is NullPattern } == 1 } + + private fun discriminatorMatchFailure(pattern: Pattern) = AnyPatternMatch( + pattern, + Failure( + "Discriminator match failure", + failureReason = FailureReason.DiscriminatorMismatch + ) + ) + + private fun jsonObjectMismatchError( + resolver: Resolver, + sampleData: Value? + ) = resolver.mismatchMessages.valueMismatchFailure("json object", sampleData) + + private fun discriminatorKeyMissingFailure(discriminatorProperty: String, discriminatorCsv: String) = Failure( + "Discriminator property $discriminatorProperty is missing from the object (it's value should be $discriminatorCsv)", + breadCrumb = discriminatorProperty, + failureReason = FailureReason.DiscriminatorMismatch + ) + + private fun addTypeInfoBreadCrumbs(matchResults: List): List { + if(this.hasNoAmbiguousPatterns()) { + return matchResults.map { it.result as Failure } + } + + val failuresWithUpdatedBreadcrumbs = matchResults.map { + Pair(it.pattern, it.result as Failure) + }.mapIndexed { index, (pattern, failure) -> + val ordinal = index + 1 + + pattern.typeAlias?.let { + if (it.isBlank() || it == "()") + failure.breadCrumb("(~~~object $ordinal)") + else + failure.breadCrumb("(~~~${withoutPatternDelimiters(it)} object)") + } ?: failure + } + return failuresWithUpdatedBreadcrumbs + } + + private fun getResult(failures: List): List = when { + isNullablePattern() -> { + val index = pattern.indexOfFirst { !isEmpty(it) } + listOf(failures[index]) + } + else -> failures + } + + private fun isNullablePattern() = pattern.size == 2 && pattern.any { isEmpty(it) } + + private fun isEmpty(it: Pattern) = it.typeAlias == "(empty)" || it is NullPattern } private fun failedToFindAny(expected: String, actual: Value?, results: List, mismatchMessages: MismatchMessages): Failure =