Skip to content

Commit

Permalink
Merge pull request #1444 from znsio/req-proc-res-val
Browse files Browse the repository at this point in the history
Request pre-processing, response value assertions, and stateful contract testing.
  • Loading branch information
joelrosario authored Nov 22, 2024
2 parents 646cfe5 + 2656b88 commit a18f874
Show file tree
Hide file tree
Showing 22 changed files with 669 additions and 94 deletions.
78 changes: 40 additions & 38 deletions core/src/main/kotlin/io/specmatic/conversions/ExampleFromFile.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,47 +10,40 @@ import io.specmatic.core.value.EmptyString
import io.specmatic.core.value.JSONObjectValue
import io.specmatic.core.value.Value
import io.specmatic.mock.mockFromJSON
import io.specmatic.test.ExampleProcessor
import java.io.File
import java.net.URI

class ExampleFromFile(val json: JSONObjectValue, val file: File) {
fun toRow(specmaticConfig: SpecmaticConfig = SpecmaticConfig()): Row {
logger.log("Loading test file ${this.expectationFilePath}")

val examples: Map<String, String> =
headers
.plus(queryParams)
.plus(requestBody?.let { mapOf("(REQUEST-BODY)" to it.toStringLiteral()) } ?: emptyMap())
val examples: Map<String, String> = headers
.plus(queryParams)
.plus(requestBody?.let { mapOf("(REQUEST-BODY)" to it.toStringLiteral()) } ?: emptyMap())

val (
columnNames,
values
) = examples.entries.let { entry ->
val (columnNames, values) = examples.entries.let { entry ->
entry.map { it.key } to entry.map { it.value }
}

val responseExample: ResponseExample? = response.let { httpResponse ->
when {
specmaticConfig.isResponseValueValidationEnabled() ->
ResponseValueExample(httpResponse)

else ->
null
specmaticConfig.isResponseValueValidationEnabled() -> ResponseValueExample(httpResponse)
else -> null
}

}

val requestExample = mockFromJSON(json.jsonObject).getRequestWithAdditionalParamsIfAny(request, specmaticConfig.additionalExampleParamsFilePath)
val scenarioStub = mockFromJSON(json.jsonObject)

return Row(
columnNames,
values,
name = testName,
fileSource = this.file.canonicalPath,
responseExampleForValidation = responseExample,
requestExample = requestExample,
responseExample = response
)
requestExample = scenarioStub.getRequestWithAdditionalParamsIfAny(specmaticConfig.additionalExampleParamsFilePath),
responseExample = response.takeUnless { this.isPartial() },
isPartial = scenarioStub.partial != null
).let { ExampleProcessor.resolveLookupIfPresent(it) }
}

constructor(file: File) : this(
Expand All @@ -60,6 +53,15 @@ class ExampleFromFile(val json: JSONObjectValue, val file: File) {
file = file
)

private fun JSONObjectValue.findByPath(path: String): Value? {
return findFirstChildByPath("partial.$path") ?: findFirstChildByPath(path)
}

private fun isPartial(): Boolean {
// TODO: Review
return json.findByPath("partial") != null
}

val expectationFilePath: String = file.canonicalPath

val response: HttpResponse
Expand Down Expand Up @@ -88,67 +90,67 @@ class ExampleFromFile(val json: JSONObjectValue, val file: File) {
)
}

val responseBody: Value? = attempt("Error reading response body in file ${file.parentFile.canonicalPath}") {
json.findFirstChildByPath("http-response.body")
val responseBody: Value? = attempt("Error reading response body in file ${file.canonicalPath}") {
json.findByPath("http-response.body")
}

val responseHeaders: JSONObjectValue? = attempt("Error reading response headers in file ${file.parentFile.canonicalPath}") {
val headers = json.findFirstChildByPath("http-response.headers") ?: return@attempt null
val responseHeaders: JSONObjectValue? = attempt("Error reading response headers in file ${file.canonicalPath}") {
val headers = json.findByPath("http-response.headers") ?: return@attempt null

if(headers !is JSONObjectValue)
throw ContractException("http-response.headers should be a JSON object, but instead it was ${headers.toStringLiteral()}")

headers
}

val responseStatus: Int = attempt("Error reading status in file ${file.parentFile.canonicalPath}") {
json.findFirstChildByPath("http-response.status")?.toStringLiteral()?.toInt()
val responseStatus: Int = attempt("Error reading status in file ${file.canonicalPath}") {
json.findByPath("http-response.status")?.toStringLiteral()?.toInt()
} ?: throw ContractException("Response status code was not found.")

val requestMethod: String = attempt("Error reading method in file ${file.parentFile.canonicalPath}") {
json.findFirstChildByPath("http-request.method")?.toStringLiteral()
val requestMethod: String = attempt("Error reading method in file ${file.canonicalPath}") {
json.findByPath("http-request.method")?.toStringLiteral()
} ?: throw ContractException("Request method was not found.")

private val rawPath: String? =
json.findFirstChildByPath("http-request.path")?.toStringLiteral()
json.findByPath("http-request.path")?.toStringLiteral()

val requestPath: String = attempt("Error reading path in file ${file.parentFile.canonicalPath}") {
val requestPath: String = attempt("Error reading path in file ${file.canonicalPath}") {
rawPath?.let { pathOnly(it) }
} ?: throw ContractException("Request path was not found.")

private fun pathOnly(requestPath: String): String {
return URI(requestPath).path ?: ""
}

private val testName: String = attempt("Error reading expectation name in file ${file.parentFile.canonicalPath}") {
json.findFirstChildByPath("name")?.toStringLiteral() ?: file.nameWithoutExtension
private val testName: String = attempt("Error reading expectation name in file ${file.canonicalPath}") {
json.findByPath("name")?.toStringLiteral() ?: file.nameWithoutExtension
}

val queryParams: Map<String, String>
get() {
val path = attempt("Error reading path in file ${file.parentFile.canonicalPath}") {
val path = attempt("Error reading path in file ${file.canonicalPath}") {
rawPath ?: throw ContractException("Request path was not found.")
}

val uri = URI.create(path)
val queryParamsFromURL = parseQuery(uri.query)

val queryParamsFromJSONBlock = attempt("Error reading query params in file ${file.parentFile.canonicalPath}") {
(json.findFirstChildByPath("http-request.query") as JSONObjectValue?)?.jsonObject?.mapValues { (_, value) ->
val queryParamsFromJSONBlock = attempt("Error reading query params in file ${file.canonicalPath}") {
(json.findByPath("http-request.query") as JSONObjectValue?)?.jsonObject?.mapValues { (_, value) ->
value.toStringLiteral()
} ?: emptyMap()
}

return queryParamsFromURL + queryParamsFromJSONBlock
}

val headers: Map<String, String> = attempt("Error reading headers in file ${file.parentFile.canonicalPath}") {
(json.findFirstChildByPath("http-request.headers") as JSONObjectValue?)?.jsonObject?.mapValues { (_, value) ->
val headers: Map<String, String> = attempt("Error reading headers in file ${file.canonicalPath}") {
(json.findByPath("http-request.headers") as JSONObjectValue?)?.jsonObject?.mapValues { (_, value) ->
value.toStringLiteral()
} ?: emptyMap()
}

val requestBody: Value? = attempt("Error reading request body in file ${file.parentFile.canonicalPath}") {
json.findFirstChildByPath("http-request.body")
val requestBody: Value? = attempt("Error reading request body in file ${file.canonicalPath}") {
json.findByPath("http-request.body")
}
}
1 change: 1 addition & 0 deletions core/src/main/kotlin/io/specmatic/core/Feature.kt
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,7 @@ data class Feature(
concreteTestScenario.specification,
concreteTestScenario.serviceType,
comment,
validators = listOf(ExamplePostValidator),
workflow = workflow,
originalScenario = originalScenario
)
Expand Down
9 changes: 6 additions & 3 deletions core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import io.specmatic.core.discriminator.DiscriminatorBasedValueGenerator
import io.specmatic.core.discriminator.DiscriminatorMetadata
import io.specmatic.core.utilities.Flags
import io.specmatic.core.utilities.Flags.Companion.EXTENSIBLE_QUERY_PARAMS
import io.specmatic.core.value.JSONArrayValue
import io.specmatic.core.value.JSONObjectValue

private const val MULTIPART_FORMDATA_BREADCRUMB = "MULTIPART-FORMDATA"
Expand Down Expand Up @@ -201,11 +202,13 @@ data class HttpRequestPattern(
val (httpRequest, resolver, failures) = parameters

val result = try {
// TODO: Review Body String Logic
val bodyValue =
if (isPatternToken(httpRequest.bodyString))
StringValue(httpRequest.bodyString)
else
body.parse(httpRequest.bodyString, resolver)
else if (httpRequest.body is JSONObjectValue || httpRequest.body is JSONArrayValue) {
httpRequest.body
} else body.parse(httpRequest.bodyString, resolver)

resolver.matchesPattern(null, body, bodyValue).breadCrumb("BODY")
} catch (e: ContractException) {
Expand Down Expand Up @@ -612,7 +615,7 @@ data class HttpRequestPattern(
val value = it.parse(example, resolver)

val requestBodyAsIs = if (!isInvalidRequestResponse(status)) {
val result = body.matches(value, resolver)
val result = resolver.matchesPattern(null, body, value)

if (result is Failure)
throw ContractException(result.toFailureReport())
Expand Down
4 changes: 2 additions & 2 deletions core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ data class HttpResponsePattern(

fun withResponseExampleValue(row: Row, resolver: Resolver): HttpResponsePattern =
attempt(breadCrumb = "RESPONSE") {
val responseExample: ResponseExample = row.responseExampleForValidation ?: return@attempt this
val responseExample: ResponseExample = row.responseExampleForValidation.takeIf { !row.isPartial } ?: return@attempt this

val responseExampleMatchResult = matches(responseExample.responseExample, resolver)

Expand Down Expand Up @@ -126,7 +126,7 @@ data class HttpResponsePattern(
else -> response.body
}

val result = body.matches(parsedValue, resolver)
val result = resolver.matchesPattern(null, body, parsedValue)
if(result is Result.Failure)
return MatchSuccess(Triple(response, resolver, failures.plus(result.breadCrumb("BODY"))))

Expand Down
11 changes: 7 additions & 4 deletions core/src/main/kotlin/io/specmatic/core/Scenario.kt
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ data class Scenario(
val disambiguate: () -> String = { "" },
val descriptionFromPlugin: String? = null,
val dictionary: Map<String, Value> = emptyMap(),
val attributeSelectionPattern: AttributeSelectionPattern = AttributeSelectionPattern()
val attributeSelectionPattern: AttributeSelectionPattern = AttributeSelectionPattern(),
val exampleRow: Row? = null
): ScenarioDetailsForResult {
constructor(scenarioInfo: ScenarioInfo) : this(
scenarioInfo.scenarioName,
Expand Down Expand Up @@ -423,6 +424,7 @@ data class Scenario(
expectedFacts = newExpectedServerState,
ignoreFailure = ignoreFailure,
exampleName = row.name,
exampleRow = row,
generativePrefix = generativePrefix,
)
}
Expand All @@ -444,9 +446,7 @@ data class Scenario(
}
}

fun validExamplesOrException(
flagsBased: FlagsBased,
) {
fun validExamplesOrException(flagsBased: FlagsBased) {
val rowsToValidate = examples.flatMap { it.rows }

val updatedResolver = flagsBased.update(resolver)
Expand Down Expand Up @@ -709,6 +709,9 @@ data class Scenario(
}

fun isA2xxScenario(): Boolean = this.httpResponsePattern.status in 200..299

fun isA4xxScenario(): Boolean = this.httpResponsePattern.status in 400..499

fun negativeBasedOn(badRequestOrDefault: BadRequestOrDefault?): Scenario {
return this.copy(
isNegative = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import io.specmatic.core.Resolver
import io.specmatic.core.Result
import io.specmatic.core.value.JSONObjectValue
import io.specmatic.core.value.Value
import io.specmatic.test.ExampleProcessor

class Discriminator(
val property: String,
Expand Down Expand Up @@ -77,6 +78,8 @@ class Discriminator(
discriminatorCsvClause
)

if (isPatternToken(actualDiscriminatorValue) || ExampleProcessor.isSubstitutionToken(actualDiscriminatorValue)) return Result.Success()

if (actualDiscriminatorValue.toStringLiteral() !in values) {
val message =
"Expected the value of discriminator property to be $discriminatorCsvClause but it was ${actualDiscriminatorValue.toStringLiteral()}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import io.specmatic.core.value.Value

data class ExactValuePattern(override val pattern: Value, override val typeAlias: String? = null, val discriminator: Boolean = false) : Pattern {
override fun matches(sampleData: Value?, resolver: Resolver): Result {
if (isPatternToken(sampleData)) return Result.Success()

return when (pattern == sampleData) {
true -> Result.Success()
else -> {
Expand Down
3 changes: 2 additions & 1 deletion core/src/main/kotlin/io/specmatic/core/pattern/Row.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ data class Row(
val requestBodyJSONExample: JSONExample? = null,
val responseExampleForValidation: ResponseExample? = null,
val requestExample: HttpRequest? = null,
val responseExample: HttpResponse? = null
val responseExample: HttpResponse? = null,
val isPartial: Boolean = false
) {
constructor(examples: Map<String, String>) :this(examples.keys.toList(), examples.values.toList())

Expand Down
22 changes: 14 additions & 8 deletions core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,20 @@ data class ScenarioStub(
return JSONObjectValue(mockInteraction)
}

fun getRequestWithAdditionalParamsIfAny(request: HttpRequest, additionalExampleParamsFilePath: String?): HttpRequest {
private fun getHttpRequest(): HttpRequest {
return this.partial?.request ?: this.request
}

fun getRequestWithAdditionalParamsIfAny(additionalExampleParamsFilePath: String?): HttpRequest {
val request = getHttpRequest()

if(additionalExampleParamsFilePath == null)
return this.request
return request

val additionalExampleParamsFile = File(additionalExampleParamsFilePath)

if (!additionalExampleParamsFile.exists() || !additionalExampleParamsFile.isFile) {
return this.request
return request
}

try {
Expand All @@ -61,22 +67,22 @@ data class ScenarioStub(

if(additionalExampleParams == null) {
logger.log("WARNING: The content of $additionalExampleParamsFilePath is not a valid JSON object")
return this.request
return request
}

val additionalHeaders = (additionalExampleParams["headers"] ?: emptyMap<String, String>()) as? Map<String, String>

if(additionalHeaders == null) {
logger.log("WARNING: The content of \"headers\" in $additionalExampleParamsFilePath is not a valid JSON object")
return this.request
return request
}

val updatedHeaders = this.request.headers.plus(additionalHeaders)
val updatedHeaders = request.headers.plus(additionalHeaders)

return this.request.copy(headers = updatedHeaders)
return request.copy(headers = updatedHeaders)
} catch (e: Exception) {
logger.log(e, "WARNING: Could not read additional example params file $additionalExampleParamsFilePath")
return this.request
return request
}
}

Expand Down
9 changes: 8 additions & 1 deletion core/src/main/kotlin/io/specmatic/test/ContractTest.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
package io.specmatic.test

import io.specmatic.core.HttpRequest
import io.specmatic.core.HttpResponse
import io.specmatic.core.Result
import io.specmatic.core.Scenario
import io.specmatic.core.filters.ScenarioMetadata

interface ResponseValidator {
fun validate(scenario: Scenario, httpResponse: HttpResponse): Result?
fun validate(scenario: Scenario, httpResponse: HttpResponse): Result? {
return null
}

fun postValidate(scenario: Scenario, httpRequest: HttpRequest, httpResponse: HttpResponse): Result? {
return null
}
}

interface ContractTest {
Expand Down
Loading

0 comments on commit a18f874

Please sign in to comment.