Skip to content

Commit

Permalink
Merge pull request #1403 from znsio/extra-fields-in-example
Browse files Browse the repository at this point in the history
Generate additional extended example when extensible schema is enabled
  • Loading branch information
joelrosario authored Oct 31, 2024
2 parents 7ac3c80 + 4afd1a6 commit 434d822
Show file tree
Hide file tree
Showing 8 changed files with 175 additions and 72 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ paths:
println(output)

assertThat(returnValue).isNotEqualTo(0)
assertThat(output).contains("No matching REST stub or contract found")
assertThat(output).contains("No matching found for this example")
}

@Test
Expand Down
18 changes: 18 additions & 0 deletions core/src/main/kotlin/io/specmatic/core/Feature.kt
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,24 @@ data class Feature(
} != null
}

fun matchResultFlagBased(scenarioStub: ScenarioStub, mismatchMessages: MismatchMessages): Results {
val (request, response) = scenarioStub

val results = scenarios.map {
it.matches(request, response, mismatchMessages, flagsBased)
}

if(results.any { it.isSuccess() })
return Results(results).withoutFluff()

val deepErrors = results.filterNot { it.isFluffy(0) }

if(deepErrors.isNotEmpty())
return Results(deepErrors)

return Results(listOf(Result.Failure("No matching found for this example")))
}

fun matchResult(request: HttpRequest, response: HttpResponse): Result {
if(scenarios.isEmpty())
return Result.Failure("No operations found")
Expand Down
2 changes: 2 additions & 0 deletions core/src/main/kotlin/io/specmatic/core/HttpResponse.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ data class HttpResponse(
return copy(body = content, headers = headers.minus(CONTENT_TYPE).plus(CONTENT_TYPE to content.httpContentType))
}

fun updateBody(body: Value): HttpResponse = copy(body = body)

fun toJSON(): JSONObjectValue =
JSONObjectValue(mutableMapOf<String, Value>().also { json ->
json["status"] = NumberValue(status)
Expand Down
30 changes: 29 additions & 1 deletion core/src/main/kotlin/io/specmatic/core/Scenario.kt
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ data class Scenario(
mismatchMessages: MismatchMessages = DefaultMismatchMessages,
unexpectedKeyCheck: UnexpectedKeyCheck? = null
): Result {
val resolver = Resolver(serverState, false, patterns).copy(mismatchMessages = mismatchMessages).let {
val resolver = resolver.copy(mismatchMessages = mismatchMessages).let {
if(unexpectedKeyCheck != null) {
val keyCheck = it.findKeyErrorCheck
it.copy(findKeyErrorCheck = keyCheck.copy(unexpectedKeyCheck = unexpectedKeyCheck))
Expand Down Expand Up @@ -266,6 +266,26 @@ data class Scenario(
return matches(httpResponse, mismatchMessages, unexpectedKeyCheck, resolver)
}

fun matches(httpRequest: HttpRequest, httpResponse: HttpResponse, mismatchMessages: MismatchMessages, flagsBased: FlagsBased): Result {
if (httpResponsePattern.status == DEFAULT_RESPONSE_CODE) {
return Result.Failure(
breadCrumb = "STATUS",
failureReason = FailureReason.StatusMismatch
).updateScenario(this)
}

val resolver = flagsBased.update(resolver.copy(mismatchMessages = mismatchMessages))

val responseMatch = matches(httpResponse, mismatchMessages, resolver.findKeyErrorCheck.unexpectedKeyCheck, resolver)

if(responseMatch is Result.Failure && responseMatch.hasReason(FailureReason.StatusMismatch))
return responseMatch.updateScenario(this)

val requestMatch = matches(httpRequest, mismatchMessages, resolver.findKeyErrorCheck.unexpectedKeyCheck, resolver)

return Result.fromResults(listOf(requestMatch, responseMatch)).updateScenario(this)
}

fun matches(httpResponse: HttpResponse, mismatchMessages: MismatchMessages = DefaultMismatchMessages, unexpectedKeyCheck: UnexpectedKeyCheck? = null): Result {
val resolver = updatedResolver(mismatchMessages, unexpectedKeyCheck)

Expand Down Expand Up @@ -304,6 +324,14 @@ data class Scenario(
}
}

fun matches(httpRequest: HttpRequest, mismatchMessages: MismatchMessages = DefaultMismatchMessages, unexpectedKeyCheck: UnexpectedKeyCheck? = null, resolver: Resolver): Result {
return try {
httpRequestPattern.matches(httpRequest, resolver).updateScenario(this)
} catch (exception: Throwable) {
Result.Failure("Exception: ${exception.message}")
}
}

private fun is4xxResponse(httpResponse: HttpResponse) = (400..499).contains(httpResponse.status)

object ContractAndRowValueMismatch : MismatchMessages {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,18 @@ import io.specmatic.core.filters.ScenarioMetadataFilter.Companion.filterUsing
import io.specmatic.core.log.logger
import io.specmatic.core.pattern.ContractException
import io.specmatic.core.route.modules.HealthCheckModule.Companion.configureHealthCheckModule
import io.specmatic.core.utilities.*
import io.specmatic.core.utilities.Flags.Companion.EXTENSIBLE_SCHEMA
import io.specmatic.core.utilities.Flags.Companion.getBooleanValue
import io.specmatic.core.utilities.capitalizeFirstChar
import io.specmatic.core.utilities.exceptionCauseMessage
import io.specmatic.core.utilities.uniqueNameForApiOperation
import io.specmatic.core.value.*
import io.specmatic.mock.MOCK_HTTP_REQUEST
import io.specmatic.core.value.JSONArrayValue
import io.specmatic.core.value.JSONObjectValue
import io.specmatic.core.value.Value
import io.specmatic.mock.NoMatchingScenario
import io.specmatic.mock.MOCK_HTTP_RESPONSE
import io.specmatic.mock.ScenarioStub
import io.specmatic.stub.HttpStub
import io.specmatic.stub.HttpStubData
import io.specmatic.test.ContractTest
import io.specmatic.test.TestInteractionsLog
import io.specmatic.test.TestInteractionsLog.combineLog
Expand Down Expand Up @@ -330,6 +334,15 @@ class ExamplesInteractiveServer(

companion object {
private val exampleFileNamePostFixCounter = AtomicInteger(0)

private val extendedExampleDescription = mapOf("description" to "Example with extra fields for extended schema").toValueMap()
private val extendedFieldsMap = mapOf("SAMPLE_EXTRA_FIELD_FOR_EXTENDED_SCHEMA" to "SAMPLE_VALUE").toValueMap()

private val extendedKeys = extendedFieldsMap.keys.joinToString(", ")
private val extendedPayloadDescription = mapOf(
"description" to "This is an example of a payload that includes additional fields for an extended schema, as indicated by the following keys: $extendedKeys.",
).toValueMap()

enum class ExampleGenerationStatus {
CREATED, EXISTED, ERROR
}
Expand Down Expand Up @@ -378,7 +391,7 @@ class ExamplesInteractiveServer(

return feature.scenarios.flatMap { scenario ->
try {
val examples = getExistingExampleFiles(scenario, examplesDir.getExamplesFromDir())
val examples = getExistingExampleFiles(feature, scenario, examplesDir.getExamplesFromDir())
.map { ExamplePathInfo(it.first.file.absolutePath, false) }
.ifEmpty { listOf(generateExampleFile(contractFile, feature, scenario)) }

Expand Down Expand Up @@ -439,15 +452,20 @@ class ExamplesInteractiveServer(
val examplesDir = getExamplesDirPath(contractFile)
val examples = examplesDir.getExamplesFromDir()

val existingExamples = getExistingExampleFiles(scenario, examples)
val existingExamples = getExistingExampleFiles(feature, scenario, examples)

val extendedExampleExists = existingExamples.any { it.first.isExtendedExample() }
val newExamples = generateExampleFiles(
contractFile, feature, scenario, allowOnlyMandatoryKeysInJSONObject,
existingExamples = if (bulkMode) existingExamples.map { it.first } else emptyList()
)

return existingExamples.map {
ExamplePathInfo(it.first.file.absolutePath, false)
}.plus(newExamples)
val extendedExampleOrEmpty = if (extendedExampleExists)
emptyList()
else listOfNotNull(generateExtendedExample(contractFile, File(newExamples.random().path), examplesDir))

return existingExamples.map { ExamplePathInfo(it.first.file.absolutePath, false) }
.plus(newExamples).plus(extendedExampleOrEmpty)
}

data class ExamplePathInfo(val path: String, val created: Boolean)
Expand Down Expand Up @@ -546,13 +564,7 @@ class ExamplesInteractiveServer(

fun validateSingleExample(feature: Feature, exampleFile: File): Result {
val scenarioStub = ScenarioStub.readFromFile(exampleFile)

return try {
validateExample(feature, scenarioStub)
Result.Success()
} catch(e: NoMatchingScenario) {
e.results.toResultIfAny()
}
return validateExample(feature, scenarioStub).toResultIfAny()
}

fun validateExamples(contractFile: File, examples: Map<String, List<ScenarioStub>> = emptyMap(), scenarioFilter: ScenarioFilter = ScenarioFilter()): Map<String, Result> {
Expand All @@ -573,15 +585,11 @@ class ExamplesInteractiveServer(
if(enableLogging) logger.log("Validating $name")

exampleList.mapNotNull { example ->
try {
validateExample(updatedFeature, example)
Result.Success()
} catch (e: NoMatchingScenario) {
if (inline && !e.results.withoutFluff().hasResults())
null
else
e.results.toResultIfAny()
}
val results = validateExample(updatedFeature, example)
if (inline && !results.hasResults()) return@mapNotNull null
if (!results.hasResults()) return@mapNotNull Result.Failure(results.report(example.request))

results.toResultIfAny()
}.let {
Result.fromResults(it)
}
Expand All @@ -590,50 +598,21 @@ class ExamplesInteractiveServer(
return results
}

private fun getCleanedUpFailure(
failureResults: Results,
noMatchingScenario: NoMatchingScenario?
): Results {
return failureResults.toResultIfAny().let {
if (it.reportString().isBlank())
Results(listOf(Result.Failure(noMatchingScenario?.message ?: "", failureReason = FailureReason.ScenarioMismatch)))
else
failureResults
}
}

private fun validateExample(
feature: Feature,
scenarioStub: ScenarioStub
) {
val result: Pair<Pair<Result.Success, List<HttpStubData>>?, NoMatchingScenario?> =
HttpStub.setExpectation(scenarioStub, feature, InteractiveExamplesMismatchMessages)
val validationResult = result.first
val noMatchingScenario = result.second

if (validationResult == null) {
val failures = noMatchingScenario?.results?.withoutFluff()?.results ?: emptyList()

val failureResults = Results(failures).withoutFluff().let {
getCleanedUpFailure(it, noMatchingScenario)
}
throw NoMatchingScenario(
failureResults,
cachedMessage = failureResults.report(scenarioStub.request),
msg = failureResults.report(scenarioStub.request)
)
}
): Results {
return feature.matchResultFlagBased(scenarioStub, InteractiveExamplesMismatchMessages)
}

private fun HttpResponse.cleanup(): HttpResponse {
return this.copy(headers = this.headers.minus(SPECMATIC_RESULT_HEADER))
}

fun getExistingExampleFiles(scenario: Scenario, examples: List<ExampleFromFile>): List<Pair<ExampleFromFile, String>> {
fun getExistingExampleFiles(feature: Feature, scenario: Scenario, examples: List<ExampleFromFile>): List<Pair<ExampleFromFile, String>> {
return examples.mapNotNull { example ->
val response = example.response

when (val matchResult = scenario.matchesMock(example.request, response)) {
when (val matchResult = scenario.matches(example.request, example.response, InteractiveExamplesMismatchMessages, feature.flagsBased)) {
is Result.Success -> example to ""
is Result.Failure -> {
val isFailureRelatedToScenario = matchResult.getFailureBreadCrumbs("").none { breadCrumb ->
Expand Down Expand Up @@ -673,6 +652,78 @@ class ExamplesInteractiveServer(
scenarioStub.response.status
)
}

private fun ExampleFromFile.isExtendedExample(): Boolean {
val requestBodyContainsFields = this.requestBody?.containsExtendedFields()
val responseBodyContainsFields = this.responseBody?.containsExtendedFields()

return requestBodyContainsFields == true || responseBodyContainsFields == true
}

private fun generateExtendedExample(contractFile: File, generateExampleFile: File, exampleDir: File): ExamplePathInfo? {
if (!getBooleanValue(EXTENSIBLE_SCHEMA)) return null

val scenarioStub = ScenarioStub.readFromFile(generateExampleFile)
if (scenarioStub.request.body.isScalarOrEmpty() && scenarioStub.response.body.isScalarOrEmpty()) return null

val requestJSON = scenarioStub.request.insertIfBodyNotScalar(extendedFieldsMap) { request ->
request.toJSON().insertFieldsInValue(extendedPayloadDescription) as JSONObjectValue
} ?: scenarioStub.request.toJSON()

val responseJSON = scenarioStub.response.insertIfBodyNotScalar(extendedFieldsMap) { response ->
response.toJSON().insertFieldsInValue(extendedPayloadDescription) as JSONObjectValue
} ?: scenarioStub.response.toJSON()

val extendedExampleJson = JSONObjectValue(extendedExampleDescription.plus(
mapOf(
MOCK_HTTP_REQUEST to requestJSON,
MOCK_HTTP_RESPONSE to responseJSON,
)
))

val file = exampleDir.resolve(generateExampleFile.nameWithoutExtension + "_extended.json")
println("Writing to file: ${file.relativeTo(contractFile.canonicalFile.parentFile).path}")
file.writeText(extendedExampleJson.toStringLiteral())
return ExamplePathInfo(file.absolutePath, true)
}

private fun HttpRequest.insertIfBodyNotScalar(extendedFieldsMap: Map<String, Value>, block: (request: HttpRequest) -> JSONObjectValue) : JSONObjectValue? {
if (this.body.isScalarOrEmpty()) return null

val updatedRequest = this.updateBody(this.body.insertFieldsInValue(extendedFieldsMap))
return block(updatedRequest)
}

private fun HttpResponse.insertIfBodyNotScalar(extendedFieldsMap: Map<String, Value>, block: (response: HttpResponse) -> JSONObjectValue ): JSONObjectValue? {
if (this.body.isScalarOrEmpty()) return null

val updatedResponse = this.updateBody(this.body.insertFieldsInValue(extendedFieldsMap))
return block(updatedResponse)
}

private fun Value.containsExtendedFields(): Boolean {
return when(this) {
is JSONObjectValue -> extendedFieldsMap.any { it.key in this.jsonObject.keys }
is JSONArrayValue -> this.list.firstOrNull()?.containsExtendedFields() ?: false
else -> false
}
}

private fun Value.isScalarOrEmpty(): Boolean {
return this is ScalarValue || this is NoBodyValue
}

private fun Value.insertFieldsInValue(extendedFieldsMap: Map<String, Value>): Value {
return when (this) {
is JSONObjectValue -> JSONObjectValue(extendedFieldsMap.plus(this.jsonObject))
is JSONArrayValue -> JSONArrayValue(this.list.map {value -> value.insertFieldsInValue(extendedFieldsMap) })
else -> this
}
}

private fun Map<String, String>.toValueMap(): Map<String, Value> {
return this.mapValues { StringValue(it.value) }
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class ExamplesView {
companion object {
fun getEndpoints(feature: Feature, examplesDir: File): List<Endpoint> {
val examples = examplesDir.getExamplesFromDir()
val scenarioExamplesPairList = getScenarioExamplesPairs(feature.scenarios, examples)
val scenarioExamplesPairList = getScenarioExamplesPairs(feature, examples)

return scenarioExamplesPairList.map { (scenario, example) ->
Endpoint(
Expand All @@ -39,17 +39,17 @@ class ExamplesView {

private fun Pattern.isDiscriminatorBased(resolver: Resolver): Boolean {
return when (val resolvedPattern = resolvedHop(this, resolver)) {
is AnyPattern -> resolvedPattern.isDiscriminatorPresent()
is AnyPattern -> resolvedPattern.isDiscriminatorPresent() && resolvedPattern.hasMultipleDiscriminatorValues()
is ListPattern -> resolvedPattern.pattern.isDiscriminatorBased(resolver)
else -> false
}
}

private fun getScenarioExamplesPairs(scenarios: List<Scenario>,examples: List<ExampleFromFile>): List<Pair<Scenario, Pair<File, String>?>> {
return scenarios.flatMap {
getExistingExampleFiles(it, examples).map { exRes ->
it to (exRes.first.file to exRes.second)
}.ifEmpty { listOf(it to null) }
private fun getScenarioExamplesPairs(feature: Feature, examples: List<ExampleFromFile>): List<Pair<Scenario, Pair<File, String>?>> {
return feature.scenarios.flatMap { scenario ->
getExistingExampleFiles(feature, scenario, examples).map { exRes ->
scenario to Pair(exRes.first.file, exRes.second)
}.ifEmpty { listOf(scenario to null) }
}
}

Expand Down
2 changes: 2 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 @@ -317,6 +317,8 @@ data class AnyPattern(

fun isDiscriminatorPresent() = discriminatorProperty != null && discriminatorValues.isNotEmpty()

fun hasMultipleDiscriminatorValues() = isDiscriminatorPresent() && discriminatorValues.size > 1

fun generateForEveryDiscriminatorValue(resolver: Resolver): List<DiscriminatorBasedItem<Value>> {
return discriminatorValues.map { discriminatorValue ->
DiscriminatorBasedItem(
Expand Down
Loading

0 comments on commit 434d822

Please sign in to comment.