From 3b54ecb1a7f4aea003279faee4d29df2eb3c2396 Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Tue, 13 Aug 2024 12:38:49 +0530 Subject: [PATCH 01/23] Built stub value substitution with support for request body json --- .../kotlin/io/specmatic/core/HttpResponse.kt | 63 +++++++++++++++++++ .../main/kotlin/io/specmatic/stub/HttpStub.kt | 14 +---- .../io/specmatic/stub/HttpStubResponse.kt | 18 ++++++ .../kotlin/io/specmatic/stub/HttpStubTest.kt | 15 ++++- .../spec_with_substitution_in_example.yaml | 34 ++++++++++ .../substitution.json | 16 +++++ 6 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 core/src/main/kotlin/io/specmatic/stub/HttpStubResponse.kt create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_substitution_in_example.yaml create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_substitution_in_example_examples/substitution.json diff --git a/core/src/main/kotlin/io/specmatic/core/HttpResponse.kt b/core/src/main/kotlin/io/specmatic/core/HttpResponse.kt index 425ffaa39..1e1aa8f2d 100644 --- a/core/src/main/kotlin/io/specmatic/core/HttpResponse.kt +++ b/core/src/main/kotlin/io/specmatic/core/HttpResponse.kt @@ -13,6 +13,63 @@ const val SPECMATIC_RESULT_HEADER = "${SPECMATIC_HEADER_PREFIX}Result" internal const val SPECMATIC_EMPTY_HEADER = "${SPECMATIC_HEADER_PREFIX}Empty" internal const val SPECMATIC_TYPE_HEADER = "${SPECMATIC_HEADER_PREFIX}Type" +class Substitution(val request: HttpRequest) { + fun resolveSubstitutions(value: Value): Value { + return when(value) { + is JSONObjectValue -> resolveSubstitutions(value) + is JSONArrayValue -> resolveSubstitutions(value) + is StringValue -> { + if(value.string.startsWith("{{") && value.string.endsWith("}}")) + StringValue(substitute(value.string)) + else + value + } + else -> value + } + } + + private fun substitute(string: String): String { + val expressionPath = string.removeSurrounding("{{", "}}") + + val parts = expressionPath.split(".") + + val area = parts.firstOrNull() ?: throw ContractException("The expression $expressionPath was empty") + + return if(area.uppercase() == "REQUEST") { + val requestPath = parts.drop(1) + + val requestPart = requestPath.firstOrNull() ?: throw ContractException("The expression $expressionPath does not include anything after REQUEST to say what has to be substituted") + val jsonBodyPath = requestPath.drop(1) + + when(requestPart.uppercase()) { + "BODY" -> { + val requestJSONBody = request.body as? JSONObjectValue ?: throw ContractException("Substitution $string cannot be resolved as the request body is not an object") + requestJSONBody.findFirstChildByPath(jsonBodyPath)?.toStringLiteral() ?: throw ContractException("Could not find $string in the request body") + } + else -> string + } + } + else + string + } + + fun resolveSubstitutions(value: JSONObjectValue): Value { + return value.copy( + value.jsonObject.mapValues { entry -> + resolveSubstitutions(entry.value) + } + ) + } + + fun resolveSubstitutions(value: JSONArrayValue): Value { + return value.copy( + value.list.map { + resolveSubstitutions(it) + } + ) + } +} + data class HttpResponse( val status: Int = 0, val headers: Map = mapOf(CONTENT_TYPE to "text/plain"), @@ -167,6 +224,12 @@ data class HttpResponse( } private fun headersHasOnlyTextPlainContentTypeHeader() = headers.size == 1 && headers[CONTENT_TYPE] == "text/plain" + + fun resolveSubstitutions(request: HttpRequest): HttpResponse { + val newBody = Substitution(request).resolveSubstitutions(this.body) + return this.copy(body = newBody) + } + } fun nativeInteger(json: Map, key: String): Int? { diff --git a/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt index 23e560526..21853e645 100644 --- a/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt @@ -12,11 +12,11 @@ import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.util.* import io.specmatic.core.* -import io.specmatic.core.route.modules.HealthCheckModule.Companion.configureHealthCheckModule -import io.specmatic.core.route.modules.HealthCheckModule.Companion.isHealthCheckRequest import io.specmatic.core.log.* import io.specmatic.core.pattern.ContractException import io.specmatic.core.pattern.parsedValue +import io.specmatic.core.route.modules.HealthCheckModule.Companion.configureHealthCheckModule +import io.specmatic.core.route.modules.HealthCheckModule.Companion.isHealthCheckRequest import io.specmatic.core.utilities.* import io.specmatic.core.value.* import io.specmatic.mock.* @@ -36,14 +36,6 @@ import java.nio.charset.Charset import java.util.* import kotlin.text.toCharArray -data class HttpStubResponse( - val response: HttpResponse, - val delayInMilliSeconds: Long? = null, - val contractPath: String = "", - val feature: Feature? = null, - val scenario: Scenario? = null -) - class HttpStub( private val features: List, rawHttpStubs: List = emptyList(), @@ -730,7 +722,7 @@ fun getHttpResponse( val (matchResults, matchingStubResponse) = stubbedResponse(threadSafeStubs, threadSafeStubQueue, httpRequest) if(matchingStubResponse != null) - FoundStubbedResponse(matchingStubResponse) + FoundStubbedResponse(matchingStubResponse.resolveSubstitutions(httpRequest)) else if (httpClientFactory != null && passThroughTargetBase.isNotBlank()) NotStubbed(passThroughResponse(httpRequest, passThroughTargetBase, httpClientFactory)) else if (strictMode) { diff --git a/core/src/main/kotlin/io/specmatic/stub/HttpStubResponse.kt b/core/src/main/kotlin/io/specmatic/stub/HttpStubResponse.kt new file mode 100644 index 000000000..1535a8400 --- /dev/null +++ b/core/src/main/kotlin/io/specmatic/stub/HttpStubResponse.kt @@ -0,0 +1,18 @@ +package io.specmatic.stub + +import io.specmatic.core.Feature +import io.specmatic.core.HttpRequest +import io.specmatic.core.HttpResponse +import io.specmatic.core.Scenario + +data class HttpStubResponse( + val response: HttpResponse, + val delayInMilliSeconds: Long? = null, + val contractPath: String = "", + val feature: Feature? = null, + val scenario: Scenario? = null +) { + fun resolveSubstitutions(request: HttpRequest): HttpStubResponse { + return this.copy(response = response.resolveSubstitutions(request)) + } +} \ No newline at end of file diff --git a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt index 9b2063927..6066e55cc 100644 --- a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt @@ -14,7 +14,6 @@ import io.specmatic.test.HttpClient import io.specmatic.test.TestExecutor import io.mockk.every import io.mockk.mockk -import io.specmatic.stub import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Nested import org.junit.jupiter.api.RepeatedTest @@ -1832,4 +1831,18 @@ components: assertThat(responseBody.jsonObject["message"]).isInstanceOf(NumberValue::class.java) } } + + @Test + fun `stub example with substitution`() { + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_substitution_in_example.yaml") + + createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> + val request = HttpRequest("POST", "/person", body = parsedJSONObject("""{"name": "Jane"}""")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val jsonResponse = response.body as JSONObjectValue + assertThat(jsonResponse.findFirstChildByPath("name")?.toStringLiteral()).isEqualTo("Jane") + } + } } diff --git a/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_example.yaml b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_example.yaml new file mode 100644 index 000000000..76f02c95e --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_example.yaml @@ -0,0 +1,34 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - id + - name + properties: + id: + type: number + name: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_example_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_example_examples/substitution.json new file mode 100644 index 000000000..42adfe187 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_example_examples/substitution.json @@ -0,0 +1,16 @@ +{ + "http-request": { + "method": "POST", + "path": "/person", + "body": { + "name": "Jane" + } + }, + "http-response": { + "status": 200, + "body": { + "id": 10, + "name": "{{REQUEST.BODY.name}}" + } + } +} \ No newline at end of file From 963df3da15363a85448703b66497647d5df83a82 Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Tue, 13 Aug 2024 14:16:13 +0530 Subject: [PATCH 02/23] Update test for vanilla substitution --- ...spec_with_map_substitution_in_example.yaml | 38 +++++++++++++++++++ .../substitution.json | 29 ++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example.yaml create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example_examples/substitution.json diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example.yaml b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example.yaml new file mode 100644 index 000000000..af8edc2be --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example.yaml @@ -0,0 +1,38 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + department: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - id + - name + properties: + id: + type: number + name: + type: string + location: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example_examples/substitution.json new file mode 100644 index 000000000..6accc4dd2 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example_examples/substitution.json @@ -0,0 +1,29 @@ +{ + "data": { + "@department": { + "engineering": { + "manager": "Jack" + }, + "sales": { + "manager": "Jill", + "location": "San Francisco" + } + } + }, + "http-request": { + "method": "POST", + "path": "/person", + "body": { + "name": "Jane", + "department": "(string)" + } + }, + "http-response": { + "status": 200, + "body": { + "id": 10, + "name": "{{REQUEST.BODY.name}}", + "manager": "{{@department}}" + } + } +} \ No newline at end of file From 9d64e7191777f7677dddf144c5e3b3cd55580a33 Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Tue, 13 Aug 2024 17:14:34 +0530 Subject: [PATCH 03/23] Added substitution unit tests --- .../io/specmatic/core/SubstitutionTest.kt | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 core/src/test/kotlin/io/specmatic/core/SubstitutionTest.kt diff --git a/core/src/test/kotlin/io/specmatic/core/SubstitutionTest.kt b/core/src/test/kotlin/io/specmatic/core/SubstitutionTest.kt new file mode 100644 index 000000000..09e92d914 --- /dev/null +++ b/core/src/test/kotlin/io/specmatic/core/SubstitutionTest.kt @@ -0,0 +1,40 @@ +package io.specmatic.core + +import io.specmatic.core.pattern.parsedJSONObject +import io.specmatic.core.value.JSONObjectValue +import io.specmatic.core.value.StringValue +import io.specmatic.core.value.Value +import io.specmatic.mock.SUBSTITUTION_DATA +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class SubstitutionTest { + @Test + fun `substitution using request body value`(){ + val request = HttpRequest("POST", "/data", body = parsedJSONObject("""{"id": "abc123"}""")) + val responseValue = parsedJSONObject("""{"id": "{{REQUEST.BODY.id}}"}""") + + val updatedVaue = Substitution(request).resolveSubstitutions(responseValue) as JSONObjectValue + + assertThat(updatedVaue.findFirstChildByPath("id")?.toStringLiteral()).isEqualTo("abc123") + } + + @Test + fun `substitution using data`(){ + val request = HttpRequest("POST", "/data", body = parsedJSONObject("""{"department": "engineering"}""")) + val responseValue = parsedJSONObject("""{"location": "{{@department}}"}""") + + val data = mapOf( + "@department" to mapOf>( + "engineering" to mapOf( + "location" to StringValue("Dallas") + ) + ) + ) + + val updatedVaue = Substitution(request).resolveSubstitutions(responseValue) as JSONObjectValue + + assertThat(updatedVaue.findFirstChildByPath("location")?.toStringLiteral()).isEqualTo("Dallas") + } +} \ No newline at end of file From c0b12d2eae1e022e2a2e40f33b6055d18971e26f Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Tue, 13 Aug 2024 18:04:18 +0530 Subject: [PATCH 04/23] Removed data substitution test --- .../io/specmatic/core/SubstitutionTest.kt | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/core/src/test/kotlin/io/specmatic/core/SubstitutionTest.kt b/core/src/test/kotlin/io/specmatic/core/SubstitutionTest.kt index 09e92d914..45f6f7a63 100644 --- a/core/src/test/kotlin/io/specmatic/core/SubstitutionTest.kt +++ b/core/src/test/kotlin/io/specmatic/core/SubstitutionTest.kt @@ -4,7 +4,6 @@ import io.specmatic.core.pattern.parsedJSONObject import io.specmatic.core.value.JSONObjectValue import io.specmatic.core.value.StringValue import io.specmatic.core.value.Value -import io.specmatic.mock.SUBSTITUTION_DATA import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test @@ -19,22 +18,4 @@ class SubstitutionTest { assertThat(updatedVaue.findFirstChildByPath("id")?.toStringLiteral()).isEqualTo("abc123") } - - @Test - fun `substitution using data`(){ - val request = HttpRequest("POST", "/data", body = parsedJSONObject("""{"department": "engineering"}""")) - val responseValue = parsedJSONObject("""{"location": "{{@department}}"}""") - - val data = mapOf( - "@department" to mapOf>( - "engineering" to mapOf( - "location" to StringValue("Dallas") - ) - ) - ) - - val updatedVaue = Substitution(request).resolveSubstitutions(responseValue) as JSONObjectValue - - assertThat(updatedVaue.findFirstChildByPath("location")?.toStringLiteral()).isEqualTo("Dallas") - } } \ No newline at end of file From d350ab56976de139a992f8c1ae819252f6a4812c Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Tue, 13 Aug 2024 22:11:14 +0530 Subject: [PATCH 05/23] Implemented basic data substitution of values in request and response bodies --- .../kotlin/io/specmatic/mock/ScenarioStub.kt | 148 +++++++++++++++++- .../main/kotlin/io/specmatic/stub/HttpStub.kt | 67 +++++++- .../main/kotlin/io/specmatic/stub/StubData.kt | 1 + .../kotlin/io/specmatic/stub/HttpStubTest.kt | 17 ++ ...spec_with_map_substitution_in_example.yaml | 11 +- .../substitution.json | 12 +- 6 files changed, 233 insertions(+), 23 deletions(-) diff --git a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt index 9824b48e3..86c29c806 100644 --- a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt +++ b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt @@ -5,7 +5,7 @@ import io.specmatic.core.pattern.ContractException import io.specmatic.core.value.* import io.specmatic.stub.stringToMockScenario -data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: HttpResponse = HttpResponse(0, emptyMap()), val delayInMilliseconds: Long? = null, val stubToken: String? = null, val requestBodyRegex: String? = null) { +data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: HttpResponse = HttpResponse(0, emptyMap()), val delayInMilliseconds: Long? = null, val stubToken: String? = null, val requestBodyRegex: String? = null, val data: JSONObjectValue = JSONObjectValue()) { fun toJSON(): JSONObjectValue { val mockInteraction = mutableMapOf() @@ -15,6 +15,148 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: return JSONObjectValue(mockInteraction) } + private fun combinations(data: Map>>): List>>> { + // Helper function to compute Cartesian product of multiple lists + fun cartesianProduct(lists: List>): List> { + return lists.fold(listOf(listOf())) { acc, list -> + acc.flatMap { item -> list.map { value -> item + value } } + } + } + + // Generate the Cartesian product of the values in the input map + val product = cartesianProduct(data.map { (key, nestedMap) -> + nestedMap.map { (nestedKey, valueMap) -> + mapOf(key to mapOf(nestedKey to valueMap)) + } + }) + + // Convert each product result into a combined map + return product.map { item -> + item.reduce { acc, map -> acc + map } + } + } + + fun resolveDataSubstitutions(): List { + if(data.jsonObject.isEmpty()) + return listOf(this) + + val substitutions = unwrapSubstitutions(data) + + val combinations = combinations(substitutions) + + return combinations.map { combination -> + replaceInExample(combination) + } + } + + private fun unwrapSubstitutions(rawSubstitutions: JSONObjectValue): Map>> { + val substitutions = rawSubstitutions.jsonObject.mapValues { + val json = + it.value as? JSONObjectValue ?: throw ContractException("Invalid structure of data in the example file") + + json.jsonObject.mapValues { + val json = + it.value as? JSONObjectValue ?: throw ContractException("Invalid structure of data in the example file") + + json.jsonObject.mapValues { + it.value + } + } + } + return substitutions + } + + fun replaceInRequestBody(value: JSONObjectValue, substitutions: Map>>): Value { + return value.copy( + value.jsonObject.mapValues { + replaceInRequestBody(it.value, substitutions) + } + ) + } + + fun replaceInRequestBody(value: JSONArrayValue, substitutions: Map>>): Value { + return value.copy( + value.list.map { + replaceInRequestBody(value, substitutions) + } + ) + } + + fun replaceInRequestBody(value: Value, substitutions: Map>>): Value { + return when(value) { + is StringValue -> { + if(value.string.startsWith("{{@") && value.string.endsWith("}}")) { + val substitutionSetName = value.string.removeSurrounding("{{", "}}") + val substitutionSet = substitutions[substitutionSetName] ?: throw ContractException("$substitutionSetName does not exist in the data") + + val substitutionKey = substitutionSet.keys.firstOrNull() ?: throw ContractException("$substitutionSetName in data is empty") + + StringValue(substitutionKey) + } else + value + } + is JSONObjectValue -> { + replaceInRequestBody(value, substitutions) + } + is JSONArrayValue -> { + replaceInRequestBody(value, substitutions) + } + else -> value + } + } + + fun replaceInExample(substitutions: Map>>): ScenarioStub { + val newRequestBody = replaceInRequestBody(request.body, substitutions) + val newRequest = request.copy(body = newRequestBody) + + val newResponseBody = replaceInResponseBody(response.body, substitutions, "") + val newResponse = response.copy(body = newResponseBody) + + return copy( + request = newRequest, + response = newResponse + ) + } + + fun replaceInResponseBody(value: JSONObjectValue, substitutions: Map>>): Value { + return value.copy( + value.jsonObject.mapValues { + replaceInResponseBody(it.value, substitutions, it.key) + } + ) + } + + fun replaceInResponseBody(value: JSONArrayValue, substitutions: Map>>): Value { + return value.copy( + value.list.map { + replaceInResponseBody(value, substitutions) + } + ) + } + + fun replaceInResponseBody(value: Value, substitutions: Map>>, key: String): Value { + return when(value) { + is StringValue -> { + if(value.string.startsWith("{{@") && value.string.endsWith("}}")) { + val substitutionSetName = value.string.removeSurrounding("{{", "}}") + val substitutionSet = substitutions[substitutionSetName] ?: throw ContractException("$substitutionSetName does not exist in the data") + + val substitutionValue = substitutionSet.values.first()[key] ?: throw ContractException("$substitutionSetName does not contain a value for $key") + + substitutionValue + } else + value + } + is JSONObjectValue -> { + replaceInResponseBody(value, substitutions) + } + is JSONArrayValue -> { + replaceInResponseBody(value, substitutions) + } + else -> value + } + } + companion object { fun parse(text: String): ScenarioStub { return stringToMockScenario(StringValue(text)) @@ -44,6 +186,8 @@ fun mockFromJSON(mockSpec: Map): ScenarioStub { val mockRequest: HttpRequest = requestFromJSON(getJSONObjectValue(MOCK_HTTP_REQUEST_ALL_KEYS, mockSpec)) val mockResponse: HttpResponse = HttpResponse.fromJSON(getJSONObjectValue(MOCK_HTTP_RESPONSE_ALL_KEYS, mockSpec)) + val data = getJSONObjectValue("data", mockSpec) + val delayInSeconds: Int? = getIntOrNull(DELAY_IN_SECONDS, mockSpec) val delayInMilliseconds: Long? = getLongOrNull(DELAY_IN_MILLISECONDS, mockSpec) val delayInMs: Long? = delayInMilliseconds ?: delayInSeconds?.let { it.toLong().times(1000) } @@ -51,7 +195,7 @@ fun mockFromJSON(mockSpec: Map): ScenarioStub { val stubToken: String? = getStringOrNull(TRANSIENT_MOCK_ID, mockSpec) val requestBodyRegex: String? = getRequestBodyRegexOrNull(mockSpec) - return ScenarioStub(request = mockRequest, response = mockResponse, delayInMilliseconds = delayInMs, stubToken = stubToken, requestBodyRegex = requestBodyRegex) + return ScenarioStub(request = mockRequest, response = mockResponse, delayInMilliseconds = delayInMs, stubToken = stubToken, requestBodyRegex = requestBodyRegex, data = JSONObjectValue(data)) } fun getRequestBodyRegexOrNull(mockSpec: Map): String? { diff --git a/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt index 21853e645..17edf84d6 100644 --- a/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt @@ -85,8 +85,65 @@ class HttpStub( requestHandlers.add(requestHandler) } + private fun transform(httpStubData: HttpStubData): List { + return listOf(httpStubData) + +// val data: Map>> = httpStubData.data +// +// val combinations: List>>> = combinations(data) +// +// return combinations.map { combination: Map>> -> +// transform(httpStubData, combination) +// } + } + +// private fun transform(httpStubData: HttpStubData, combination: Map>>): HttpStubData { +// val requestBodyPattern = httpStubData.requestType.body +// } + + fun combinations(data: Map>>): List>>> { + // Helper function to calculate the Cartesian product of a list of maps + fun cartesianProduct(maps: List>): List> { + if (maps.isEmpty()) return listOf(emptyMap()) + + val result = mutableListOf>() + val first = maps[0] + val rest = cartesianProduct(maps.drop(1)) + + for ((key, value) in first) { + for (restMap in rest) { + result.add(mapOf(key to value) + restMap) + } + } + + return result + } + + // Creating individual combinations for each top-level key in the input map + val individualCombinations = data.map { entry -> + entry.key to cartesianProduct(entry.value.map { mapOf(it.key to it.value) }) + } + + // Combine the individual combinations across the different top-level keys + val result = cartesianProduct(individualCombinations.map { mapOf(it.first to it.second) }) + + // Transforming the result to match the expected return type + return result.flatMap { combination -> + val flattened = combination.mapValues { (_, listOfMaps) -> + listOfMaps.associate { it.entries.first().toPair() } + } + flattened.values.first().map { flattenedKey -> + mapOf( + flattenedKey.key to flattenedKey.value + ) + }.map { mapOf(flattened.entries.first().key to it) } + } + } + + private fun staticHttpStubData(rawHttpStubs: List): MutableList { - val staticStubs = rawHttpStubs.filter { it.stubToken == null }.toMutableList() + val staticStubs = rawHttpStubs.filter { it.stubToken == null }.flatMap { transform(it) } + val stubsFromSpecificationExamples: List = features.map { feature -> feature.stubsFromExamples.entries.map { (exampleName, examples) -> examples.mapNotNull { (request, response) -> @@ -1008,9 +1065,11 @@ fun stubResponse( } fun contractInfoToHttpExpectations(contractInfo: List>>): List { - return contractInfo.flatMap { (feature, mocks) -> - mocks.map { mock -> - feature.matchingStub(mock, ContractAndStubMismatchMessages) + return contractInfo.flatMap { (feature, examples) -> + examples.flatMap { + it.resolveDataSubstitutions() + }.map { example -> + feature.matchingStub(example, ContractAndStubMismatchMessages) } } } diff --git a/core/src/main/kotlin/io/specmatic/stub/StubData.kt b/core/src/main/kotlin/io/specmatic/stub/StubData.kt index 66f8a008c..4a8216cc1 100644 --- a/core/src/main/kotlin/io/specmatic/stub/StubData.kt +++ b/core/src/main/kotlin/io/specmatic/stub/StubData.kt @@ -6,6 +6,7 @@ import io.specmatic.core.pattern.ContractException import io.specmatic.core.pattern.attempt import io.specmatic.core.utilities.ExternalCommand import io.specmatic.core.utilities.jsonStringToValueMap +import io.specmatic.core.value.Value data class HttpStubData( val requestType: HttpRequestPattern, diff --git a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt index 6066e55cc..e71f16c81 100644 --- a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt @@ -21,6 +21,8 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.DisabledOnOs import org.junit.jupiter.api.condition.OS import org.junit.jupiter.api.fail +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod import org.springframework.http.MediaType @@ -1845,4 +1847,19 @@ components: assertThat(jsonResponse.findFirstChildByPath("name")?.toStringLiteral()).isEqualTo("Jane") } } + + @ParameterizedTest + @CsvSource("engineering,Bangalore", "sales,Mumbai") + fun `stub example with data substitution`(department: String, location: String) { + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example.yaml") + + createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> + val request = HttpRequest("POST", "/person", body = parsedJSONObject("""{"department": "$department"}""")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val jsonResponse = response.body as JSONObjectValue + assertThat(jsonResponse.findFirstChildByPath("location")?.toStringLiteral()).isEqualTo("$location") + } + } } diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example.yaml b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example.yaml index af8edc2be..a0c4045f5 100644 --- a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example.yaml +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example.yaml @@ -13,10 +13,8 @@ paths: schema: type: object required: - - name + - department properties: - name: - type: string department: type: string responses: @@ -27,12 +25,7 @@ paths: schema: type: object required: - - id - - name + - location properties: - id: - type: number - name: - type: string location: type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example_examples/substitution.json index 6accc4dd2..e5f21cf79 100644 --- a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example_examples/substitution.json +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example_examples/substitution.json @@ -2,11 +2,10 @@ "data": { "@department": { "engineering": { - "manager": "Jack" + "location": "Bangalore" }, "sales": { - "manager": "Jill", - "location": "San Francisco" + "location": "Mumbai" } } }, @@ -14,16 +13,13 @@ "method": "POST", "path": "/person", "body": { - "name": "Jane", - "department": "(string)" + "department": "{{@department}}" } }, "http-response": { "status": 200, "body": { - "id": 10, - "name": "{{REQUEST.BODY.name}}", - "manager": "{{@department}}" + "location": "{{@department}}" } } } \ No newline at end of file From aef08b875cf7293aabfd950252131d64735d4f80 Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Wed, 14 Aug 2024 12:42:32 +0530 Subject: [PATCH 06/23] Added support for substituting data in response headers --- .../kotlin/io/specmatic/core/HttpResponse.kt | 65 ++-------------- .../kotlin/io/specmatic/core/Substitution.kt | 77 +++++++++++++++++++ .../kotlin/io/specmatic/mock/ScenarioStub.kt | 14 +++- .../kotlin/io/specmatic/stub/HttpStubTest.kt | 54 ++++++++++++- ...th_map_substitution_in_response_body.yaml} | 0 .../substitution.json | 0 ...c_with_substitution_in_response_body.yaml} | 0 .../substitution.json | 0 ..._with_substitution_in_response_header.yaml | 31 ++++++++ .../substitution.json | 16 ++++ 10 files changed, 190 insertions(+), 67 deletions(-) create mode 100644 core/src/main/kotlin/io/specmatic/core/Substitution.kt rename core/src/test/resources/openapi/substitutions/{spec_with_map_substitution_in_example.yaml => spec_with_map_substitution_in_response_body.yaml} (100%) rename core/src/test/resources/openapi/substitutions/{spec_with_map_substitution_in_example_examples => spec_with_map_substitution_in_response_body_examples}/substitution.json (100%) rename core/src/test/resources/openapi/substitutions/{spec_with_substitution_in_example.yaml => spec_with_substitution_in_response_body.yaml} (100%) rename core/src/test/resources/openapi/substitutions/{spec_with_substitution_in_example_examples => spec_with_substitution_in_response_body_examples}/substitution.json (100%) create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header.yaml create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header_examples/substitution.json diff --git a/core/src/main/kotlin/io/specmatic/core/HttpResponse.kt b/core/src/main/kotlin/io/specmatic/core/HttpResponse.kt index 1e1aa8f2d..49a421054 100644 --- a/core/src/main/kotlin/io/specmatic/core/HttpResponse.kt +++ b/core/src/main/kotlin/io/specmatic/core/HttpResponse.kt @@ -1,75 +1,18 @@ package io.specmatic.core +import io.ktor.http.* import io.specmatic.conversions.guessType import io.specmatic.core.GherkinSection.Then import io.specmatic.core.pattern.ContractException import io.specmatic.core.pattern.Pattern import io.specmatic.core.pattern.parsedValue import io.specmatic.core.value.* -import io.ktor.http.* private const val SPECMATIC_HEADER_PREFIX = "X-$APPLICATION_NAME-" const val SPECMATIC_RESULT_HEADER = "${SPECMATIC_HEADER_PREFIX}Result" internal const val SPECMATIC_EMPTY_HEADER = "${SPECMATIC_HEADER_PREFIX}Empty" internal const val SPECMATIC_TYPE_HEADER = "${SPECMATIC_HEADER_PREFIX}Type" -class Substitution(val request: HttpRequest) { - fun resolveSubstitutions(value: Value): Value { - return when(value) { - is JSONObjectValue -> resolveSubstitutions(value) - is JSONArrayValue -> resolveSubstitutions(value) - is StringValue -> { - if(value.string.startsWith("{{") && value.string.endsWith("}}")) - StringValue(substitute(value.string)) - else - value - } - else -> value - } - } - - private fun substitute(string: String): String { - val expressionPath = string.removeSurrounding("{{", "}}") - - val parts = expressionPath.split(".") - - val area = parts.firstOrNull() ?: throw ContractException("The expression $expressionPath was empty") - - return if(area.uppercase() == "REQUEST") { - val requestPath = parts.drop(1) - - val requestPart = requestPath.firstOrNull() ?: throw ContractException("The expression $expressionPath does not include anything after REQUEST to say what has to be substituted") - val jsonBodyPath = requestPath.drop(1) - - when(requestPart.uppercase()) { - "BODY" -> { - val requestJSONBody = request.body as? JSONObjectValue ?: throw ContractException("Substitution $string cannot be resolved as the request body is not an object") - requestJSONBody.findFirstChildByPath(jsonBodyPath)?.toStringLiteral() ?: throw ContractException("Could not find $string in the request body") - } - else -> string - } - } - else - string - } - - fun resolveSubstitutions(value: JSONObjectValue): Value { - return value.copy( - value.jsonObject.mapValues { entry -> - resolveSubstitutions(entry.value) - } - ) - } - - fun resolveSubstitutions(value: JSONArrayValue): Value { - return value.copy( - value.list.map { - resolveSubstitutions(it) - } - ) - } -} - data class HttpResponse( val status: Int = 0, val headers: Map = mapOf(CONTENT_TYPE to "text/plain"), @@ -226,8 +169,10 @@ data class HttpResponse( private fun headersHasOnlyTextPlainContentTypeHeader() = headers.size == 1 && headers[CONTENT_TYPE] == "text/plain" fun resolveSubstitutions(request: HttpRequest): HttpResponse { - val newBody = Substitution(request).resolveSubstitutions(this.body) - return this.copy(body = newBody) + val substitution = Substitution(request) + val newBody = substitution.resolveSubstitutions(this.body) + val newHeaders = substitution.resolveSubstitutions(this.headers) + return this.copy(body = newBody, headers = newHeaders) } } diff --git a/core/src/main/kotlin/io/specmatic/core/Substitution.kt b/core/src/main/kotlin/io/specmatic/core/Substitution.kt new file mode 100644 index 000000000..a4d4da18e --- /dev/null +++ b/core/src/main/kotlin/io/specmatic/core/Substitution.kt @@ -0,0 +1,77 @@ +package io.specmatic.core + +import io.specmatic.core.pattern.ContractException +import io.specmatic.core.value.JSONArrayValue +import io.specmatic.core.value.JSONObjectValue +import io.specmatic.core.value.StringValue +import io.specmatic.core.value.Value + +class Substitution(val request: HttpRequest) { + fun resolveSubstitutions(value: Value): Value { + return when(value) { + is JSONObjectValue -> resolveSubstitutions(value) + is JSONArrayValue -> resolveSubstitutions(value) + is StringValue -> { + if(value.string.startsWith("{{") && value.string.endsWith("}}")) + StringValue(substitute(value.string)) + else + value + } + else -> value + } + } + + private fun substitute(string: String): String { + val expressionPath = string.removeSurrounding("{{", "}}") + + val parts = expressionPath.split(".") + + val area = parts.firstOrNull() ?: throw ContractException("The expression $expressionPath was empty") + + return if(area.uppercase() == "REQUEST") { + val requestPath = parts.drop(1) + + val requestPart = requestPath.firstOrNull() ?: throw ContractException("The expression $expressionPath does not include anything after REQUEST to say what has to be substituted") + val payloadPath = requestPath.drop(1) + + when(requestPart.uppercase()) { + "BODY" -> { + val requestJSONBody = request.body as? JSONObjectValue + ?: throw ContractException("Substitution $string cannot be resolved as the request body is not an object") + requestJSONBody.findFirstChildByPath(payloadPath)?.toStringLiteral() ?: throw ContractException("Could not find $string in the request body") + } + "HEADERS" -> { + val requestHeaders = request.headers + val requestHeaderName = payloadPath.joinToString(".") + requestHeaders[requestHeaderName] + ?: throw ContractException("Substitution $string cannot be resolved as the request header $requestHeaderName cannot be found") + } + else -> string + } + } + else + string + } + + private fun resolveSubstitutions(value: JSONObjectValue): Value { + return value.copy( + value.jsonObject.mapValues { entry -> + resolveSubstitutions(entry.value) + } + ) + } + + private fun resolveSubstitutions(value: JSONArrayValue): Value { + return value.copy( + value.list.map { + resolveSubstitutions(it) + } + ) + } + + fun resolveSubstitutions(map: Map): Map { + return map.mapValues { + substitute(it.value) + } + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt index 86c29c806..fe8bff19f 100644 --- a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt +++ b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt @@ -55,10 +55,10 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: it.value as? JSONObjectValue ?: throw ContractException("Invalid structure of data in the example file") json.jsonObject.mapValues { - val json = + val innerJSON = it.value as? JSONObjectValue ?: throw ContractException("Invalid structure of data in the example file") - json.jsonObject.mapValues { + innerJSON.jsonObject.mapValues { it.value } } @@ -186,7 +186,7 @@ fun mockFromJSON(mockSpec: Map): ScenarioStub { val mockRequest: HttpRequest = requestFromJSON(getJSONObjectValue(MOCK_HTTP_REQUEST_ALL_KEYS, mockSpec)) val mockResponse: HttpResponse = HttpResponse.fromJSON(getJSONObjectValue(MOCK_HTTP_RESPONSE_ALL_KEYS, mockSpec)) - val data = getJSONObjectValue("data", mockSpec) + val data = getJSONObjectValueOrNull("data", mockSpec)?.let { JSONObjectValue(it) } ?: JSONObjectValue() val delayInSeconds: Int? = getIntOrNull(DELAY_IN_SECONDS, mockSpec) val delayInMilliseconds: Long? = getLongOrNull(DELAY_IN_MILLISECONDS, mockSpec) @@ -195,7 +195,7 @@ fun mockFromJSON(mockSpec: Map): ScenarioStub { val stubToken: String? = getStringOrNull(TRANSIENT_MOCK_ID, mockSpec) val requestBodyRegex: String? = getRequestBodyRegexOrNull(mockSpec) - return ScenarioStub(request = mockRequest, response = mockResponse, delayInMilliseconds = delayInMs, stubToken = stubToken, requestBodyRegex = requestBodyRegex, data = JSONObjectValue(data)) + return ScenarioStub(request = mockRequest, response = mockResponse, delayInMilliseconds = delayInMs, stubToken = stubToken, requestBodyRegex = requestBodyRegex, data = data) } fun getRequestBodyRegexOrNull(mockSpec: Map): String? { @@ -214,6 +214,12 @@ fun getJSONObjectValue(key: String, mapData: Map): Map): Map? { + val data = mapData[key] ?: return null + if(data !is JSONObjectValue) throw ContractException("$key should be a json object") + return data.jsonObject +} + fun getIntOrNull(key: String, mapData: Map): Int? { val data = mapData[key] diff --git a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt index e71f16c81..bd3bb7a3a 100644 --- a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt @@ -1835,8 +1835,8 @@ components: } @Test - fun `stub example with substitution`() { - val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_substitution_in_example.yaml") + fun `stub example with substitution in response body`() { + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_substitution_in_response_body.yaml") createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> val request = HttpRequest("POST", "/person", body = parsedJSONObject("""{"name": "Jane"}""")) @@ -1848,10 +1848,58 @@ components: } } + @Test + fun `stub example with substitution in response header`() { + val spec = """ + openapi: 3.0.0 + info: + title: Sample API + version: 0.1.9 + paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + responses: + '200': + description: OK + headers: + X-Name: + description: ID in the header + schema: + type: string + content: + text/plain: + schema: + type: string + """.trimIndent() + + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header.yaml") + + createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> + val request = HttpRequest("POST", "/person", body = parsedJSONObject("""{"name": "Jane"}""")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val responseHeaders = response.headers + assertThat(responseHeaders["X-Name"]).isEqualTo("Jane") + } + } + @ParameterizedTest @CsvSource("engineering,Bangalore", "sales,Mumbai") fun `stub example with data substitution`(department: String, location: String) { - val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example.yaml") + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_map_substitution_in_response_body.yaml") createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> val request = HttpRequest("POST", "/person", body = parsedJSONObject("""{"department": "$department"}""")) diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example.yaml b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_response_body.yaml similarity index 100% rename from core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example.yaml rename to core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_response_body.yaml diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_response_body_examples/substitution.json similarity index 100% rename from core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_example_examples/substitution.json rename to core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_response_body_examples/substitution.json diff --git a/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_example.yaml b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_body.yaml similarity index 100% rename from core/src/test/resources/openapi/substitutions/spec_with_substitution_in_example.yaml rename to core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_body.yaml diff --git a/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_example_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_body_examples/substitution.json similarity index 100% rename from core/src/test/resources/openapi/substitutions/spec_with_substitution_in_example_examples/substitution.json rename to core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_body_examples/substitution.json diff --git a/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header.yaml b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header.yaml new file mode 100644 index 000000000..403357c59 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + responses: + '200': + description: OK + headers: + X-Name: + description: ID in the header + schema: + type: string + content: + text/plain: + schema: + type: string \ No newline at end of file diff --git a/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header_examples/substitution.json new file mode 100644 index 000000000..7db6832ce --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header_examples/substitution.json @@ -0,0 +1,16 @@ +{ + "http-request": { + "method": "POST", + "path": "/person", + "body": { + "name": "Jane" + } + }, + "http-response": { + "status": 200, + "headers": { + "X-Name": "{{REQUEST.BODY.name}}" + }, + "body": "success" + } +} \ No newline at end of file From bd2bbe5eca42828043bbbe3bed47cc6f0acd0a8d Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Wed, 14 Aug 2024 12:49:21 +0530 Subject: [PATCH 07/23] Added test for pull request headers for performing substitution --- .../kotlin/io/specmatic/stub/HttpStubTest.kt | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt index bd3bb7a3a..2e44b2bca 100644 --- a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt @@ -1850,32 +1850,41 @@ components: @Test fun `stub example with substitution in response header`() { + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header.yaml") + + createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> + val request = HttpRequest("POST", "/person", body = parsedJSONObject("""{"name": "Jane"}""")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val responseHeaders = response.headers + assertThat(responseHeaders["X-Name"]).isEqualTo("Jane") + } + } + + @Test + fun `stub example with substitution in response using request headers`() { val spec = """ openapi: 3.0.0 info: title: Sample API version: 0.1.9 paths: - /person: - post: - summary: Add person - requestBody: - required: true - content: - application/json: - schema: - type: object - required: - - name - properties: - name: - type: string + /data: + get: + summary: Get data + parameters: + - in: header + name: X-Trace + schema: + type: string + required: true responses: '200': description: OK headers: - X-Name: - description: ID in the header + X-Trace: + description: Trace id schema: type: string content: @@ -1886,13 +1895,17 @@ components: val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header.yaml") - createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> - val request = HttpRequest("POST", "/person", body = parsedJSONObject("""{"name": "Jane"}""")) + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + val exampleRequest = HttpRequest("GET", "/data", headers = mapOf("X-Trace" to "abc123")) + val exampleResponse = HttpResponse(200, mapOf("X-Trace" to "{{REQUEST.HEADERS.X-Trace}}")) + + HttpStub(feature, listOf(ScenarioStub(exampleRequest, exampleResponse))).use { stub -> + val request = HttpRequest("GET", "/data", headers = mapOf("X-Trace" to "abc123")) val response = stub.client.execute(request) assertThat(response.status).isEqualTo(200) val responseHeaders = response.headers - assertThat(responseHeaders["X-Name"]).isEqualTo("Jane") + assertThat(responseHeaders["X-Trace"]).isEqualTo("abc123") } } From 712bd158dc5f584621172d84253293fe103853c5 Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Wed, 14 Aug 2024 12:56:12 +0530 Subject: [PATCH 08/23] Added test for pull request query params for performing substitution --- .../kotlin/io/specmatic/core/Substitution.kt | 8 ++++ .../kotlin/io/specmatic/stub/HttpStubTest.kt | 48 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/core/src/main/kotlin/io/specmatic/core/Substitution.kt b/core/src/main/kotlin/io/specmatic/core/Substitution.kt index a4d4da18e..f44ac3c89 100644 --- a/core/src/main/kotlin/io/specmatic/core/Substitution.kt +++ b/core/src/main/kotlin/io/specmatic/core/Substitution.kt @@ -46,6 +46,14 @@ class Substitution(val request: HttpRequest) { requestHeaders[requestHeaderName] ?: throw ContractException("Substitution $string cannot be resolved as the request header $requestHeaderName cannot be found") } + "QUERY-PARAMS" -> { + val requestQueryParams = request.queryParams + val requestQueryParamName = payloadPath.joinToString(".") + val queryParamPair = requestQueryParams.paramPairs.find { it.first == requestQueryParamName } + ?: throw ContractException("Substitution $string cannot be resolved as the request query param $requestQueryParamName cannot be found") + + queryParamPair.second + } else -> string } } diff --git a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt index 2e44b2bca..fcba160ce 100644 --- a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt @@ -1893,8 +1893,6 @@ components: type: string """.trimIndent() - val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_substitution_in_response_header.yaml") - val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() val exampleRequest = HttpRequest("GET", "/data", headers = mapOf("X-Trace" to "abc123")) val exampleResponse = HttpResponse(200, mapOf("X-Trace" to "{{REQUEST.HEADERS.X-Trace}}")) @@ -1909,6 +1907,52 @@ components: } } + @Test + fun `stub example with substitution in response using request query params`() { + val spec = """ + openapi: 3.0.0 + info: + title: Sample API + version: 0.1.9 + paths: + /data: + get: + summary: Get data + parameters: + - in: query + name: X-Trace + schema: + type: string + required: true + responses: + '200': + description: OK + headers: + X-Trace: + description: Trace id + schema: + type: string + content: + text/plain: + schema: + type: string + """.trimIndent() + + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + val exampleRequest = HttpRequest("GET", "/data", queryParametersMap = mapOf("X-Trace" to "abc123")) + val exampleResponse = HttpResponse(200, mapOf("X-Trace" to "{{REQUEST.QUERY-PARAMS.X-Trace}}")) + + HttpStub(feature, listOf(ScenarioStub(exampleRequest, exampleResponse))).use { stub -> + val request = HttpRequest("GET", "/data", queryParametersMap = mapOf("X-Trace" to "abc123")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val responseHeaders = response.headers + assertThat(responseHeaders["X-Trace"]).isEqualTo("abc123") + } + } + + @ParameterizedTest @CsvSource("engineering,Bangalore", "sales,Mumbai") fun `stub example with data substitution`(department: String, location: String) { From 6cbfd67295a3703051c41fb429b2c201795f013d Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Wed, 14 Aug 2024 20:53:31 +0530 Subject: [PATCH 09/23] Removed unused code --- .../main/kotlin/io/specmatic/stub/HttpStub.kt | 58 +------------------ 1 file changed, 1 insertion(+), 57 deletions(-) diff --git a/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt index 17edf84d6..ae559c287 100644 --- a/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt @@ -85,64 +85,8 @@ class HttpStub( requestHandlers.add(requestHandler) } - private fun transform(httpStubData: HttpStubData): List { - return listOf(httpStubData) - -// val data: Map>> = httpStubData.data -// -// val combinations: List>>> = combinations(data) -// -// return combinations.map { combination: Map>> -> -// transform(httpStubData, combination) -// } - } - -// private fun transform(httpStubData: HttpStubData, combination: Map>>): HttpStubData { -// val requestBodyPattern = httpStubData.requestType.body -// } - - fun combinations(data: Map>>): List>>> { - // Helper function to calculate the Cartesian product of a list of maps - fun cartesianProduct(maps: List>): List> { - if (maps.isEmpty()) return listOf(emptyMap()) - - val result = mutableListOf>() - val first = maps[0] - val rest = cartesianProduct(maps.drop(1)) - - for ((key, value) in first) { - for (restMap in rest) { - result.add(mapOf(key to value) + restMap) - } - } - - return result - } - - // Creating individual combinations for each top-level key in the input map - val individualCombinations = data.map { entry -> - entry.key to cartesianProduct(entry.value.map { mapOf(it.key to it.value) }) - } - - // Combine the individual combinations across the different top-level keys - val result = cartesianProduct(individualCombinations.map { mapOf(it.first to it.second) }) - - // Transforming the result to match the expected return type - return result.flatMap { combination -> - val flattened = combination.mapValues { (_, listOfMaps) -> - listOfMaps.associate { it.entries.first().toPair() } - } - flattened.values.first().map { flattenedKey -> - mapOf( - flattenedKey.key to flattenedKey.value - ) - }.map { mapOf(flattened.entries.first().key to it) } - } - } - - private fun staticHttpStubData(rawHttpStubs: List): MutableList { - val staticStubs = rawHttpStubs.filter { it.stubToken == null }.flatMap { transform(it) } + val staticStubs = rawHttpStubs.filter { it.stubToken == null } val stubsFromSpecificationExamples: List = features.map { feature -> feature.stubsFromExamples.entries.map { (exampleName, examples) -> From 1cdf094591a3a197d1c22bfec387a84ce442e7ab Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Wed, 14 Aug 2024 20:53:49 +0530 Subject: [PATCH 10/23] Cleaned up accessibility on ScenarioStub methods --- .../main/kotlin/io/specmatic/mock/ScenarioStub.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt index fe8bff19f..dcaaca2df 100644 --- a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt +++ b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt @@ -66,7 +66,7 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: return substitutions } - fun replaceInRequestBody(value: JSONObjectValue, substitutions: Map>>): Value { + private fun replaceInRequestBody(value: JSONObjectValue, substitutions: Map>>): Value { return value.copy( value.jsonObject.mapValues { replaceInRequestBody(it.value, substitutions) @@ -74,7 +74,7 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: ) } - fun replaceInRequestBody(value: JSONArrayValue, substitutions: Map>>): Value { + private fun replaceInRequestBody(value: JSONArrayValue, substitutions: Map>>): Value { return value.copy( value.list.map { replaceInRequestBody(value, substitutions) @@ -82,7 +82,7 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: ) } - fun replaceInRequestBody(value: Value, substitutions: Map>>): Value { + private fun replaceInRequestBody(value: Value, substitutions: Map>>): Value { return when(value) { is StringValue -> { if(value.string.startsWith("{{@") && value.string.endsWith("}}")) { @@ -105,7 +105,7 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: } } - fun replaceInExample(substitutions: Map>>): ScenarioStub { + private fun replaceInExample(substitutions: Map>>): ScenarioStub { val newRequestBody = replaceInRequestBody(request.body, substitutions) val newRequest = request.copy(body = newRequestBody) @@ -118,7 +118,7 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: ) } - fun replaceInResponseBody(value: JSONObjectValue, substitutions: Map>>): Value { + private fun replaceInResponseBody(value: JSONObjectValue, substitutions: Map>>): Value { return value.copy( value.jsonObject.mapValues { replaceInResponseBody(it.value, substitutions, it.key) @@ -126,7 +126,7 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: ) } - fun replaceInResponseBody(value: JSONArrayValue, substitutions: Map>>): Value { + private fun replaceInResponseBody(value: JSONArrayValue, substitutions: Map>>): Value { return value.copy( value.list.map { replaceInResponseBody(value, substitutions) @@ -134,7 +134,7 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: ) } - fun replaceInResponseBody(value: Value, substitutions: Map>>, key: String): Value { + private fun replaceInResponseBody(value: Value, substitutions: Map>>, key: String): Value { return when(value) { is StringValue -> { if(value.string.startsWith("{{@") && value.string.endsWith("}}")) { From ba8829e6eb816f16dc4999a3e9093d066d9393e8 Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Thu, 15 Aug 2024 17:53:24 +0530 Subject: [PATCH 11/23] Implemented substitution of response scalars with request scalars --- .../io/specmatic/core/HttpPathPattern.kt | 2 + .../io/specmatic/core/HttpRequestPattern.kt | 4 + .../kotlin/io/specmatic/core/HttpResponse.kt | 8 - .../io/specmatic/core/HttpResponsePattern.kt | 10 + .../main/kotlin/io/specmatic/core/Scenario.kt | 5 + .../kotlin/io/specmatic/core/Substitution.kt | 81 +++++- .../io/specmatic/core/pattern/AnyPattern.kt | 32 ++- .../core/pattern/Base64StringPattern.kt | 3 + .../specmatic/core/pattern/BinaryPattern.kt | 3 + .../specmatic/core/pattern/BooleanPattern.kt | 8 +- .../io/specmatic/core/pattern/DatePattern.kt | 16 +- .../specmatic/core/pattern/DateTimePattern.kt | 16 +- .../specmatic/core/pattern/DeferredPattern.kt | 5 + .../core/pattern/DictionaryPattern.kt | 16 ++ .../io/specmatic/core/pattern/EmailPattern.kt | 3 + .../io/specmatic/core/pattern/HasException.kt | 11 + .../io/specmatic/core/pattern/HasFailure.kt | 1 + .../core/pattern/JSONObjectPattern.kt | 12 + .../io/specmatic/core/pattern/ListPattern.kt | 16 ++ .../io/specmatic/core/pattern/NullPattern.kt | 16 +- .../specmatic/core/pattern/NumberPattern.kt | 3 + .../io/specmatic/core/pattern/Pattern.kt | 6 + .../io/specmatic/core/pattern/ReturnValue.kt | 10 + .../specmatic/core/pattern/StringPattern.kt | 3 + .../io/specmatic/core/pattern/UUIDPattern.kt | 17 +- .../kotlin/io/specmatic/core/value/Value.kt | 6 + .../io/specmatic/stub/HttpStubResponse.kt | 6 +- .../io/specmatic/core/SubstitutionTest.kt | 10 +- .../kotlin/io/specmatic/stub/HttpStubTest.kt | 247 +++++++++++++++++- 29 files changed, 520 insertions(+), 56 deletions(-) diff --git a/core/src/main/kotlin/io/specmatic/core/HttpPathPattern.kt b/core/src/main/kotlin/io/specmatic/core/HttpPathPattern.kt index e81bc9355..6dd834c25 100644 --- a/core/src/main/kotlin/io/specmatic/core/HttpPathPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/HttpPathPattern.kt @@ -9,6 +9,8 @@ import java.net.URI val OMIT = listOf("(OMIT)", "(omit)") +val EMPTY_PATH= HttpPathPattern(emptyList(), "") + data class HttpPathPattern( val pathSegmentPatterns: List, val path: String diff --git a/core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt b/core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt index 9c998c3aa..4b72b7184 100644 --- a/core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/HttpRequestPattern.kt @@ -731,6 +731,10 @@ data class HttpRequestPattern( } ?: row } + fun getSubstitution(request: HttpRequest, resolver: Resolver): Substitution { + return Substitution(request, httpPathPattern ?: HttpPathPattern(emptyList(), ""), headersPattern, httpQueryParamPattern, body, resolver) + } + } fun missingParam(missingValue: String): ContractException { diff --git a/core/src/main/kotlin/io/specmatic/core/HttpResponse.kt b/core/src/main/kotlin/io/specmatic/core/HttpResponse.kt index 49a421054..dcab8f304 100644 --- a/core/src/main/kotlin/io/specmatic/core/HttpResponse.kt +++ b/core/src/main/kotlin/io/specmatic/core/HttpResponse.kt @@ -167,14 +167,6 @@ data class HttpResponse( } private fun headersHasOnlyTextPlainContentTypeHeader() = headers.size == 1 && headers[CONTENT_TYPE] == "text/plain" - - fun resolveSubstitutions(request: HttpRequest): HttpResponse { - val substitution = Substitution(request) - val newBody = substitution.resolveSubstitutions(this.body) - val newHeaders = substitution.resolveSubstitutions(this.headers) - return this.copy(body = newBody, headers = newHeaders) - } - } fun nativeInteger(json: Map, key: String): Int? { diff --git a/core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt b/core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt index 9d33004f1..3ea95cd76 100644 --- a/core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/HttpResponsePattern.kt @@ -157,6 +157,16 @@ data class HttpResponsePattern( response.body.exactMatchElseType() ) } + + fun resolveSubstitutions(substitution: Substitution, response: HttpResponse): HttpResponse { + val substitutedHeaders = substitution.resolveHeaderSubstitutions(response.headers, headersPattern.pattern).breadCrumb("RESPONSE.HEADERS").value + val substitutedBody = body.resolveSubstitutions(substitution, response.body, substitution.resolver).breadCrumb("RESPONSE.BODY").value + + return response.copy( + headers = substitutedHeaders, + body = substitutedBody + ) + } } private val valueMismatchMessages = object : MismatchMessages { diff --git a/core/src/main/kotlin/io/specmatic/core/Scenario.kt b/core/src/main/kotlin/io/specmatic/core/Scenario.kt index f53988e62..babf316db 100644 --- a/core/src/main/kotlin/io/specmatic/core/Scenario.kt +++ b/core/src/main/kotlin/io/specmatic/core/Scenario.kt @@ -578,6 +578,11 @@ data class Scenario( && operationId.responseStatus == status && httpRequestPattern.matchesPath(operationId.requestPath, resolver).isSuccess() } + + fun resolveSubtitutions(request: HttpRequest, response: HttpResponse): HttpResponse { + val substitution = httpRequestPattern.getSubstitution(request, resolver) + return httpResponsePattern.resolveSubstitutions(substitution, response) + } } fun newExpectedServerStateBasedOn( diff --git a/core/src/main/kotlin/io/specmatic/core/Substitution.kt b/core/src/main/kotlin/io/specmatic/core/Substitution.kt index f44ac3c89..97eac7da9 100644 --- a/core/src/main/kotlin/io/specmatic/core/Substitution.kt +++ b/core/src/main/kotlin/io/specmatic/core/Substitution.kt @@ -1,12 +1,19 @@ package io.specmatic.core -import io.specmatic.core.pattern.ContractException +import io.specmatic.core.pattern.* import io.specmatic.core.value.JSONArrayValue import io.specmatic.core.value.JSONObjectValue import io.specmatic.core.value.StringValue import io.specmatic.core.value.Value -class Substitution(val request: HttpRequest) { +class Substitution( + val request: HttpRequest, + val httpPathPattern: HttpPathPattern, + val headersPattern: HttpHeadersPattern, + val httpQueryParamPattern: HttpQueryParamPattern, + val body: Pattern, + val resolver: Resolver +) { fun resolveSubstitutions(value: Value): Value { return when(value) { is JSONObjectValue -> resolveSubstitutions(value) @@ -21,7 +28,7 @@ class Substitution(val request: HttpRequest) { } } - private fun substitute(string: String): String { + fun substitute(string: String): String { val expressionPath = string.removeSurrounding("{{", "}}") val parts = expressionPath.split(".") @@ -34,26 +41,45 @@ class Substitution(val request: HttpRequest) { val requestPart = requestPath.firstOrNull() ?: throw ContractException("The expression $expressionPath does not include anything after REQUEST to say what has to be substituted") val payloadPath = requestPath.drop(1) - when(requestPart.uppercase()) { + val payloadKey = payloadPath.joinToString(".") + + when (requestPart.uppercase()) { "BODY" -> { val requestJSONBody = request.body as? JSONObjectValue ?: throw ContractException("Substitution $string cannot be resolved as the request body is not an object") - requestJSONBody.findFirstChildByPath(payloadPath)?.toStringLiteral() ?: throw ContractException("Could not find $string in the request body") + requestJSONBody.findFirstChildByPath(payloadPath)?.toStringLiteral() + ?: throw ContractException("Could not find $string in the request body") } + "HEADERS" -> { val requestHeaders = request.headers - val requestHeaderName = payloadPath.joinToString(".") + val requestHeaderName = payloadKey requestHeaders[requestHeaderName] ?: throw ContractException("Substitution $string cannot be resolved as the request header $requestHeaderName cannot be found") } + "QUERY-PARAMS" -> { val requestQueryParams = request.queryParams - val requestQueryParamName = payloadPath.joinToString(".") + val requestQueryParamName = payloadKey val queryParamPair = requestQueryParams.paramPairs.find { it.first == requestQueryParamName } ?: throw ContractException("Substitution $string cannot be resolved as the request query param $requestQueryParamName cannot be found") queryParamPair.second } + + "PATH" -> { + val indexOfPathParam = httpPathPattern.pathSegmentPatterns.indexOfFirst { it.key == payloadKey } + + if (indexOfPathParam < 0) throw ContractException("Could not find path param named $string") + + (request.path ?: "").split("/").let { + if (it.firstOrNull() == "") + it.drop(1) + else + it + }.get(indexOfPathParam) + } + else -> string } } @@ -77,9 +103,44 @@ class Substitution(val request: HttpRequest) { ) } - fun resolveSubstitutions(map: Map): Map { - return map.mapValues { - substitute(it.value) + fun resolveHeaderSubstitutions(map: Map, patternMap: Map): ReturnValue> { + return map.mapValues { (key, value) -> + val string = substitute(value) + + val returnValue = (patternMap.get(key) ?: patternMap.get("$key?"))?.let { pattern -> + try { + HasValue(pattern.parse(string, resolver).toStringLiteral()) + } catch(e: Throwable) { + HasException(e) + } + } ?: HasValue(value) + + returnValue.breadCrumb(key) + }.mapFold() + } + + fun substitute(value: Value, pattern: Pattern): ReturnValue { + return try { + if(value !is StringValue || !hasTemplate(value.string)) + return HasValue(value) + + val updatedString = substitute(value.string) + HasValue(pattern.parse(updatedString, resolver)) + } catch(e: Throwable) { + HasException(e) + } + } + + private fun hasTemplate(string: String): Boolean { + return string.startsWith("{{") && string.endsWith("}}") + } + + fun substitute(string: String, pattern: Pattern): ReturnValue { + return try { + val updatedString = substitute(string) + HasValue(pattern.parse(updatedString, resolver)) + } catch(e: Throwable) { + HasException(e) } } } \ No newline at end of file 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 46efaec73..e74e14b58 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/AnyPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/AnyPattern.kt @@ -1,9 +1,6 @@ package io.specmatic.core.pattern -import io.specmatic.core.MismatchMessages -import io.specmatic.core.Resolver -import io.specmatic.core.Result -import io.specmatic.core.mismatchResult +import io.specmatic.core.* import io.specmatic.core.pattern.config.NegativePatternConfiguration import io.specmatic.core.value.EmptyString import io.specmatic.core.value.NullValue @@ -22,6 +19,33 @@ data class AnyPattern( data class AnyPatternMatch(val pattern: Pattern, val result: Result) + override fun resolveSubstitutions( + substitution: Substitution, + value: Value, + resolver: Resolver + ): ReturnValue { + val options = pattern.map { + it.resolveSubstitutions(substitution, value, resolver) + } + + val hasValue = options.find { it is HasValue } + + if(hasValue != null) + return hasValue + + val failures = options.map { + it.realise( + hasValue = { _, _ -> + throw NotImplementedError() + }, + orFailure = { failure -> failure.failure }, + orException = { exception -> exception.toHasFailure().failure } + ) + } + + return HasFailure(Result.Failure.fromFailures(failures)) + } + override fun matches(sampleData: Value?, resolver: Resolver): Result { val matchResults = pattern.map { AnyPatternMatch(it, resolver.matchesPattern(key, it, sampleData ?: EmptyString)) diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/Base64StringPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/Base64StringPattern.kt index 459d1d8b6..1d4d3ac0b 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/Base64StringPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/Base64StringPattern.kt @@ -12,6 +12,9 @@ import java.util.* data class Base64StringPattern(override val typeAlias: String? = null) : Pattern, ScalarType { override fun matches(sampleData: Value?, resolver: Resolver): Result { + if (sampleData?.hasTemplate() == true) + return Result.Success() + return when (sampleData) { is StringValue -> { return if (Base64.isBase64(sampleData.string)) Result.Success() else mismatchResult("string of bytes (base64)", sampleData, resolver.mismatchMessages) diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/BinaryPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/BinaryPattern.kt index 0f6e242f0..20a203c61 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/BinaryPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/BinaryPattern.kt @@ -15,6 +15,9 @@ data class BinaryPattern( ) : Pattern, ScalarType { override fun matches(sampleData: Value?, resolver: Resolver): Result { + if (sampleData?.hasTemplate() == true) + return Result.Success() + return when (sampleData) { is StringValue -> return Result.Success() else -> mismatchResult("string", sampleData, resolver.mismatchMessages) diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/BooleanPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/BooleanPattern.kt index 63adfb10e..219f89331 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/BooleanPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/BooleanPattern.kt @@ -10,11 +10,15 @@ import io.specmatic.core.value.Value import java.util.* data class BooleanPattern(override val example: String? = null) : Pattern, ScalarType, HasDefaultExample { - override fun matches(sampleData: Value?, resolver: Resolver): Result = - when(sampleData) { + override fun matches(sampleData: Value?, resolver: Resolver): Result { + if (sampleData?.hasTemplate() == true) + return Result.Success() + + return when (sampleData) { is BooleanValue -> Result.Success() else -> mismatchResult("boolean", sampleData, resolver.mismatchMessages) } + } override fun generate(resolver: Resolver): Value = resolver.resolveExample(example, this) ?: randomBoolean() diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/DatePattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/DatePattern.kt index a16932ee6..ee714dea9 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/DatePattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/DatePattern.kt @@ -8,12 +8,18 @@ import io.specmatic.core.value.StringValue import io.specmatic.core.value.Value object DatePattern : Pattern, ScalarType { - override fun matches(sampleData: Value?, resolver: Resolver): Result = when (sampleData) { - is StringValue -> resultOf { - parse(sampleData.string, resolver) - Result.Success() + override fun matches(sampleData: Value?, resolver: Resolver): Result { + if (sampleData?.hasTemplate() == true) + return Result.Success() + + return when (sampleData) { + is StringValue -> resultOf { + parse(sampleData.string, resolver) + Result.Success() + } + + else -> Result.Failure("Date types can only be represented using strings") } - else -> Result.Failure("Date types can only be represented using strings") } override fun generate(resolver: Resolver): StringValue = StringValue(RFC3339.currentDate()) diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/DateTimePattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/DateTimePattern.kt index 2e41bb2fc..8672b0d2d 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/DateTimePattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/DateTimePattern.kt @@ -9,12 +9,18 @@ import io.specmatic.core.value.Value object DateTimePattern : Pattern, ScalarType { - override fun matches(sampleData: Value?, resolver: Resolver): Result = when (sampleData) { - is StringValue -> resultOf { - parse(sampleData.string, resolver) - Result.Success() + override fun matches(sampleData: Value?, resolver: Resolver): Result { + if (sampleData?.hasTemplate() == true) + return Result.Success() + + return when (sampleData) { + is StringValue -> resultOf { + parse(sampleData.string, resolver) + Result.Success() + } + + else -> Result.Failure("DateTime types can only be represented using strings") } - else -> Result.Failure("DateTime types can only be represented using strings") } override fun generate(resolver: Resolver): StringValue = StringValue(RFC3339.currentDateTime()) diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/DeferredPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/DeferredPattern.kt index ad15bff57..45cc87927 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/DeferredPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/DeferredPattern.kt @@ -2,6 +2,7 @@ package io.specmatic.core.pattern import io.specmatic.core.Resolver import io.specmatic.core.Result +import io.specmatic.core.Substitution import io.specmatic.core.pattern.config.NegativePatternConfiguration import io.specmatic.core.value.EmptyString import io.specmatic.core.value.Value @@ -52,6 +53,10 @@ data class DeferredPattern(override val pattern: String, val key: String? = null return resolver.getPattern(pattern).listOf(valueList, resolver) } + override fun resolveSubstitutions(substitution: Substitution, value: Value, resolver: Resolver): ReturnValue { + return resolvePattern(resolver).resolveSubstitutions(substitution, value, resolver) + } + override val typeAlias: String = pattern override fun parse(value: String, resolver: Resolver): Value = diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/DictionaryPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/DictionaryPattern.kt index 448806de9..ae986f5c3 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/DictionaryPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/DictionaryPattern.kt @@ -2,6 +2,7 @@ package io.specmatic.core.pattern import io.specmatic.core.Resolver import io.specmatic.core.Result +import io.specmatic.core.Substitution import io.specmatic.core.mismatchResult import io.specmatic.core.pattern.config.NegativePatternConfiguration import io.specmatic.core.value.JSONArrayValue @@ -10,6 +11,21 @@ import io.specmatic.core.value.StringValue import io.specmatic.core.value.Value data class DictionaryPattern(val keyPattern: Pattern, val valuePattern: Pattern, override val typeAlias: String? = null) : Pattern { + override fun resolveSubstitutions( + substitution: Substitution, + value: Value, + resolver: Resolver + ): ReturnValue { + if(value !is JSONObjectValue) + return HasFailure(Result.Failure("Cannot resolve substitutions, expected object but got ${value.displayableType()}")) + + val updatedMap = value.jsonObject.mapValues { (key, value) -> + valuePattern.resolveSubstitutions(substitution, value, resolver) + } + + return updatedMap.mapFold().ifValue { value.copy(it) } + } + override fun matches(sampleData: Value?, resolver: Resolver): Result { if(sampleData !is JSONObjectValue) return mismatchResult("JSON object", sampleData, resolver.mismatchMessages) diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/EmailPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/EmailPattern.kt index 9c0bcb888..368ccc5d9 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/EmailPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/EmailPattern.kt @@ -23,6 +23,9 @@ class EmailPattern (private val stringPatternDelegate: StringPattern) : } override fun matches(sampleData: Value?, resolver: Resolver): Result { + if (sampleData?.hasTemplate() == true) + return Result.Success() + if (sampleData !is StringValue) return mismatchResult("email string", sampleData, resolver.mismatchMessages) val email = sampleData.toStringLiteral() return if (emailRegex.matches(email)) { diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/HasException.kt b/core/src/main/kotlin/io/specmatic/core/pattern/HasException.kt index 1ecea2727..4e1ed6989 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/HasException.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/HasException.kt @@ -1,6 +1,17 @@ package io.specmatic.core.pattern +import io.specmatic.core.Result +import io.specmatic.core.utilities.exceptionCauseMessage + data class HasException(val t: Throwable, val message: String = "", val breadCrumb: String? = null) : ReturnValue, ReturnFailure { + fun toHasFailure(): HasFailure { + val failure: Result.Failure = Result.Failure( + message = exceptionCauseMessage(t), + breadCrumb = breadCrumb ?: "" + ) + return HasFailure(failure, message) + } + override fun withDefault(default: U, fn: (T) -> U): U { return default } diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/HasFailure.kt b/core/src/main/kotlin/io/specmatic/core/pattern/HasFailure.kt index 64ac9d0bf..cd1ab70ef 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/HasFailure.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/HasFailure.kt @@ -3,6 +3,7 @@ package io.specmatic.core.pattern import io.specmatic.core.Result data class HasFailure(val failure: Result.Failure, val message: String = "") : ReturnValue, ReturnFailure { + override fun withDefault(default: U, fn: (T) -> U): U { return default } diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt index a233d2b14..ec556d650 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt @@ -34,6 +34,18 @@ data class JSONObjectPattern( else -> false } + override fun resolveSubstitutions(substitution: Substitution, value: Value, resolver: Resolver): ReturnValue { + if(value !is JSONObjectValue) + return HasFailure(Result.Failure("Cannot resolve substitutions, expected object but got ${value.displayableType()}")) + + val updatedMap = value.jsonObject.mapValues { (key, value) -> + val pattern = pattern.get(key) ?: pattern.getValue("$key?") + pattern.resolveSubstitutions(substitution, value, resolver).breadCrumb(key) + } + + return updatedMap.mapFold().ifValue { value.copy(it) } + } + override fun encompasses( otherPattern: Pattern, thisResolver: Resolver, diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt index ce9832ab8..1747a83d7 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt @@ -2,6 +2,7 @@ package io.specmatic.core.pattern import io.specmatic.core.Resolver import io.specmatic.core.Result +import io.specmatic.core.Substitution import io.specmatic.core.mismatchResult import io.specmatic.core.pattern.config.NegativePatternConfiguration import io.specmatic.core.value.JSONArrayValue @@ -13,6 +14,21 @@ data class ListPattern(override val pattern: Pattern, override val typeAlias: St override val memberList: MemberList get() = MemberList(emptyList(), pattern) + override fun resolveSubstitutions( + substitution: Substitution, + value: Value, + resolver: Resolver + ): ReturnValue { + if(value !is JSONArrayValue) + return HasFailure(Result.Failure("Cannot resolve substitutions, expected list but got ${value.displayableType()}")) + + val updatedList = value.list.mapIndexed { index, listItem -> + pattern.resolveSubstitutions(substitution, listItem, resolver).breadCrumb("[$index]") + }.listFold() + + return updatedList.ifValue { value.copy(list = it) } + } + override fun matches(sampleData: Value?, resolver: Resolver): Result { if(sampleData !is ListValue) return when { diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/NullPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/NullPattern.kt index b2973c114..36f5dbb46 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/NullPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/NullPattern.kt @@ -12,12 +12,16 @@ import io.specmatic.core.value.Value const val NULL_TYPE = "(null)" object NullPattern : Pattern, ScalarType { - override fun matches(sampleData: Value?, resolver: Resolver): Result = - when { - sampleData is NullValue -> Result.Success() - sampleData is StringValue && sampleData.string.isEmpty() -> Result.Success() - else -> mismatchResult("null", sampleData, resolver.mismatchMessages) - } + override fun matches(sampleData: Value?, resolver: Resolver): Result { + if (sampleData?.hasTemplate() == true) + return Result.Success() + + return when { + sampleData is NullValue -> Result.Success() + sampleData is StringValue && sampleData.string.isEmpty() -> Result.Success() + else -> mismatchResult("null", sampleData, resolver.mismatchMessages) + } + } override fun generate(resolver: Resolver): Value = NullValue diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/NumberPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/NumberPattern.kt index b9f233ed5..06641773a 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/NumberPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/NumberPattern.kt @@ -55,6 +55,9 @@ data class NumberPattern( } override fun matches(sampleData: Value?, resolver: Resolver): Result { + if (sampleData?.hasTemplate() == true) + return Result.Success() + if (sampleData !is NumberValue) return mismatchResult("number", sampleData, resolver.mismatchMessages) diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/Pattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/Pattern.kt index 65549b19e..8f170d2e0 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/Pattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/Pattern.kt @@ -1,7 +1,9 @@ package io.specmatic.core.pattern +import io.specmatic.core.HttpResponse import io.specmatic.core.Resolver import io.specmatic.core.Result +import io.specmatic.core.Substitution import io.specmatic.core.pattern.config.NegativePatternConfiguration import io.specmatic.core.value.StringValue import io.specmatic.core.value.Value @@ -66,6 +68,10 @@ interface Pattern { return AnyPattern(listOf(NullPattern, this), example = defaultValue) } + fun resolveSubstitutions(substitution: Substitution, value: Value, resolver: Resolver): ReturnValue { + return substitution.substitute(value, this) + } + val typeAlias: String? val typeName: String val pattern: Any diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/ReturnValue.kt b/core/src/main/kotlin/io/specmatic/core/pattern/ReturnValue.kt index 06bf308ac..5bae796df 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/ReturnValue.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/ReturnValue.kt @@ -47,6 +47,16 @@ fun Map>.mapFold(): ReturnValue List>.listFold(): ReturnValue> { + val initial: ReturnValue> = HasValue>(emptyList()) + + return this.fold(initial) { accR: ReturnValue>, valueR: ReturnValue -> + accR.assimilate(valueR) { acc, value -> + acc.plus(value) + } + } +} + fun Sequence>>.sequenceListFold(): Sequence>> { val data: Sequence>> = this diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/StringPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/StringPattern.kt index 53dba090d..4bb2b5fed 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/StringPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/StringPattern.kt @@ -25,6 +25,9 @@ data class StringPattern ( } override fun matches(sampleData: Value?, resolver: Resolver): Result { + if (sampleData?.hasTemplate() == true) + return Result.Success() + return when (sampleData) { is StringValue -> { if (minLength != null && sampleData.toStringLiteral().length < minLength) return mismatchResult( diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/UUIDPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/UUIDPattern.kt index 59c3d0a28..38173c87b 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/UUIDPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/UUIDPattern.kt @@ -9,13 +9,18 @@ import io.specmatic.core.value.Value import java.util.* object UUIDPattern : Pattern, ScalarType { - override fun matches(sampleData: Value?, resolver: Resolver): Result = when (sampleData) { - is StringValue -> resultOf { - parse(sampleData.string, resolver) - Result.Success() - } + override fun matches(sampleData: Value?, resolver: Resolver): Result { + if (sampleData?.hasTemplate() == true) + return Result.Success() + + return when (sampleData) { + is StringValue -> resultOf { + parse(sampleData.string, resolver) + Result.Success() + } - else -> Result.Failure("UUID types can only be represented using strings") + else -> Result.Failure("UUID types can only be represented using strings") + } } override fun generate(resolver: Resolver): StringValue = StringValue(UUID.randomUUID().toString()) diff --git a/core/src/main/kotlin/io/specmatic/core/value/Value.kt b/core/src/main/kotlin/io/specmatic/core/value/Value.kt index a100ae2bc..5f28befd8 100644 --- a/core/src/main/kotlin/io/specmatic/core/value/Value.kt +++ b/core/src/main/kotlin/io/specmatic/core/value/Value.kt @@ -26,4 +26,10 @@ interface Value { fun typeDeclarationWithoutKey(exampleKey: String, types: Map, exampleDeclarations: ExampleDeclarations): Pair fun typeDeclarationWithKey(key: String, types: Map, exampleDeclarations: ExampleDeclarations): Pair fun listOf(valueList: List): Value + + fun hasTemplate(): Boolean { + return this is StringValue + && this.string.startsWith("{{") + && this.string.endsWith("}}") + } } diff --git a/core/src/main/kotlin/io/specmatic/stub/HttpStubResponse.kt b/core/src/main/kotlin/io/specmatic/stub/HttpStubResponse.kt index 1535a8400..35307eed6 100644 --- a/core/src/main/kotlin/io/specmatic/stub/HttpStubResponse.kt +++ b/core/src/main/kotlin/io/specmatic/stub/HttpStubResponse.kt @@ -13,6 +13,10 @@ data class HttpStubResponse( val scenario: Scenario? = null ) { fun resolveSubstitutions(request: HttpRequest): HttpStubResponse { - return this.copy(response = response.resolveSubstitutions(request)) + val updatedResponse = scenario?.let { + it.resolveSubtitutions(request, response) + } ?: response + + return this.copy(response = updatedResponse) } } \ No newline at end of file diff --git a/core/src/test/kotlin/io/specmatic/core/SubstitutionTest.kt b/core/src/test/kotlin/io/specmatic/core/SubstitutionTest.kt index 45f6f7a63..f5c7f3c11 100644 --- a/core/src/test/kotlin/io/specmatic/core/SubstitutionTest.kt +++ b/core/src/test/kotlin/io/specmatic/core/SubstitutionTest.kt @@ -2,20 +2,20 @@ package io.specmatic.core import io.specmatic.core.pattern.parsedJSONObject import io.specmatic.core.value.JSONObjectValue -import io.specmatic.core.value.StringValue -import io.specmatic.core.value.Value import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test class SubstitutionTest { @Test + @Disabled fun `substitution using request body value`(){ val request = HttpRequest("POST", "/data", body = parsedJSONObject("""{"id": "abc123"}""")) val responseValue = parsedJSONObject("""{"id": "{{REQUEST.BODY.id}}"}""") - val updatedVaue = Substitution(request).resolveSubstitutions(responseValue) as JSONObjectValue - - assertThat(updatedVaue.findFirstChildByPath("id")?.toStringLiteral()).isEqualTo("abc123") +// val updatedVaue = Substitution(request, httpPathPattern, headersPattern, httpQueryParamPattern, body, resolver).resolveSubstitutions(responseValue) as JSONObjectValue +// +// assertThat(updatedVaue.findFirstChildByPath("id")?.toStringLiteral()).isEqualTo("abc123") } } \ No newline at end of file diff --git a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt index fcba160ce..5b9bac2bf 100644 --- a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt @@ -1,5 +1,6 @@ package io.specmatic.stub +import io.ktor.http.* import io.specmatic.conversions.OpenApiSpecification import io.specmatic.core.* import io.specmatic.core.pattern.* @@ -1920,7 +1921,7 @@ components: summary: Get data parameters: - in: query - name: X-Trace + name: traceId schema: type: string required: true @@ -1939,11 +1940,11 @@ components: """.trimIndent() val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() - val exampleRequest = HttpRequest("GET", "/data", queryParametersMap = mapOf("X-Trace" to "abc123")) - val exampleResponse = HttpResponse(200, mapOf("X-Trace" to "{{REQUEST.QUERY-PARAMS.X-Trace}}")) + val exampleRequest = HttpRequest("GET", "/data", queryParametersMap = mapOf("traceId" to "abc123")) + val exampleResponse = HttpResponse(200, mapOf("X-Trace" to "{{REQUEST.QUERY-PARAMS.traceId}}")) HttpStub(feature, listOf(ScenarioStub(exampleRequest, exampleResponse))).use { stub -> - val request = HttpRequest("GET", "/data", queryParametersMap = mapOf("X-Trace" to "abc123")) + val request = HttpRequest("GET", "/data", queryParametersMap = mapOf("traceId" to "abc123")) val response = stub.client.execute(request) assertThat(response.status).isEqualTo(200) @@ -1952,6 +1953,244 @@ components: } } + @Test + fun `stub example with substitution in response using request path param`() { + val spec = """ + openapi: 3.0.0 + info: + title: Sample API + version: 0.1.9 + paths: + /data/{id}: + get: + summary: Get data + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - id + - name + properties: + id: + type: string + """.trimIndent() + + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + val exampleRequest = HttpRequest("GET", "/data/abc123") + val exampleResponse = HttpResponse(200, headers = mapOf("Content-Type" to "application/json"), body = parsedJSONObject("""{"id": "{{REQUEST.PATH.id}}"}""")) + + HttpStub(feature, listOf(ScenarioStub(exampleRequest, exampleResponse))).use { stub -> + val request = HttpRequest("GET", "/data/abc123") + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val jsonResponseBody = response.body as JSONObjectValue + assertThat(jsonResponseBody.findFirstChildByPath("id")).isEqualTo(StringValue("abc123")) + } + } + + @Test + fun `type coersion when a stringly request param and the response value are different`() { + val spec = """ + openapi: 3.0.0 + info: + title: Sample API + version: 0.1.9 + paths: + /data/{id}: + get: + summary: Get data + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - id + - name + properties: + id: + type: integer + """.trimIndent() + + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + val exampleRequest = HttpRequest("GET", "/data/123") + val exampleResponse = HttpResponse(200, headers = mapOf("Content-Type" to "application/json"), body = parsedJSONObject("""{"id": "{{REQUEST.PATH.id}}"}""")) + + HttpStub(feature, listOf(ScenarioStub(exampleRequest, exampleResponse))).use { stub -> + val request = HttpRequest("GET", "/data/123") + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val jsonResponseBody = response.body as JSONObjectValue + assertThat(jsonResponseBody.findFirstChildByPath("id")).isEqualTo(NumberValue(123)) + } + } + + @Test + fun `type coersion when a request object field value and the response object field value are different`() { + val spec = """ + openapi: 3.0.0 + info: + title: Sample API + version: 0.1.9 + paths: + /data: + post: + summary: Get data + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - id + - name + properties: + id: + type: integer + """.trimIndent() + + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + val exampleRequest = HttpRequest("POST", "/data", body = parsedJSONObject("""{"id": "123"}""")) + val exampleResponse = HttpResponse(200, headers = mapOf("Content-Type" to "application/json"), body = parsedJSONObject("""{"id": "{{REQUEST.BODY.id}}"}""")) + + HttpStub(feature, listOf(ScenarioStub(exampleRequest, exampleResponse))).use { stub -> + val response = stub.client.execute(exampleRequest) + + assertThat(response.status).isEqualTo(200) + val jsonResponseBody = response.body as JSONObjectValue + assertThat(jsonResponseBody.findFirstChildByPath("id")).isEqualTo(NumberValue(123)) + } + } + + @Test + fun `throw an error when the value in the request body cannot be used in the body due to schema mismatch`() { + val spec = """ + openapi: 3.0.0 + info: + title: Sample API + version: 0.1.9 + paths: + /data: + post: + summary: Get data + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - id + - name + properties: + id: + type: integer + """.trimIndent() + + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + val exampleRequest = HttpRequest("POST", "/data", body = parsedJSONObject("""{"id": "abc"}""")) + val exampleResponse = HttpResponse(200, headers = mapOf("Content-Type" to "application/json"), body = parsedJSONObject("""{"id": "{{REQUEST.BODY.id}}"}""")) + + HttpStub(feature, listOf(ScenarioStub(exampleRequest, exampleResponse))).use { stub -> + val response = stub.client.execute(exampleRequest) + + assertThat(response.status).isEqualTo(400) + assertThat(response.body.toStringLiteral()).contains("RESPONSE.BODY.id") + } + } + + @Test + fun `throw an error when the value in the request header cannot be used in the body due to schema mismatch`() { + val spec = """ + openapi: 3.0.0 + info: + title: Sample API + version: 0.1.9 + paths: + /data: + post: + summary: Get data + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: string + responses: + '200': + description: OK + headers: + X-Id: + description: id from the body + schema: + type: integer + content: + text/plain: + schema: + type: string + """.trimIndent() + + val feature = OpenApiSpecification.fromYAML(spec, "").toFeature() + val exampleRequest = HttpRequest("POST", "/data", body = parsedJSONObject("""{"id": "abc"}""")) + val exampleResponse = HttpResponse(200, headers = mapOf("Content-Type" to "text/plain", "X-Id" to "{{REQUEST.BODY.id}}"), body = StringValue("success")) + + HttpStub(feature, listOf(ScenarioStub(exampleRequest, exampleResponse))).use { stub -> + val response = stub.client.execute(exampleRequest) + + assertThat(response.status).isEqualTo(400) + assertThat(response.body.toStringLiteral()).contains("RESPONSE.HEADERS.X-Id") + } + } @ParameterizedTest @CsvSource("engineering,Bangalore", "sales,Mumbai") From 418e3cf73d815bab0f9db1a4a71398edc3b8bf19 Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Thu, 15 Aug 2024 19:56:59 +0530 Subject: [PATCH 12/23] Implemented typed reading of key for template substitution --- .../io/specmatic/core/pattern/AnyPattern.kt | 11 ++++- .../specmatic/core/pattern/DeferredPattern.kt | 4 ++ .../core/pattern/DictionaryPattern.kt | 13 ++++++ .../io/specmatic/core/pattern/HasException.kt | 4 +- .../io/specmatic/core/pattern/HasFailure.kt | 4 +- .../io/specmatic/core/pattern/HasValue.kt | 28 ++++++------- .../core/pattern/JSONObjectPattern.kt | 16 ++++++++ .../io/specmatic/core/pattern/ListPattern.kt | 12 ++++++ .../io/specmatic/core/pattern/Pattern.kt | 7 ++++ .../io/specmatic/core/pattern/ReturnValue.kt | 4 +- .../kotlin/io/specmatic/mock/ScenarioStub.kt | 41 ++++++++++++++----- .../main/kotlin/io/specmatic/stub/HttpStub.kt | 14 ++++++- .../kotlin/io/specmatic/stub/HttpStubTest.kt | 16 +++++++- ..._map_substitution_with_int_in_request.yaml | 31 ++++++++++++++ .../substitution.json | 25 +++++++++++ 15 files changed, 196 insertions(+), 34 deletions(-) create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_int_in_request.yaml create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_int_in_request_examples/substitution.json 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 e74e14b58..bebeee0fc 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/AnyPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/AnyPattern.kt @@ -37,7 +37,7 @@ data class AnyPattern( it.realise( hasValue = { _, _ -> throw NotImplementedError() - }, + }, orFailure = { failure -> failure.failure }, orException = { exception -> exception.toHasFailure().failure } ) @@ -46,6 +46,15 @@ data class AnyPattern( return HasFailure(Result.Failure.fromFailures(failures)) } + override fun getTemplateTypes(key: String, value: Value, resolver: Resolver): ReturnValue> { + val initialValue: ReturnValue> = HasValue(emptyMap()) + + return pattern.fold(initialValue) { acc, pattern -> + val templateTypes = pattern.getTemplateTypes("", value, resolver) + acc.assimilate(templateTypes) { data, additional -> data + additional } + } + } + override fun matches(sampleData: Value?, resolver: Resolver): Result { val matchResults = pattern.map { AnyPatternMatch(it, resolver.matchesPattern(key, it, sampleData ?: EmptyString)) diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/DeferredPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/DeferredPattern.kt index 45cc87927..5b1d6729b 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/DeferredPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/DeferredPattern.kt @@ -57,6 +57,10 @@ data class DeferredPattern(override val pattern: String, val key: String? = null return resolvePattern(resolver).resolveSubstitutions(substitution, value, resolver) } + override fun getTemplateTypes(key: String, value: Value, resolver: Resolver): ReturnValue> { + return resolvePattern(resolver).getTemplateTypes(key, value, resolver) + } + override val typeAlias: String = pattern override fun parse(value: String, resolver: Resolver): Value = diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/DictionaryPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/DictionaryPattern.kt index ae986f5c3..749e9c6fd 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/DictionaryPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/DictionaryPattern.kt @@ -26,6 +26,19 @@ data class DictionaryPattern(val keyPattern: Pattern, val valuePattern: Pattern, return updatedMap.mapFold().ifValue { value.copy(it) } } + override fun getTemplateTypes(key: String, value: Value, resolver: Resolver): ReturnValue> { + if(value !is JSONObjectValue) + return HasFailure(Result.Failure("Cannot resolve substitutions, expected object but got ${value.displayableType()}")) + + val initialValue: ReturnValue> = HasValue(emptyMap()) + + return value.jsonObject.entries.fold(initialValue) { acc, (key, value) -> + val patternMap = valuePattern.getTemplateTypes(key, value, resolver) + + acc.assimilate(patternMap) { data, additional -> data + additional } + } + } + override fun matches(sampleData: Value?, resolver: Resolver): Result { if(sampleData !is JSONObjectValue) return mismatchResult("JSON object", sampleData, resolver.mismatchMessages) diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/HasException.kt b/core/src/main/kotlin/io/specmatic/core/pattern/HasException.kt index 4e1ed6989..afdff1bbc 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/HasException.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/HasException.kt @@ -25,11 +25,11 @@ data class HasException(val t: Throwable, val message: String = "", val bread return this } - override fun assimilate(valueResult: ReturnValue, fn: (T, U) -> T): ReturnValue { + override fun assimilate(acc: ReturnValue, fn: (T, U) -> T): ReturnValue { return cast() } - override fun combine(valueResult: ReturnValue, fn: (T, U) -> V): ReturnValue { + override fun combine(acc: ReturnValue, fn: (T, U) -> V): ReturnValue { return cast() } diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/HasFailure.kt b/core/src/main/kotlin/io/specmatic/core/pattern/HasFailure.kt index cd1ab70ef..e8828f92c 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/HasFailure.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/HasFailure.kt @@ -16,11 +16,11 @@ data class HasFailure(val failure: Result.Failure, val message: String = "") return this } - override fun assimilate(valueResult: ReturnValue, fn: (T, U) -> T): ReturnValue { + override fun assimilate(acc: ReturnValue, fn: (T, U) -> T): ReturnValue { return cast() } - override fun combine(valueResult: ReturnValue, fn: (T, U) -> V): ReturnValue { + override fun combine(acc: ReturnValue, fn: (T, U) -> V): ReturnValue { return cast() } diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/HasValue.kt b/core/src/main/kotlin/io/specmatic/core/pattern/HasValue.kt index 0e8f8d64b..2dce8e3aa 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/HasValue.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/HasValue.kt @@ -39,17 +39,17 @@ data class HasValue(override val value: T, val valueDetails: List assimilate(valueResult: ReturnValue, fn: (T, U) -> T): ReturnValue { - if(valueResult is ReturnFailure) - return valueResult.cast() - else if(valueResult is HasException) - return valueResult.cast() + override fun assimilate(acc: ReturnValue, fn: (T, U) -> T): ReturnValue { + if(acc is ReturnFailure) + return acc.cast() + else if(acc is HasException) + return acc.cast() - valueResult as HasValue + acc as HasValue return try { - val newValue = fn(value, valueResult.value) - HasValue(newValue, valueDetails.plus(valueResult.valueDetails)) + val newValue = fn(value, acc.value) + HasValue(newValue, valueDetails.plus(acc.valueDetails)) } catch(t: Throwable) { HasException(t) } @@ -76,15 +76,15 @@ data class HasValue(override val value: T, val valueDetails: List combine(valueResult: ReturnValue, fn: (T, U) -> V): ReturnValue { - if(valueResult is ReturnFailure) - return valueResult.cast() + override fun combine(acc: ReturnValue, fn: (T, U) -> V): ReturnValue { + if(acc is ReturnFailure) + return acc.cast() - valueResult as HasValue + acc as HasValue return try { - val newValue = fn(value, valueResult.value) - HasValue(newValue, valueDetails.plus(valueResult.valueDetails)) + val newValue = fn(value, acc.value) + HasValue(newValue, valueDetails.plus(acc.valueDetails)) } catch(t: Throwable) { HasException(t) } diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt index ec556d650..3d57dfd6f 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt @@ -46,6 +46,22 @@ data class JSONObjectPattern( return updatedMap.mapFold().ifValue { value.copy(it) } } + override fun getTemplateTypes(key: String, value: Value, resolver: Resolver): ReturnValue> { + if(value !is JSONObjectValue) + return HasFailure(Result.Failure("Cannot resolve data substitutions, expected object but got ${value.displayableType()}")) + + val initialValue: ReturnValue> = HasValue(emptyMap()) + + return pattern.mapKeys { + withoutOptionality(it.key) + }.entries.fold(initialValue) { acc, (key, valuePattern) -> + value.jsonObject.get(key)?.let { valueInObject -> + val additionalTemplateTypes = valuePattern.getTemplateTypes(key, valueInObject, resolver) + acc.assimilate(additionalTemplateTypes) { data, additional -> data + additional } + } ?: acc + } + } + override fun encompasses( otherPattern: Pattern, thisResolver: Resolver, diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt index 1747a83d7..0d39fe1bd 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/ListPattern.kt @@ -29,6 +29,18 @@ data class ListPattern(override val pattern: Pattern, override val typeAlias: St return updatedList.ifValue { value.copy(list = it) } } + override fun getTemplateTypes(key: String, value: Value, resolver: Resolver): ReturnValue> { + if(value !is JSONArrayValue) + return HasFailure(Result.Failure("Cannot resolve data substitutions, expected list but got ${value.displayableType()}")) + + val initialValue: ReturnValue> = HasValue(emptyMap()) + return value.list.fold(initialValue) { acc, valuePattern -> + val patterns = pattern.getTemplateTypes("", valuePattern, resolver) + + acc.assimilate(patterns) { data, additional -> data + additional } + } + } + override fun matches(sampleData: Value?, resolver: Resolver): Result { if(sampleData !is ListValue) return when { diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/Pattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/Pattern.kt index 8f170d2e0..f6b395db2 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/Pattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/Pattern.kt @@ -72,6 +72,13 @@ interface Pattern { return substitution.substitute(value, this) } + fun getTemplateTypes(key: String, value: Value, resolver: Resolver): ReturnValue> { + return if(value is StringValue && value.string.startsWith("{{@") && value.string.endsWith("}}")) + HasValue(mapOf(key to this)) + else + HasValue(emptyMap()) + } + val typeAlias: String? val typeName: String val pattern: Any diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/ReturnValue.kt b/core/src/main/kotlin/io/specmatic/core/pattern/ReturnValue.kt index 5bae796df..524e68f46 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/ReturnValue.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/ReturnValue.kt @@ -17,8 +17,8 @@ sealed interface ReturnValue { abstract fun ifValue(fn: (T) -> U): ReturnValue abstract fun ifHasValue(fn: (HasValue) -> ReturnValue): ReturnValue fun update(fn: (T) -> T): ReturnValue - fun assimilate(valueResult: ReturnValue, fn: (T, U) -> T): ReturnValue - fun combine(valueResult: ReturnValue, fn: (T, U) -> V): ReturnValue + fun assimilate(acc: ReturnValue, fn: (T, U) -> T): ReturnValue + fun combine(acc: ReturnValue, fn: (T, U) -> V): ReturnValue fun realise(hasValue: (T, String?) -> U, orFailure: (HasFailure) -> U, orException: (HasException) -> U): U fun addDetails(message: String, breadCrumb: String): ReturnValue } diff --git a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt index dcaaca2df..a89a74b24 100644 --- a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt +++ b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt @@ -2,6 +2,8 @@ package io.specmatic.mock import io.specmatic.core.* import io.specmatic.core.pattern.ContractException +import io.specmatic.core.pattern.Pattern +import io.specmatic.core.pattern.StringPattern import io.specmatic.core.value.* import io.specmatic.stub.stringToMockScenario @@ -36,6 +38,19 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: } } + fun resolveDataSubstitutions(scenario: Scenario): List { + if(data.jsonObject.isEmpty()) + return listOf(this) + + val substitutions = unwrapSubstitutions(data) + + val combinations = combinations(substitutions) + + return combinations.map { combination -> + replaceInExample(combination, scenario.httpRequestPattern.body, scenario.resolver) + } + } + fun resolveDataSubstitutions(): List { if(data.jsonObject.isEmpty()) return listOf(this) @@ -45,7 +60,7 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: val combinations = combinations(substitutions) return combinations.map { combination -> - replaceInExample(combination) + replaceInExample(combination, StringPattern(""), Resolver()) } } @@ -66,23 +81,23 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: return substitutions } - private fun replaceInRequestBody(value: JSONObjectValue, substitutions: Map>>): Value { + private fun replaceInRequestBody(value: JSONObjectValue, substitutions: Map>>, requestTemplatePatterns: Map, resolver: Resolver): Value { return value.copy( value.jsonObject.mapValues { - replaceInRequestBody(it.value, substitutions) + replaceInRequestBody(it.key, it.value, substitutions, requestTemplatePatterns, resolver) } ) } - private fun replaceInRequestBody(value: JSONArrayValue, substitutions: Map>>): Value { + private fun replaceInRequestBody(value: JSONArrayValue, substitutions: Map>>, requestTemplatePatterns: Map, resolver: Resolver): Value { return value.copy( value.list.map { - replaceInRequestBody(value, substitutions) + replaceInRequestBody(value, substitutions, requestTemplatePatterns, resolver) } ) } - private fun replaceInRequestBody(value: Value, substitutions: Map>>): Value { + private fun replaceInRequestBody(key: String, value: Value, substitutions: Map>>, requestTemplatePatterns: Map, resolver: Resolver): Value { return when(value) { is StringValue -> { if(value.string.startsWith("{{@") && value.string.endsWith("}}")) { @@ -91,22 +106,26 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: val substitutionKey = substitutionSet.keys.firstOrNull() ?: throw ContractException("$substitutionSetName in data is empty") - StringValue(substitutionKey) + val pattern = requestTemplatePatterns.getValue(key) + + pattern.parse(substitutionKey, resolver) } else value } is JSONObjectValue -> { - replaceInRequestBody(value, substitutions) + replaceInRequestBody(value, substitutions, requestTemplatePatterns, resolver) } is JSONArrayValue -> { - replaceInRequestBody(value, substitutions) + replaceInRequestBody(value, substitutions, requestTemplatePatterns, resolver) } else -> value } } - private fun replaceInExample(substitutions: Map>>): ScenarioStub { - val newRequestBody = replaceInRequestBody(request.body, substitutions) + private fun replaceInExample(substitutions: Map>>, requestBody: Pattern, resolver: Resolver): ScenarioStub { + val requestTemplatePatterns = requestBody.getTemplateTypes("", request.body, resolver).value + + val newRequestBody = replaceInRequestBody("", request.body, substitutions, requestTemplatePatterns, resolver) val newRequest = request.copy(body = newRequestBody) val newResponseBody = replaceInResponseBody(response.body, substitutions, "") diff --git a/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt index ae559c287..cb3dcdd79 100644 --- a/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt @@ -1008,7 +1008,7 @@ fun stubResponse( } } -fun contractInfoToHttpExpectations(contractInfo: List>>): List { +fun contractInfoToHttpExpectationsOld(contractInfo: List>>): List { return contractInfo.flatMap { (feature, examples) -> examples.flatMap { it.resolveDataSubstitutions() @@ -1018,6 +1018,18 @@ fun contractInfoToHttpExpectations(contractInfo: List>>): List { + return contractInfo.flatMap { (feature, examples) -> + examples.map { example -> + feature.matchingStub(example, ContractAndStubMismatchMessages) to example + }.flatMap { (stubData, example) -> + example.resolveDataSubstitutions(stubData.scenario!!).map { + feature.matchingStub(it, ContractAndStubMismatchMessages) + } + } + } +} + fun badRequest(errorMessage: String?): HttpResponse { return HttpResponse(HttpStatusCode.BadRequest.value, errorMessage, mapOf(SPECMATIC_RESULT_HEADER to "failure")) } diff --git a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt index 5b9bac2bf..e769d91ef 100644 --- a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt @@ -1,6 +1,5 @@ package io.specmatic.stub -import io.ktor.http.* import io.specmatic.conversions.OpenApiSpecification import io.specmatic.core.* import io.specmatic.core.pattern.* @@ -2206,4 +2205,19 @@ components: assertThat(jsonResponse.findFirstChildByPath("location")?.toStringLiteral()).isEqualTo("$location") } } + + @ParameterizedTest + @CsvSource("1,Bangalore", "2,Mumbai") + fun `stub example with data substitution having integer in request`(id: String, location: String) { + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_map_substitution_with_int_in_request.yaml") + + createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> + val request = HttpRequest("POST", "/person", body = parsedJSONObject("""{"id": $id}""")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val jsonResponse = response.body as JSONObjectValue + assertThat(jsonResponse.findFirstChildByPath("location")?.toStringLiteral()).isEqualTo("$location") + } + } } diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_int_in_request.yaml b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_int_in_request.yaml new file mode 100644 index 000000000..648073fbb --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_int_in_request.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - location + properties: + location: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_int_in_request_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_int_in_request_examples/substitution.json new file mode 100644 index 000000000..045687b81 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_int_in_request_examples/substitution.json @@ -0,0 +1,25 @@ +{ + "data": { + "@id": { + "1": { + "location": "Bangalore" + }, + "2": { + "location": "Mumbai" + } + } + }, + "http-request": { + "method": "POST", + "path": "/person", + "body": { + "id": "{{@id}}" + } + }, + "http-response": { + "status": 200, + "body": { + "location": "{{@id}}" + } + } +} \ No newline at end of file From 8f4880333533f250efa64d553d1256b7e0df5a86 Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Thu, 15 Aug 2024 20:18:22 +0530 Subject: [PATCH 13/23] Implemented data substitution in remaining request params --- .../kotlin/io/specmatic/core/value/Value.kt | 8 ++ .../kotlin/io/specmatic/mock/ScenarioStub.kt | 78 ++++++++++++++----- .../main/kotlin/io/specmatic/stub/HttpStub.kt | 10 --- 3 files changed, 68 insertions(+), 28 deletions(-) diff --git a/core/src/main/kotlin/io/specmatic/core/value/Value.kt b/core/src/main/kotlin/io/specmatic/core/value/Value.kt index 5f28befd8..e1ad58973 100644 --- a/core/src/main/kotlin/io/specmatic/core/value/Value.kt +++ b/core/src/main/kotlin/io/specmatic/core/value/Value.kt @@ -32,4 +32,12 @@ interface Value { && this.string.startsWith("{{") && this.string.endsWith("}}") } + + fun hasDataTemplate(): Boolean { + return this is StringValue && this.string.hasDataTemplate() + } } + +fun String.hasDataTemplate(): Boolean { + return this.startsWith("{{@") && this.endsWith("}}") +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt index a89a74b24..9f01fda7d 100644 --- a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt +++ b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt @@ -3,7 +3,6 @@ package io.specmatic.mock import io.specmatic.core.* import io.specmatic.core.pattern.ContractException import io.specmatic.core.pattern.Pattern -import io.specmatic.core.pattern.StringPattern import io.specmatic.core.value.* import io.specmatic.stub.stringToMockScenario @@ -51,19 +50,6 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: } } - fun resolveDataSubstitutions(): List { - if(data.jsonObject.isEmpty()) - return listOf(this) - - val substitutions = unwrapSubstitutions(data) - - val combinations = combinations(substitutions) - - return combinations.map { combination -> - replaceInExample(combination, StringPattern(""), Resolver()) - } - } - private fun unwrapSubstitutions(rawSubstitutions: JSONObjectValue): Map>> { val substitutions = rawSubstitutions.jsonObject.mapValues { val json = @@ -97,10 +83,20 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: ) } + private fun substituteStringInRequest(value: String, substitutions: Map>>): String { + return if(value.hasDataTemplate()) { + val substitutionSetName = value.removeSurrounding("{{", "}}") + val substitutionSet = substitutions[substitutionSetName] ?: throw ContractException("$substitutionSetName does not exist in the data") + + substitutionSet.keys.firstOrNull() ?: throw ContractException("$substitutionSetName in data is empty") + } else + value + } + private fun replaceInRequestBody(key: String, value: Value, substitutions: Map>>, requestTemplatePatterns: Map, resolver: Resolver): Value { return when(value) { is StringValue -> { - if(value.string.startsWith("{{@") && value.string.endsWith("}}")) { + if(value.hasDataTemplate()) { val substitutionSetName = value.string.removeSurrounding("{{", "}}") val substitutionSet = substitutions[substitutionSetName] ?: throw ContractException("$substitutionSetName does not exist in the data") @@ -126,10 +122,20 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: val requestTemplatePatterns = requestBody.getTemplateTypes("", request.body, resolver).value val newRequestBody = replaceInRequestBody("", request.body, substitutions, requestTemplatePatterns, resolver) - val newRequest = request.copy(body = newRequestBody) + val newRequestHeaders = replaceInRequestHeaders(request.headers, substitutions) + val newQueryParams: Map = replaceInRequestQueryParams(request.queryParams, substitutions) + + val newRequest = request.copy( + headers = newRequestHeaders, + queryParams = QueryParameters(newQueryParams), + body = newRequestBody) val newResponseBody = replaceInResponseBody(response.body, substitutions, "") - val newResponse = response.copy(body = newResponseBody) + val newResponseHeaders = replaceInResponseHeaders(response.headers, substitutions) + val newResponse = response.copy( + headers = newResponseHeaders, + body = newResponseBody + ) return copy( request = newRequest, @@ -137,6 +143,30 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: ) } + private fun replaceInResponseHeaders( + headers: Map, + substitutions: Map>> + ): Map { + return headers.mapValues { (key, value) -> + substituteStringInResponse(value, substitutions, key) + } + } + + private fun replaceInRequestQueryParams( + queryParams: QueryParameters, + substitutions: Map>> + ): Map { + return queryParams.asMap().mapValues { (key, value) -> + substituteStringInRequest(value, substitutions) + } + } + + private fun replaceInRequestHeaders(headers: Map, substitutions: Map>>): Map { + return headers.mapValues { (key, value) -> + substituteStringInRequest(value, substitutions) + } + } + private fun replaceInResponseBody(value: JSONObjectValue, substitutions: Map>>): Value { return value.copy( value.jsonObject.mapValues { @@ -153,10 +183,22 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: ) } + private fun substituteStringInResponse(value: String, substitutions: Map>>, key: String): String { + return if(value.hasDataTemplate()) { + val substitutionSetName = value.removeSurrounding("{{", "}}") + val substitutionSet = substitutions[substitutionSetName] ?: throw ContractException("$substitutionSetName does not exist in the data") + + val substitutionValue = substitutionSet.values.first()[key] ?: throw ContractException("$substitutionSetName does not contain a value for $key") + + substitutionValue.toStringLiteral() + } else + value + } + private fun replaceInResponseBody(value: Value, substitutions: Map>>, key: String): Value { return when(value) { is StringValue -> { - if(value.string.startsWith("{{@") && value.string.endsWith("}}")) { + if(value.hasDataTemplate()) { val substitutionSetName = value.string.removeSurrounding("{{", "}}") val substitutionSet = substitutions[substitutionSetName] ?: throw ContractException("$substitutionSetName does not exist in the data") diff --git a/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt index cb3dcdd79..4e7010960 100644 --- a/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt @@ -1008,16 +1008,6 @@ fun stubResponse( } } -fun contractInfoToHttpExpectationsOld(contractInfo: List>>): List { - return contractInfo.flatMap { (feature, examples) -> - examples.flatMap { - it.resolveDataSubstitutions() - }.map { example -> - feature.matchingStub(example, ContractAndStubMismatchMessages) - } - } -} - fun contractInfoToHttpExpectations(contractInfo: List>>): List { return contractInfo.flatMap { (feature, examples) -> examples.map { example -> From e58ab7c35ccb25125340572e68539e6c4457b04d Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Thu, 15 Aug 2024 23:33:46 +0530 Subject: [PATCH 14/23] Added tests for data substitution in query, headers, path --- .../kotlin/io/specmatic/mock/ScenarioStub.kt | 13 +++++- .../kotlin/io/specmatic/stub/HttpStubTest.kt | 19 ++++++++ ...with_map_substitution_in_all_sections.yaml | 43 +++++++++++++++++++ .../substitution.json | 39 +++++++++++++++++ 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_sections.yaml create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_sections_examples/substitution.json diff --git a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt index 9f01fda7d..03e393053 100644 --- a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt +++ b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt @@ -121,11 +121,13 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: private fun replaceInExample(substitutions: Map>>, requestBody: Pattern, resolver: Resolver): ScenarioStub { val requestTemplatePatterns = requestBody.getTemplateTypes("", request.body, resolver).value - val newRequestBody = replaceInRequestBody("", request.body, substitutions, requestTemplatePatterns, resolver) + val newPath = replaceInPath(request.path ?: "", substitutions) val newRequestHeaders = replaceInRequestHeaders(request.headers, substitutions) val newQueryParams: Map = replaceInRequestQueryParams(request.queryParams, substitutions) + val newRequestBody = replaceInRequestBody("", request.body, substitutions, requestTemplatePatterns, resolver) val newRequest = request.copy( + path = newPath, headers = newRequestHeaders, queryParams = QueryParameters(newQueryParams), body = newRequestBody) @@ -143,6 +145,15 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: ) } + private fun replaceInPath(path: String, substitutions: Map>>): String { + val rawPathSegments = path.split("/") + val pathSegments = rawPathSegments.let { if(it.firstOrNull() == "") it.drop(1) else it } + val updatedSegments = pathSegments.map { if(it.hasDataTemplate()) substituteStringInRequest(it, substitutions) else it } + val prefix = if(pathSegments.size != rawPathSegments.size) listOf("") else emptyList() + + return (prefix + updatedSegments).joinToString("/") + } + private fun replaceInResponseHeaders( headers: Map, substitutions: Map>> diff --git a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt index e769d91ef..8ad7519da 100644 --- a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt @@ -2220,4 +2220,23 @@ components: assertThat(jsonResponse.findFirstChildByPath("location")?.toStringLiteral()).isEqualTo("$location") } } + + @Test + fun `data substitution involving all GET request parts and response parts`() { + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_sections.yaml") + + createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> + val request = HttpRequest("GET", "/data/abc", headers = mapOf("X-Routing-Token" to "AB"), queryParametersMap = mapOf("location" to "IN")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + + assertThat(response.headers["X-Region"]).isEqualTo("IN") + + val jsonResponse = response.body as JSONObjectValue + assertThat(jsonResponse.findFirstChildByPath("city")?.toStringLiteral()).isEqualTo("Mumbai") + assertThat(jsonResponse.findFirstChildByPath("currency")?.toStringLiteral()).isEqualTo("INR") + + } + } } diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_sections.yaml b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_sections.yaml new file mode 100644 index 000000000..ef666e05d --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_sections.yaml @@ -0,0 +1,43 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /data/{id}: + get: + summary: Fetch data + parameters: + - in: path + required: true + name: id + schema: + type: string + - in: query + name: location + required: true + schema: + type: string + - in: header + name: X-Routing-Token + required: true + schema: + type: string + responses: + '200': + description: OK + headers: + X-Region: + schema: + type: string + content: + application/json: + schema: + type: object + required: + - name + - currency + properties: + city: + type: string + currency: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_sections_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_sections_examples/substitution.json new file mode 100644 index 000000000..e7403c24d --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_sections_examples/substitution.json @@ -0,0 +1,39 @@ +{ + "data": { + "@token": { + "AB": { + "X-Region": "IN" + } + }, + "@location": { + "IN": { + "currency": "INR" + } + }, + "@id": { + "abc": { + "city": "Mumbai" + } + } + }, + "http-request": { + "method": "GET", + "path": "/data/{{@id}}", + "headers": { + "X-Routing-Token": "{{@token}}" + }, + "query": { + "location": "{{@location}}" + } + }, + "http-response": { + "status": 200, + "headers": { + "X-Region": "{{@token}}" + }, + "body": { + "city": "{{@id}}", + "currency": "{{@location}}" + } + } +} \ No newline at end of file From ce840b16495e65bcdb0b1ae013b273cd465b060f Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Thu, 15 Aug 2024 23:38:06 +0530 Subject: [PATCH 15/23] renamed a spec --- core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt | 2 +- ...yaml => spec_with_map_substitution_in_all_get_sections.yaml} | 0 .../substitution.json | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename core/src/test/resources/openapi/substitutions/{spec_with_map_substitution_in_all_sections.yaml => spec_with_map_substitution_in_all_get_sections.yaml} (100%) rename core/src/test/resources/openapi/substitutions/{spec_with_map_substitution_in_all_sections_examples => spec_with_map_substitution_in_all_get_sections_examples}/substitution.json (100%) diff --git a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt index 8ad7519da..b344a88b7 100644 --- a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt @@ -2223,7 +2223,7 @@ components: @Test fun `data substitution involving all GET request parts and response parts`() { - val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_sections.yaml") + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_get_sections.yaml") createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> val request = HttpRequest("GET", "/data/abc", headers = mapOf("X-Routing-Token" to "AB"), queryParametersMap = mapOf("location" to "IN")) diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_sections.yaml b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_get_sections.yaml similarity index 100% rename from core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_sections.yaml rename to core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_get_sections.yaml diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_sections_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_get_sections_examples/substitution.json similarity index 100% rename from core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_sections_examples/substitution.json rename to core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_get_sections_examples/substitution.json From 6eac4995f976eab0594696443576a24b407429d0 Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Fri, 16 Aug 2024 12:23:17 +0530 Subject: [PATCH 16/23] Fixed support for templating enum strings --- .../io/specmatic/core/pattern/EnumPattern.kt | 10 ++++++ .../kotlin/io/specmatic/core/FeatureTest.kt | 14 ++++++++ .../openapi/spec_with_empoyee_enum.yaml | 35 +++++++++++++++++++ .../example.json | 12 +++++++ ...emplate_substitution_in_response_body.yaml | 31 ++++++++++++++++ version.properties | 2 +- 6 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 core/src/test/resources/openapi/spec_with_empoyee_enum.yaml create mode 100644 core/src/test/resources/openapi/spec_with_empoyee_enum_examples/example.json create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_template_substitution_in_response_body.yaml diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/EnumPattern.kt b/core/src/main/kotlin/io/specmatic/core/pattern/EnumPattern.kt index 84db30ad1..5856f9090 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/EnumPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/EnumPattern.kt @@ -1,8 +1,10 @@ package io.specmatic.core.pattern import io.specmatic.core.Resolver +import io.specmatic.core.Result import io.specmatic.core.pattern.config.NegativePatternConfiguration import io.specmatic.core.value.NullValue +import io.specmatic.core.value.StringValue import io.specmatic.core.value.Value private fun validEnumValues(values: List, key: String?, typeAlias: String?, example: String?, nullable: Boolean): AnyPattern { @@ -39,6 +41,14 @@ data class EnumPattern( nullable: Boolean = false ) : this(validEnumValues(values, key, typeAlias, example, nullable), nullable) + override fun matches(sampleData: Value?, resolver: Resolver): Result { + if(sampleData is StringValue && (sampleData.hasTemplate() || sampleData.hasDataTemplate())) { + return Result.Success() + } + + return pattern.matches(sampleData, resolver) + } + fun withExample(example: String?): EnumPattern { return this.copy(pattern = pattern.copy(example = example)) } diff --git a/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt b/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt index 5af4837d3..0f369d2fb 100644 --- a/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt +++ b/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt @@ -7,6 +7,8 @@ import io.specmatic.core.pattern.parsedJSONObject import io.specmatic.core.utilities.exceptionCauseMessage import io.specmatic.core.value.* import io.specmatic.stub.captureStandardOutput +import io.specmatic.stub.createStub +import io.specmatic.stub.createStubFromContracts import io.specmatic.test.ScenarioTestGenerationException import io.specmatic.test.ScenarioTestGenerationFailure import io.specmatic.test.TestExecutor @@ -2377,6 +2379,18 @@ paths: assertThat(results.success()).withFailMessage(results.report()).isTrue() } + @Test + fun `should be able to stub out enum with string type`() { + createStubFromContracts(listOf("src/test/resources/openapi/spec_with_empoyee_enum.yaml"), timeoutMillis = 0).use { stub -> + val request = HttpRequest("GET", "/person", queryParametersMap = mapOf("type" to "employee")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val responseBody = response.body as JSONObjectValue + assertThat(responseBody.findFirstChildByPath("type")?.toStringLiteral()).isEqualTo("employee") + } + } + companion object { @JvmStatic fun singleFeatureContractSource(): Stream { diff --git a/core/src/test/resources/openapi/spec_with_empoyee_enum.yaml b/core/src/test/resources/openapi/spec_with_empoyee_enum.yaml new file mode 100644 index 000000000..e5e9a1f1c --- /dev/null +++ b/core/src/test/resources/openapi/spec_with_empoyee_enum.yaml @@ -0,0 +1,35 @@ +openapi: 3.0.3 +info: + title: Person API + description: A simple API to fetch person details. + version: 1.0.0 + +paths: + /person: + get: + summary: Fetch person details + description: Retrieve details of a person using their ID. + parameters: + - in: query + name: type + required: true + schema: + $ref: '#/components/schemas/PersonType' + description: The ID of the person to fetch. + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + type: + $ref: '#/components/schemas/PersonType' +components: + schemas: + PersonType: + type: string + enum: + - employee + - manager diff --git a/core/src/test/resources/openapi/spec_with_empoyee_enum_examples/example.json b/core/src/test/resources/openapi/spec_with_empoyee_enum_examples/example.json new file mode 100644 index 000000000..6d8a7c67c --- /dev/null +++ b/core/src/test/resources/openapi/spec_with_empoyee_enum_examples/example.json @@ -0,0 +1,12 @@ +{ + "http-request": { + "method": "GET", + "path": "/person?type=(PersonType)" + }, + "http-response": { + "status": 200, + "body": { + "type": "{{REQUEST.QUERY-PARAMS.type}}" + } + } +} \ No newline at end of file diff --git a/core/src/test/resources/openapi/substitutions/spec_with_template_substitution_in_response_body.yaml b/core/src/test/resources/openapi/substitutions/spec_with_template_substitution_in_response_body.yaml new file mode 100644 index 000000000..a0c4045f5 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_template_substitution_in_response_body.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - department + properties: + department: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - location + properties: + location: + type: string diff --git a/version.properties b/version.properties index 795e9d33f..6eafb4507 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -version=2.0.10 +version=2.0.11 From 546460bea54054746ebd0f4c71cd15d6df0aa987 Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Fri, 16 Aug 2024 12:54:27 +0530 Subject: [PATCH 17/23] Fixed issue with template replacement in arrays --- .../kotlin/io/specmatic/mock/ScenarioStub.kt | 4 +- .../kotlin/io/specmatic/core/FeatureTest.kt | 15 ++++++- .../openapi/spec_with_empoyee_enum.yaml | 5 +++ .../openapi/spec_with_empoyee_enum2.yaml | 40 +++++++++++++++++++ .../example.json | 23 +++++++++++ .../example.json | 1 + 6 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 core/src/test/resources/openapi/spec_with_empoyee_enum2.yaml create mode 100644 core/src/test/resources/openapi/spec_with_empoyee_enum2_examples/example.json diff --git a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt index 03e393053..b9f91f6cc 100644 --- a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt +++ b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt @@ -188,8 +188,8 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: private fun replaceInResponseBody(value: JSONArrayValue, substitutions: Map>>): Value { return value.copy( - value.list.map { - replaceInResponseBody(value, substitutions) + value.list.map { item: Value -> + replaceInResponseBody(item, substitutions, "") } ) } diff --git a/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt b/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt index 0f369d2fb..9206ca3e7 100644 --- a/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt +++ b/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt @@ -2380,7 +2380,7 @@ paths: } @Test - fun `should be able to stub out enum with string type`() { + fun `should be able to stub out enum with string type using substitution`() { createStubFromContracts(listOf("src/test/resources/openapi/spec_with_empoyee_enum.yaml"), timeoutMillis = 0).use { stub -> val request = HttpRequest("GET", "/person", queryParametersMap = mapOf("type" to "employee")) val response = stub.client.execute(request) @@ -2391,6 +2391,19 @@ paths: } } + @Test + fun `should be able to stub out enum with string type using data substitution`() { + createStubFromContracts(listOf("src/test/resources/openapi/spec_with_empoyee_enum2.yaml"), timeoutMillis = 0).use { stub -> + val request = HttpRequest("GET", "/person", queryParametersMap = mapOf("type" to "manager")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + val responseBody = response.body as JSONObjectValue + assertThat(responseBody.findFirstChildByPath("type")?.toStringLiteral()).isEqualTo("manager") + assertThat(responseBody.findFirstChildByPath("name")?.toStringLiteral()).isEqualTo("Justin") + } + } + companion object { @JvmStatic fun singleFeatureContractSource(): Stream { diff --git a/core/src/test/resources/openapi/spec_with_empoyee_enum.yaml b/core/src/test/resources/openapi/spec_with_empoyee_enum.yaml index e5e9a1f1c..4489e90e4 100644 --- a/core/src/test/resources/openapi/spec_with_empoyee_enum.yaml +++ b/core/src/test/resources/openapi/spec_with_empoyee_enum.yaml @@ -23,7 +23,12 @@ paths: application/json: schema: type: object + required: + - name + - type properties: + name: + type: string type: $ref: '#/components/schemas/PersonType' components: diff --git a/core/src/test/resources/openapi/spec_with_empoyee_enum2.yaml b/core/src/test/resources/openapi/spec_with_empoyee_enum2.yaml new file mode 100644 index 000000000..4489e90e4 --- /dev/null +++ b/core/src/test/resources/openapi/spec_with_empoyee_enum2.yaml @@ -0,0 +1,40 @@ +openapi: 3.0.3 +info: + title: Person API + description: A simple API to fetch person details. + version: 1.0.0 + +paths: + /person: + get: + summary: Fetch person details + description: Retrieve details of a person using their ID. + parameters: + - in: query + name: type + required: true + schema: + $ref: '#/components/schemas/PersonType' + description: The ID of the person to fetch. + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + required: + - name + - type + properties: + name: + type: string + type: + $ref: '#/components/schemas/PersonType' +components: + schemas: + PersonType: + type: string + enum: + - employee + - manager diff --git a/core/src/test/resources/openapi/spec_with_empoyee_enum2_examples/example.json b/core/src/test/resources/openapi/spec_with_empoyee_enum2_examples/example.json new file mode 100644 index 000000000..d3740cbe6 --- /dev/null +++ b/core/src/test/resources/openapi/spec_with_empoyee_enum2_examples/example.json @@ -0,0 +1,23 @@ +{ + "data": { + "@personType": { + "manager": { + "name": "Justin" + } + } + }, + "http-request": { + "method": "GET", + "path": "/person", + "query": { + "type": "{{@personType}}" + } + }, + "http-response": { + "status": 200, + "body": { + "name": "{{@personType}}", + "type": "manager" + } + } +} \ No newline at end of file diff --git a/core/src/test/resources/openapi/spec_with_empoyee_enum_examples/example.json b/core/src/test/resources/openapi/spec_with_empoyee_enum_examples/example.json index 6d8a7c67c..b087dc6eb 100644 --- a/core/src/test/resources/openapi/spec_with_empoyee_enum_examples/example.json +++ b/core/src/test/resources/openapi/spec_with_empoyee_enum_examples/example.json @@ -6,6 +6,7 @@ "http-response": { "status": 200, "body": { + "name": "Jill", "type": "{{REQUEST.QUERY-PARAMS.type}}" } } From c521a1aa6c6f467f8e0761545bc89103e1dac371 Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Fri, 16 Aug 2024 15:02:41 +0530 Subject: [PATCH 18/23] Improved error message when specmatic config is not well formed --- core/src/main/kotlin/io/specmatic/core/SpecmaticConfig.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/main/kotlin/io/specmatic/core/SpecmaticConfig.kt b/core/src/main/kotlin/io/specmatic/core/SpecmaticConfig.kt index c1c6606df..4764f6335 100644 --- a/core/src/main/kotlin/io/specmatic/core/SpecmaticConfig.kt +++ b/core/src/main/kotlin/io/specmatic/core/SpecmaticConfig.kt @@ -282,6 +282,7 @@ fun loadSpecmaticConfig(configFileName: String? = null): SpecmaticConfig { logger.log(e, "A dependency version conflict has been detected. If you are using Spring in a maven project, a common resolution is to set the property to your pom project.") throw e } catch (e: Throwable) { + logger.log(e, "Your configuration file may have some missing configuration sections. Please ensure that the $configFileName file adheres to the schema described at: https://specmatic.io/documentation/specmatic_json.html") throw Exception("Your configuration file may have some missing configuration sections. Please ensure that the $configFileName file adheres to the schema described at: https://specmatic.io/documentation/specmatic_json.html", e) } } \ No newline at end of file From b57110bbf96ec2f5d3bfa3dc23e4ef737d534bbd Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Fri, 16 Aug 2024 17:23:30 +0530 Subject: [PATCH 19/23] Show the filename for errors when resolving a templates --- .../kotlin/io/specmatic/mock/ScenarioStub.kt | 7 ++++- .../main/kotlin/io/specmatic/stub/HttpStub.kt | 11 ++++++- core/src/main/kotlin/io/specmatic/stub/api.kt | 6 ++-- .../kotlin/io/specmatic/core/FeatureTest.kt | 17 ++++++++++ .../spec_with_non_existent_data_key.yaml | 31 +++++++++++++++++++ .../substitution.json | 22 +++++++++++++ 6 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_non_existent_data_key.yaml create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_non_existent_data_key_examples/substitution.json diff --git a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt index b9f91f6cc..84a07dccf 100644 --- a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt +++ b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt @@ -5,8 +5,9 @@ import io.specmatic.core.pattern.ContractException import io.specmatic.core.pattern.Pattern import io.specmatic.core.value.* import io.specmatic.stub.stringToMockScenario +import java.io.File -data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: HttpResponse = HttpResponse(0, emptyMap()), val delayInMilliseconds: Long? = null, val stubToken: String? = null, val requestBodyRegex: String? = null, val data: JSONObjectValue = JSONObjectValue()) { +data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: HttpResponse = HttpResponse(0, emptyMap()), val delayInMilliseconds: Long? = null, val stubToken: String? = null, val requestBodyRegex: String? = null, val data: JSONObjectValue = JSONObjectValue(), val filePath: String? = null) { fun toJSON(): JSONObjectValue { val mockInteraction = mutableMapOf() @@ -233,6 +234,10 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: fun parse(text: String): ScenarioStub { return stringToMockScenario(StringValue(text)) } + + fun readFromFile(file: File): ScenarioStub { + return stringToMockScenario(StringValue(file.readText(Charsets.UTF_8))).copy(filePath = file.path) + } } } diff --git a/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt index 4e7010960..304cfc120 100644 --- a/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt @@ -1013,7 +1013,16 @@ fun contractInfoToHttpExpectations(contractInfo: List feature.matchingStub(example, ContractAndStubMismatchMessages) to example }.flatMap { (stubData, example) -> - example.resolveDataSubstitutions(stubData.scenario!!).map { + val examplesWithDataSubstitutionsResolved = try { + example.resolveDataSubstitutions(stubData.scenario!!) + } catch(e: Throwable) { + println() + logger.log(" Error resolving template data for example ${example.filePath}") + logger.log(" " + exceptionCauseMessage(e)) + throw e + } + + examplesWithDataSubstitutionsResolved.map { feature.matchingStub(it, ContractAndStubMismatchMessages) } } diff --git a/core/src/main/kotlin/io/specmatic/stub/api.kt b/core/src/main/kotlin/io/specmatic/stub/api.kt index b17b4cfed..7efbc123e 100644 --- a/core/src/main/kotlin/io/specmatic/stub/api.kt +++ b/core/src/main/kotlin/io/specmatic/stub/api.kt @@ -25,7 +25,7 @@ fun createStubFromContractAndData(contractGherkin: String, dataDirectory: String val mocks = (File(dataDirectory).listFiles()?.filter { it.name.endsWith(".json") } ?: emptyList()).map { file -> consoleLog(StringLog("Loading data from ${file.name}")) - stringToMockScenario(StringValue(file.readText(Charsets.UTF_8))) + ScenarioStub.readFromFile(file) .also { contractBehaviour.matchingStub(it, ContractAndStubMismatchMessages) } @@ -203,7 +203,7 @@ fun loadContractStubsFromImplicitPaths(contractPathDataList: List { diff --git a/core/src/test/resources/openapi/substitutions/spec_with_non_existent_data_key.yaml b/core/src/test/resources/openapi/substitutions/spec_with_non_existent_data_key.yaml new file mode 100644 index 000000000..a0c4045f5 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_non_existent_data_key.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - department + properties: + department: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - location + properties: + location: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_non_existent_data_key_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_non_existent_data_key_examples/substitution.json new file mode 100644 index 000000000..4dac6358f --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_non_existent_data_key_examples/substitution.json @@ -0,0 +1,22 @@ +{ + "data": { + "id": { + "@data": { + "test": "test" + } + } + }, + "http-request": { + "method": "POST", + "path": "/person", + "body": { + "department": "{{@id}}" + } + }, + "http-response": { + "status": 200, + "body": { + "location": "{{@id}}" + } + } +} \ No newline at end of file From 66dffb39bad4d578b35c3fd27e2225f4db42b422 Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Fri, 16 Aug 2024 17:49:18 +0530 Subject: [PATCH 20/23] Added an error message when "data" is missing but the example has templates --- .../kotlin/io/specmatic/mock/ScenarioStub.kt | 19 ++++++++++++ .../kotlin/io/specmatic/core/FeatureTest.kt | 17 ++++++++++ ...with_example_missing_the_data_section.yaml | 31 +++++++++++++++++++ .../example.json | 16 ++++++++++ 4 files changed, 83 insertions(+) create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section.yaml create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section_examples/example.json diff --git a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt index 84a07dccf..148200769 100644 --- a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt +++ b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt @@ -38,7 +38,26 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: } } + fun findPatterns(input: String): Set { + val pattern = """\{\{(@\w+)\}\}""".toRegex() + return pattern.findAll(input).map { it.groupValues[1] }.toSet() + } + + fun requestDataTemplates(): Set { + return findPatterns(request.toLogString()) + } + + fun responseDataTemplates(): Set { + return findPatterns(response.toLogString()) + } + fun resolveDataSubstitutions(scenario: Scenario): List { + val dataTemplates = requestDataTemplates() + responseDataTemplates() + + val missingDataTemplates = dataTemplates.filter { it !in data.jsonObject } + if(missingDataTemplates.isNotEmpty()) + throw ContractException("Could not find the following data templates defined: ${missingDataTemplates.joinToString(", ")}") + if(data.jsonObject.isEmpty()) return listOf(this) diff --git a/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt b/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt index 3dfa32768..99d5de50c 100644 --- a/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt +++ b/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt @@ -2415,12 +2415,29 @@ paths: } } + println(output) + assertThat(output) .contains("Error resolving template data for example") .contains("spec_with_non_existent_data_key_examples/substitution.json") .contains("@id") } + @Test + fun `should flag an error when data substitution keys are not found in @data`() { + val (output, _) = captureStandardOutput { + try { + val stub = createStubFromContracts(listOf("src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section.yaml"), timeoutMillis = 0) + stub.close() + } catch(e: Throwable) { + + } + } + + assertThat(output) + .contains("@id") + } + companion object { @JvmStatic fun singleFeatureContractSource(): Stream { diff --git a/core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section.yaml b/core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section.yaml new file mode 100644 index 000000000..a0c4045f5 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - department + properties: + department: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - location + properties: + location: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section_examples/example.json b/core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section_examples/example.json new file mode 100644 index 000000000..e515b429b --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section_examples/example.json @@ -0,0 +1,16 @@ +{ + "http-request": { + "method": "GET", + "path": "/person", + "body": { + "department": "{{@department}}" + } + }, + "http-response": { + "status": 200, + "body": { + "city": "{{@id}}", + "currency": "{{@department}}" + } + } +} \ No newline at end of file From d0866a477143a5f74207bcdef1fca7993760cc94 Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Fri, 16 Aug 2024 18:27:41 +0530 Subject: [PATCH 21/23] Fixed parsing of data template value of inline query params in external example --- .../kotlin/io/specmatic/core/HttpRequest.kt | 19 ++++++-- .../kotlin/io/specmatic/core/FeatureTest.kt | 2 +- .../example.json | 5 +-- .../substitution.json | 5 +-- ...with_map_substitution_in_query_params.yaml | 43 +++++++++++++++++++ .../substitution.json | 39 +++++++++++++++++ 6 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_query_params.yaml create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_query_params_examples/substitution.json diff --git a/core/src/main/kotlin/io/specmatic/core/HttpRequest.kt b/core/src/main/kotlin/io/specmatic/core/HttpRequest.kt index 07ddbbd8c..d99afe50d 100644 --- a/core/src/main/kotlin/io/specmatic/core/HttpRequest.kt +++ b/core/src/main/kotlin/io/specmatic/core/HttpRequest.kt @@ -68,9 +68,13 @@ data class HttpRequest( val urlParam = URI(path) updateWith(urlParam) } catch (e: URISyntaxException) { - copy(path = path) + val pieces = path.split("?", limit = 2) + updateWithPathAndQuery(pieces.get(0), pieces.getOrNull(1)) +// copy(path = path) } catch (e: UnsupportedEncodingException) { - copy(path = path) + val pieces = path.split("?", limit = 2) + updateWithPathAndQuery(pieces.get(0), pieces.getOrNull(1)) +// copy(path = path) } } @@ -81,8 +85,15 @@ data class HttpRequest( fun updateBody(body: String?): HttpRequest = copy(body = parsedValue(body)) fun updateWith(url: URI): HttpRequest { - val path = url.path - val queryParams = parseQuery(url.query) +// val path = url.path +// val queryParams = parseQuery(url.query) +// return copy(path = path, queryParams = QueryParameters(queryParams)) + + return updateWithPathAndQuery(url.path, url.query) + } + + fun updateWithPathAndQuery(path: String, query: String?): HttpRequest { + val queryParams = parseQuery(query) return copy(path = path, queryParams = QueryParameters(queryParams)) } diff --git a/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt b/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt index 99d5de50c..64320d249 100644 --- a/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt +++ b/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt @@ -2435,7 +2435,7 @@ paths: } assertThat(output) - .contains("@id") + .contains("@department") } companion object { diff --git a/core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section_examples/example.json b/core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section_examples/example.json index e515b429b..f9b7ab299 100644 --- a/core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section_examples/example.json +++ b/core/src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section_examples/example.json @@ -1,6 +1,6 @@ { "http-request": { - "method": "GET", + "method": "POST", "path": "/person", "body": { "department": "{{@department}}" @@ -9,8 +9,7 @@ "http-response": { "status": 200, "body": { - "city": "{{@id}}", - "currency": "{{@department}}" + "location": "{{@department}}" } } } \ No newline at end of file diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_get_sections_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_get_sections_examples/substitution.json index e7403c24d..45d8e3ca0 100644 --- a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_get_sections_examples/substitution.json +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_all_get_sections_examples/substitution.json @@ -18,12 +18,9 @@ }, "http-request": { "method": "GET", - "path": "/data/{{@id}}", + "path": "/data/{{@id}}?location={{@location}}", "headers": { "X-Routing-Token": "{{@token}}" - }, - "query": { - "location": "{{@location}}" } }, "http-response": { diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_query_params.yaml b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_query_params.yaml new file mode 100644 index 000000000..ef666e05d --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_query_params.yaml @@ -0,0 +1,43 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /data/{id}: + get: + summary: Fetch data + parameters: + - in: path + required: true + name: id + schema: + type: string + - in: query + name: location + required: true + schema: + type: string + - in: header + name: X-Routing-Token + required: true + schema: + type: string + responses: + '200': + description: OK + headers: + X-Region: + schema: + type: string + content: + application/json: + schema: + type: object + required: + - name + - currency + properties: + city: + type: string + currency: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_query_params_examples/substitution.json b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_query_params_examples/substitution.json new file mode 100644 index 000000000..e7403c24d --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_in_query_params_examples/substitution.json @@ -0,0 +1,39 @@ +{ + "data": { + "@token": { + "AB": { + "X-Region": "IN" + } + }, + "@location": { + "IN": { + "currency": "INR" + } + }, + "@id": { + "abc": { + "city": "Mumbai" + } + } + }, + "http-request": { + "method": "GET", + "path": "/data/{{@id}}", + "headers": { + "X-Routing-Token": "{{@token}}" + }, + "query": { + "location": "{{@location}}" + } + }, + "http-response": { + "status": 200, + "headers": { + "X-Region": "{{@token}}" + }, + "body": { + "city": "{{@id}}", + "currency": "{{@location}}" + } + } +} \ No newline at end of file From 3c7828e5790439c86102dd175b8703e6bc8af555 Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Fri, 16 Aug 2024 19:32:25 +0530 Subject: [PATCH 22/23] Allow references to a sub-key when dereferencing data --- .../kotlin/io/specmatic/mock/ScenarioStub.kt | 34 +++++++++++++----- .../kotlin/io/specmatic/stub/HttpStubTest.kt | 31 ++++++++++++++++ ...different_lookup_key_in_response_body.yaml | 31 ++++++++++++++++ .../example.json | 22 ++++++++++++ ...fferent_lookup_key_in_response_header.yaml | 35 +++++++++++++++++++ .../example.json | 25 +++++++++++++ 6 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_body.yaml create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_body_examples/example.json create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_header.yaml create mode 100644 core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_header_examples/example.json diff --git a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt index 148200769..60cad74fa 100644 --- a/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt +++ b/core/src/main/kotlin/io/specmatic/mock/ScenarioStub.kt @@ -43,12 +43,16 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: return pattern.findAll(input).map { it.groupValues[1] }.toSet() } + fun dataTemplateNameOnly(wholeDataTemplateName: String): String { + return wholeDataTemplateName.split(".").first() + } + fun requestDataTemplates(): Set { - return findPatterns(request.toLogString()) + return findPatterns(request.toLogString()).map(this::dataTemplateNameOnly).toSet() } fun responseDataTemplates(): Set { - return findPatterns(response.toLogString()) + return findPatterns(response.toLogString()).map(this::dataTemplateNameOnly).toSet() } fun resolveDataSubstitutions(scenario: Scenario): List { @@ -216,24 +220,38 @@ data class ScenarioStub(val request: HttpRequest = HttpRequest(), val response: private fun substituteStringInResponse(value: String, substitutions: Map>>, key: String): String { return if(value.hasDataTemplate()) { - val substitutionSetName = value.removeSurrounding("{{", "}}") - val substitutionSet = substitutions[substitutionSetName] ?: throw ContractException("$substitutionSetName does not exist in the data") + val dataSetIdentifiers = DataSetIdentifiers(value, key) - val substitutionValue = substitutionSet.values.first()[key] ?: throw ContractException("$substitutionSetName does not contain a value for $key") + val substitutionSet = substitutions[dataSetIdentifiers.name] ?: throw ContractException("${dataSetIdentifiers.name} does not exist in the data") + + val substitutionValue = substitutionSet.values.first()[dataSetIdentifiers.key] ?: throw ContractException("${dataSetIdentifiers.name} does not contain a value for ${dataSetIdentifiers.key}") substitutionValue.toStringLiteral() } else value } + class DataSetIdentifiers(rawSetName: String, objectKey: String) { + val name: String + val key: String + + init { + val substitutionSetPieces = rawSetName.removeSurrounding("{{", "}}").split(".") + + name = substitutionSetPieces.getOrNull(0) ?: throw ContractException("Substitution set name {{}} was empty") + key = substitutionSetPieces.getOrNull(1) ?: objectKey + } + } + private fun replaceInResponseBody(value: Value, substitutions: Map>>, key: String): Value { return when(value) { is StringValue -> { if(value.hasDataTemplate()) { - val substitutionSetName = value.string.removeSurrounding("{{", "}}") - val substitutionSet = substitutions[substitutionSetName] ?: throw ContractException("$substitutionSetName does not exist in the data") + val dataSetIdentifiers = DataSetIdentifiers(value.string, key) + + val substitutionSet = substitutions[dataSetIdentifiers.name] ?: throw ContractException("${dataSetIdentifiers.name} does not exist in the data") - val substitutionValue = substitutionSet.values.first()[key] ?: throw ContractException("$substitutionSetName does not contain a value for $key") + val substitutionValue = substitutionSet.values.first()[dataSetIdentifiers.key] ?: throw ContractException("${dataSetIdentifiers.name} does not contain a value for ${dataSetIdentifiers.key}") substitutionValue } else diff --git a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt index b344a88b7..6d6758a79 100644 --- a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt @@ -2239,4 +2239,35 @@ components: } } + + @Test + fun `data substitution with explicitly referenced data key in response body`() { + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_body.yaml") + + createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> + val request = HttpRequest("POST", "/person", body = parsedJSONObject("""{"department": "engineering"}""")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + + val jsonResponse = response.body as JSONObjectValue + assertThat(jsonResponse.findFirstChildByPath("location")?.toStringLiteral()).isEqualTo("Mumbai") + + } + } + + @Test + fun `data substitution with explicitly referenced data key in response header`() { + val specWithSubstitution = osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_header.yaml") + + createStubFromContracts(listOf(specWithSubstitution), timeoutMillis = 0).use { stub -> + val request = HttpRequest("POST", "/person", body = parsedJSONObject("""{"department": "engineering"}""")) + val response = stub.client.execute(request) + + assertThat(response.status).isEqualTo(200) + + assertThat(response.headers["X-Location"]).isEqualTo("Mumbai") + + } + } } diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_body.yaml b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_body.yaml new file mode 100644 index 000000000..a0c4045f5 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_body.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - department + properties: + department: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - location + properties: + location: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_body_examples/example.json b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_body_examples/example.json new file mode 100644 index 000000000..7ecf40461 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_body_examples/example.json @@ -0,0 +1,22 @@ +{ + "data": { + "@dept": { + "engineering": { + "city": "Mumbai" + } + } + }, + "http-request": { + "method": "POST", + "path": "/person", + "body": { + "department": "{{@dept}}" + } + }, + "http-response": { + "status": 200, + "body": { + "location": "{{@dept.city}}" + } + } +} \ No newline at end of file diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_header.yaml b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_header.yaml new file mode 100644 index 000000000..ee7bea502 --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_header.yaml @@ -0,0 +1,35 @@ +openapi: 3.0.0 +info: + title: Sample API + version: 0.1.9 +paths: + /person: + post: + summary: Add person + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - department + properties: + department: + type: string + responses: + '200': + description: OK + headers: + X-Location: + schema: + type: string + content: + application/json: + schema: + type: object + required: + - location + properties: + location: + type: string diff --git a/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_header_examples/example.json b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_header_examples/example.json new file mode 100644 index 000000000..b021e402d --- /dev/null +++ b/core/src/test/resources/openapi/substitutions/spec_with_map_substitution_with_different_lookup_key_in_response_header_examples/example.json @@ -0,0 +1,25 @@ +{ + "data": { + "@dept": { + "engineering": { + "city": "Mumbai" + } + } + }, + "http-request": { + "method": "POST", + "path": "/person", + "body": { + "department": "{{@dept}}" + } + }, + "http-response": { + "status": 200, + "headers": { + "X-Location": "{{@dept.city}}" + }, + "body": { + "location": "{{@dept.city}}" + } + } +} \ No newline at end of file From 3065233b6a28be76b6605211c5e94ea1959228b7 Mon Sep 17 00:00:00 2001 From: Joel Rosario Date: Fri, 16 Aug 2024 20:33:47 +0530 Subject: [PATCH 23/23] Moved tests from FeatureTest to HttpStubTest, fixed test failing due to platform-specific path --- .../kotlin/io/specmatic/core/FeatureTest.kt | 35 +------------------ .../kotlin/io/specmatic/stub/HttpStubTest.kt | 34 ++++++++++++++++++ 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt b/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt index 64320d249..03a5e7734 100644 --- a/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt +++ b/core/src/test/kotlin/io/specmatic/core/FeatureTest.kt @@ -6,6 +6,7 @@ import io.specmatic.core.pattern.StringPattern import io.specmatic.core.pattern.parsedJSONObject import io.specmatic.core.utilities.exceptionCauseMessage import io.specmatic.core.value.* +import io.specmatic.osAgnosticPath import io.specmatic.stub.captureStandardOutput import io.specmatic.stub.createStub import io.specmatic.stub.createStubFromContracts @@ -2404,40 +2405,6 @@ paths: } } - @Test - fun `should log the file in which a substitution error has occurred`() { - val (output, _) = captureStandardOutput { - try { - val stub = createStubFromContracts(listOf("src/test/resources/openapi/substitutions/spec_with_non_existent_data_key.yaml"), timeoutMillis = 0) - stub.close() - } catch(e: Throwable) { - - } - } - - println(output) - - assertThat(output) - .contains("Error resolving template data for example") - .contains("spec_with_non_existent_data_key_examples/substitution.json") - .contains("@id") - } - - @Test - fun `should flag an error when data substitution keys are not found in @data`() { - val (output, _) = captureStandardOutput { - try { - val stub = createStubFromContracts(listOf("src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section.yaml"), timeoutMillis = 0) - stub.close() - } catch(e: Throwable) { - - } - } - - assertThat(output) - .contains("@department") - } - companion object { @JvmStatic fun singleFeatureContractSource(): Stream { diff --git a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt index 6d6758a79..23df95aac 100644 --- a/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/HttpStubTest.kt @@ -2270,4 +2270,38 @@ components: } } + + @Test + fun `should log the file in which a substitution error has occurred`() { + val (output, _) = captureStandardOutput { + try { + val stub = createStubFromContracts(listOf(osAgnosticPath("src/test/resources/openapi/substitutions/spec_with_non_existent_data_key.yaml")), timeoutMillis = 0) + stub.close() + } catch(e: Throwable) { + + } + } + + println(output) + + assertThat(output) + .contains("Error resolving template data for example") + .contains(osAgnosticPath("spec_with_non_existent_data_key_examples/substitution.json")) + .contains("@id") + } + + @Test + fun `should flag an error when data substitution keys are not found in @data`() { + val (output, _) = captureStandardOutput { + try { + val stub = createStubFromContracts(listOf(("src/test/resources/openapi/substitutions/spec_with_example_missing_the_data_section.yaml")), timeoutMillis = 0) + stub.close() + } catch(e: Throwable) { + + } + } + + assertThat(output) + .contains("@department") + } }