From d3eb6f10e964439d486b646066193718c67c03b7 Mon Sep 17 00:00:00 2001 From: Sufiyan Date: Fri, 3 Jan 2025 13:14:14 +0530 Subject: [PATCH 1/8] Non-patchable key errors should result in 422 response. - When a non-patchable key is being patched with a new value VS should return a 422 error response or fallback to 400 --- .../stub/stateful/StatefulHttpStub.kt | 44 ++++++++++++---- .../stub/stateful/StatefulHttpStubTest.kt | 52 ++++++++++++++----- .../spec_with_strictly_restful_apis.yaml | 14 +++++ 3 files changed, 86 insertions(+), 24 deletions(-) diff --git a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt index e605d84d9..acadfb33a 100644 --- a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt @@ -11,14 +11,7 @@ import io.ktor.server.response.* import io.ktor.server.routing.* import io.specmatic.conversions.OpenApiSpecification import io.specmatic.conversions.OpenApiSpecification.Companion.applyOverlay -import io.specmatic.core.Feature -import io.specmatic.core.HttpRequest -import io.specmatic.core.HttpRequestPattern -import io.specmatic.core.HttpResponse -import io.specmatic.core.Resolver -import io.specmatic.core.Scenario -import io.specmatic.core.SpecmaticConfig -import io.specmatic.core.loadSpecmaticConfig +import io.specmatic.core.* import io.specmatic.core.log.HttpLogMessage import io.specmatic.core.log.logger import io.specmatic.core.pattern.ContractException @@ -181,7 +174,7 @@ class StatefulHttpStub( fakeResponse, httpRequest, specmaticConfig.stub.includeMandatoryAndRequestedKeysInResponse, - responses.responseWithStatusCodeStartingWith("404")?.successResponse?.responseBodyPattern + responses ) ?: generateHttpResponseFrom(fakeResponse, httpRequest) return FoundStubbedResponse( @@ -251,7 +244,7 @@ class StatefulHttpStub( fakeResponse: ResponseDetails, httpRequest: HttpRequest, includeMandatoryAndRequestedKeysInResponse: Boolean?, - notFoundResponseBodyPattern: Pattern? + responses: Map = emptyMap() ): HttpResponse? { val scenario = fakeResponse.successResponse?.scenario @@ -267,7 +260,7 @@ class StatefulHttpStub( scenario?.getFieldsToBeMadeMandatoryBasedOnAttributeSelection(httpRequest.queryParams).orEmpty() val notFoundResponse = generate4xxResponseWithMessage( - notFoundResponseBodyPattern, + responses.responseWithStatusCodeStartingWith("404")?.successResponse?.responseBodyPattern, scenario, message = "Resource with resourceId '$resourceId' not found", statusCode = 404 @@ -296,6 +289,23 @@ class StatefulHttpStub( } if(method == "PATCH" && pathSegments.size > 1) { + val existingEntity = stubCache.findResponseFor(resourcePath, resourceIdKey, resourceId)?.responseBody + val result = existingEntity?.validateNonPatchableKeys(httpRequest, specmaticConfig.virtualService.nonPatchableKeys) + + if (result is Result.Failure) { + val unprocessableEntity = responses.responseWithStatusCodeStartingWith("422") + val (errorStatusCode, errorResponseBodyPattern) = if (unprocessableEntity?.successResponse != null) { + Pair(422, unprocessableEntity.successResponse.responseBodyPattern) + } else Pair(400, responses.responseWithStatusCodeStartingWith("400")?.successResponse?.responseBodyPattern) + + return generate4xxResponseWithMessage( + errorResponseBodyPattern, + scenario, + result.reportString(), + errorStatusCode + ) + } + val responseBody = generatePatchResponse( httpRequest, @@ -617,4 +627,16 @@ class StatefulHttpStub( }.toMap() }.flatMap { map -> map.entries.map { it.toPair() } }.toMap() } + + private fun JSONObjectValue.validateNonPatchableKeys(httpRequest: HttpRequest, keysToLookFor: Set): Result { + if (httpRequest.body !is JSONObjectValue) return Result.Success() + + val results = keysToLookFor.filter { it in this.jsonObject.keys }.mapNotNull { key -> + if (this.jsonObject.getValue(key).toStringLiteral() != httpRequest.body.jsonObject.getValue(key).toStringLiteral()) { + Result.Failure(breadCrumb = key, message = "Key ${key.quote()} is not patchable") + } else null + } + + return Result.fromResults(results).breadCrumb("REQUEST.BODY") + } } \ No newline at end of file diff --git a/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt index 04c194f72..d30c7a279 100644 --- a/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt @@ -143,7 +143,7 @@ class StatefulHttpStubTest { @Test @Order(4) - fun `should update an existing product with patch except for the non-patchable 'description' key`() { + fun `should update an existing product with patch and ignore non-patchable keys if value remains the same`() { val response = httpStub.client.execute( HttpRequest( method = "PATCH", @@ -151,9 +151,9 @@ class StatefulHttpStubTest { body = parsedJSONObject( """ { - "name": "Product B", - "price": 100, - "description": "random description" + "name": "Product A", + "description": "A detailed description of Product A.", + "price": 100 } """.trimIndent() ) @@ -164,14 +164,40 @@ class StatefulHttpStubTest { val responseBody = response.body as JSONObjectValue assertThat(responseBody.getStringValue("id")).isEqualTo(resourceId) - assertThat(responseBody.getStringValue("name")).isEqualTo("Product B") - assertThat(responseBody.getStringValue("price")).isEqualTo("100") + assertThat(responseBody.getStringValue("name")).isEqualTo("Product A") assertThat(responseBody.getStringValue("description")).isEqualTo("A detailed description of Product A.") + assertThat(responseBody.getStringValue("price")).isEqualTo("100") assertThat(responseBody.getStringValue("inStock")).isEqualTo("true") } @Test @Order(5) + fun `should get a 422 response when trying to patch a non-patchable key with a new value`() { + val response = httpStub.client.execute( + HttpRequest( + method = "PATCH", + path = "/products/$resourceId", + body = parsedJSONObject( + """ + { + "name": "Product B", + "description": "A detailed description of Product B." + } + """.trimIndent() + ) + ) + ) + + println(response.toLogString()) + assertThat(response.status).isEqualTo(422) + assertThat(response.body.toStringLiteral()) + .contains("error") + .contains(">> REQUEST.BODY.description") + .contains("""Key \"description\" is not patchable""") + } + + @Test + @Order(6) fun `should get the updated product`() { val response = httpStub.client.execute( HttpRequest( @@ -184,14 +210,14 @@ class StatefulHttpStubTest { val responseBody = response.body as JSONObjectValue assertThat(responseBody.getStringValue("id")).isEqualTo(resourceId) - assertThat(responseBody.getStringValue("name")).isEqualTo("Product B") + assertThat(responseBody.getStringValue("name")).isEqualTo("Product A") assertThat(responseBody.getStringValue("price")).isEqualTo("100") assertThat(responseBody.getStringValue("description")).isEqualTo("A detailed description of Product A.") assertThat(responseBody.getStringValue("inStock")).isEqualTo("true") } @Test - @Order(6) + @Order(7) fun `should delete a product`() { val response = httpStub.client.execute( HttpRequest( @@ -213,7 +239,7 @@ class StatefulHttpStubTest { } @Test - @Order(7) + @Order(8) fun `should post a product even though the request contains unknown keys`() { val response = httpStub.client.execute( HttpRequest( @@ -244,7 +270,7 @@ class StatefulHttpStubTest { } @Test - @Order(8) + @Order(9) fun `should get a 400 response in a structured manner for an invalid post request`() { val response = httpStub.client.execute( HttpRequest( @@ -270,7 +296,7 @@ class StatefulHttpStubTest { assertThat(error).contains("Contract expected boolean but request contained \"true\"") } - @Order(9) + @Order(10) @Test fun `should get a 400 response as a string for an invalid get request where 400 schema is not defined for the same in the spec`() { val response = httpStub.client.execute( @@ -288,7 +314,7 @@ class StatefulHttpStubTest { } @Test - @Order(10) + @Order(11) fun `should get a 404 response in a structured manner for a get request where the entry with requested id is not present in the cache`() { val response = httpStub.client.execute( HttpRequest( @@ -304,7 +330,7 @@ class StatefulHttpStubTest { } @Test - @Order(11) + @Order(12) fun `should get a 404 response as a string for a delete request with missing id where 404 schema is not defined for the same in the spec`() { val response = httpStub.client.execute( HttpRequest( diff --git a/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis.yaml b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis.yaml index 43198694c..3a2839533 100644 --- a/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis.yaml +++ b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis.yaml @@ -120,6 +120,12 @@ paths: application/json: schema: $ref: '#/components/schemas/Product' + '422': + description: Product update failed + content: + application/json: + schema: + $ref: '#/components/schemas/UnprocessableEntityError' '404': description: Product not found @@ -221,3 +227,11 @@ components: type: string reason: type: string + + UnprocessableEntityError: + type: object + properties: + error: + type: string + reason: + type: string From 2243396bbe67e30ae0294f6aa8323d449cfce454 Mon Sep 17 00:00:00 2001 From: Sufiyan Date: Fri, 3 Jan 2025 16:20:32 +0530 Subject: [PATCH 2/8] Should be able to filter on extended keys. --- .../stub/stateful/StatefulHttpStub.kt | 8 +- .../io/specmatic/stub/stateful/StubCache.kt | 2 +- .../stub/stateful/StatefulHttpStubTest.kt | 130 ++++++++++++++++++ 3 files changed, 137 insertions(+), 3 deletions(-) diff --git a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt index acadfb33a..b00598c27 100644 --- a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt @@ -323,7 +323,7 @@ class StatefulHttpStub( val responseBody = stubCache.findAllResponsesFor( resourcePath, attributeSelectionKeys, - httpRequest.queryParams.asMap() + httpRequest.getAttributeFilters(scenario) ) return generatedResponse.withUpdated(responseBody, attributeSelectionKeys) } @@ -631,7 +631,7 @@ class StatefulHttpStub( private fun JSONObjectValue.validateNonPatchableKeys(httpRequest: HttpRequest, keysToLookFor: Set): Result { if (httpRequest.body !is JSONObjectValue) return Result.Success() - val results = keysToLookFor.filter { it in this.jsonObject.keys }.mapNotNull { key -> + val results = keysToLookFor.filter { it in this.jsonObject.keys && it in httpRequest.body.jsonObject.keys }.mapNotNull { key -> if (this.jsonObject.getValue(key).toStringLiteral() != httpRequest.body.jsonObject.getValue(key).toStringLiteral()) { Result.Failure(breadCrumb = key, message = "Key ${key.quote()} is not patchable") } else null @@ -639,4 +639,8 @@ class StatefulHttpStub( return Result.fromResults(results).breadCrumb("REQUEST.BODY") } + + private fun HttpRequest.getAttributeFilters(scenario: Scenario): Map { + return this.queryParams.asMap().filterKeys { it != scenario.attributeSelectionPattern.queryParamKey } + } } \ No newline at end of file diff --git a/core/src/main/kotlin/io/specmatic/stub/stateful/StubCache.kt b/core/src/main/kotlin/io/specmatic/stub/stateful/StubCache.kt index d1e329f30..c5ff70484 100644 --- a/core/src/main/kotlin/io/specmatic/stub/stateful/StubCache.kt +++ b/core/src/main/kotlin/io/specmatic/stub/stateful/StubCache.kt @@ -80,7 +80,7 @@ class StubCache { if(filter.isEmpty()) return true return filter.all { (filterKey, filterValue) -> - if(this.containsKey(filterKey).not()) return@all true + if(this.containsKey(filterKey).not()) return@all false val actualValue = this.getValue(filterKey) actualValue.toStringLiteral() == filterValue diff --git a/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt index d30c7a279..b73e7ede4 100644 --- a/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt @@ -4,7 +4,9 @@ import io.specmatic.conversions.OpenApiSpecification import io.specmatic.core.* import io.specmatic.core.pattern.parsedJSONObject import io.specmatic.core.utilities.ContractPathData +import io.specmatic.core.utilities.Flags.Companion.EXTENSIBLE_SCHEMA import io.specmatic.core.value.* +import io.specmatic.mock.ScenarioStub import io.specmatic.stub.ContractStub import io.specmatic.stub.loadContractStubsFromImplicitPaths import org.assertj.core.api.Assertions.assertThat @@ -14,6 +16,7 @@ import org.junit.jupiter.api.MethodOrderer import org.junit.jupiter.api.Order import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestMethodOrder +import java.io.File import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors @@ -500,6 +503,133 @@ class StatefulHttpStubWithAttributeSelectionTest { } } +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) +class StatefulHttpStubAttributeFilteringTest { + companion object { + private lateinit var httpStub: ContractStub + private val API_DIR = File("src/test/resources/openapi/spec_with_strictly_restful_apis") + private val API_SPEC = API_DIR.resolve("spec_with_strictly_restful_apis.yaml") + private val POST_EXAMPLE = API_DIR.resolve("spec_with_strictly_restful_apis_examples/post_iphone_product_with_id_300.json") + + private fun getExtendedPostExample(): ScenarioStub { + val example = ScenarioStub.readFromFile(POST_EXAMPLE) + val updatedResponse = example.response.updateBody( + JSONObjectValue((example.response.body as JSONObjectValue).jsonObject.plus(mapOf("extraKey" to StringValue("extraValue")))) + ) + return example.copy(response = updatedResponse) + } + + @JvmStatic + @BeforeAll + fun setup() { + System.setProperty(EXTENSIBLE_SCHEMA, "true") + val extendedPostExample = getExtendedPostExample() + val feature = OpenApiSpecification.fromFile(API_SPEC.canonicalPath).toFeature() + val scenariosWithAttrSelection = feature.scenarios.map { + it.copy(attributeSelectionPattern = AttributeSelectionPattern(queryParamKey = "columns")) + } + httpStub = StatefulHttpStub( + features = listOf(feature.copy(scenarios = scenariosWithAttrSelection)), + scenarioStubs = listOf(extendedPostExample) + ) + } + + @JvmStatic + @AfterAll + fun tearDown() { + httpStub.close() + System.clearProperty(EXTENSIBLE_SCHEMA) + } + } + + @Test + fun `should get the list of products`() { + val response = httpStub.client.execute( + HttpRequest( + method = "GET", + path = "/products" + ) + ) + + assertThat(response.status).isEqualTo(200) + assertThat(response.body).isInstanceOf(JSONArrayValue::class.java) + + val responseBody = response.body as JSONArrayValue + assertThat(responseBody.list.size).isEqualTo(1) + } + + @Test + fun `should be able to filter based on attributes`() { + val response = httpStub.client.execute( + HttpRequest( + method = "GET", + path = "/products", + queryParams = QueryParameters(mapOf("name" to "iPhone 16")), + ) + ) + + assertThat(response.status).isEqualTo(200) + assertThat(response.body).isInstanceOf(JSONArrayValue::class.java) + + val responseBody = response.body as JSONArrayValue + assertThat(responseBody.list.size).isEqualTo(1) + assertThat((responseBody.list.first() as JSONObjectValue).findFirstChildByPath("name")!!.toStringLiteral()).isEqualTo("iPhone 16") + } + + @Test + fun `should be able to filter based on extra attributes not in the spec`() { + val response = httpStub.client.execute( + HttpRequest( + method = "GET", + path = "/products", + queryParams = QueryParameters(mapOf("extraKey" to "extraValue")), + ) + ) + + assertThat(response.status).isEqualTo(200) + assertThat(response.body).isInstanceOf(JSONArrayValue::class.java) + + val responseBody = response.body as JSONArrayValue + assertThat(responseBody.list.size).isEqualTo(1) + assertThat((responseBody.list.first() as JSONObjectValue).findFirstChildByPath("extraKey")!!.toStringLiteral()).isEqualTo("extraValue") + } + + @Test + fun `should return an empty array if no products match the filter`() { + val response = httpStub.client.execute( + HttpRequest( + method = "GET", + path = "/products", + queryParams = QueryParameters(mapOf("name" to "Xiaomi")), + ) + ) + + assertThat(response.status).isEqualTo(200) + assertThat(response.body).isInstanceOf(JSONArrayValue::class.java) + + val responseBody = response.body as JSONArrayValue + assertThat(responseBody.list.size).isEqualTo(0) + } + + @Test + fun `attribute selection query param should not be filtered on`() { + val response = httpStub.client.execute( + HttpRequest( + method = "GET", + path = "/products", + queryParams = QueryParameters(mapOf("columns" to "name")), + ) + ) + + assertThat(response.status).isEqualTo(200) + assertThat(response.body).isInstanceOf(JSONArrayValue::class.java) + + val responseBody = response.body as JSONArrayValue + assertThat(responseBody.list.size).isEqualTo(1) + assertThat((responseBody.list.first() as JSONObjectValue).findFirstChildByPath("name")!!.toStringLiteral()).isEqualTo("iPhone 16") + } +} + class StatefulHttpStubSeedDataFromExamplesTest { companion object { private lateinit var httpStub: ContractStub From 048f573f8ea2c46b130c484b174d4bc65d38d000 Mon Sep 17 00:00:00 2001 From: Sufiyan Date: Fri, 3 Jan 2025 16:54:07 +0530 Subject: [PATCH 3/8] Change non-patchable fallback to 409 instead of 400 --- .../main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt index b00598c27..e67ea22f3 100644 --- a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt @@ -296,7 +296,7 @@ class StatefulHttpStub( val unprocessableEntity = responses.responseWithStatusCodeStartingWith("422") val (errorStatusCode, errorResponseBodyPattern) = if (unprocessableEntity?.successResponse != null) { Pair(422, unprocessableEntity.successResponse.responseBodyPattern) - } else Pair(400, responses.responseWithStatusCodeStartingWith("400")?.successResponse?.responseBodyPattern) + } else Pair(409, responses.responseWithStatusCodeStartingWith("409")?.successResponse?.responseBodyPattern) return generate4xxResponseWithMessage( errorResponseBodyPattern, From 3eceeb073a460544ca2c85514d08d060b11e66c1 Mon Sep 17 00:00:00 2001 From: Sufiyan Date: Fri, 3 Jan 2025 17:07:31 +0530 Subject: [PATCH 4/8] modify attribute filtering logic in stateful vs. --- .../io/specmatic/stub/stateful/StatefulHttpStub.kt | 12 +++++++----- .../kotlin/io/specmatic/stub/stateful/StubCache.kt | 9 +++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt index e67ea22f3..0d8538a19 100644 --- a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt @@ -320,11 +320,17 @@ class StatefulHttpStub( } if(method == "GET" && pathSegments.size == 1) { + val keysToFilterOut = scenario.httpRequestPattern.httpQueryParamPattern.queryKeyNames.map { + withoutOptionality(it) + }.plus(scenario.attributeSelectionPattern.queryParamKey) + val responseBody = stubCache.findAllResponsesFor( resourcePath, attributeSelectionKeys, - httpRequest.getAttributeFilters(scenario) + httpRequest.queryParams.asMap(), + ifKeyNotExist = { key -> key in keysToFilterOut } ) + return generatedResponse.withUpdated(responseBody, attributeSelectionKeys) } @@ -639,8 +645,4 @@ class StatefulHttpStub( return Result.fromResults(results).breadCrumb("REQUEST.BODY") } - - private fun HttpRequest.getAttributeFilters(scenario: Scenario): Map { - return this.queryParams.asMap().filterKeys { it != scenario.attributeSelectionPattern.queryParamKey } - } } \ No newline at end of file diff --git a/core/src/main/kotlin/io/specmatic/stub/stateful/StubCache.kt b/core/src/main/kotlin/io/specmatic/stub/stateful/StubCache.kt index c5ff70484..80f3f009f 100644 --- a/core/src/main/kotlin/io/specmatic/stub/stateful/StubCache.kt +++ b/core/src/main/kotlin/io/specmatic/stub/stateful/StubCache.kt @@ -57,12 +57,13 @@ class StubCache { fun findAllResponsesFor( path: String, attributeSelectionKeys: Set, - filter: Map = emptyMap() + filter: Map = emptyMap(), + ifKeyNotExist: (String) -> Boolean = { true } ): JSONArrayValue = lock.withLock { val responseBodies = cachedResponses.filter { it.path == path }.map{ it.responseBody }.filter { - it.jsonObject.satisfiesFilter(filter) + it.jsonObject.satisfiesFilter(filter, ifKeyNotExist) }.map { it.removeKeysNotPresentIn(attributeSelectionKeys) } @@ -76,11 +77,11 @@ class StubCache { } } - private fun Map.satisfiesFilter(filter: Map): Boolean { + private fun Map.satisfiesFilter(filter: Map, ifKeyNotExist: (String) -> Boolean): Boolean { if(filter.isEmpty()) return true return filter.all { (filterKey, filterValue) -> - if(this.containsKey(filterKey).not()) return@all false + if(this.containsKey(filterKey).not()) return@all ifKeyNotExist(filterKey) val actualValue = this.getValue(filterKey) actualValue.toStringLiteral() == filterValue From cdd7c82e09cee3a78ddcc3059dc624156d2cb426 Mon Sep 17 00:00:00 2001 From: Sufiyan Date: Fri, 3 Jan 2025 20:09:48 +0530 Subject: [PATCH 5/8] Implement Attribute filtering values validation - The value being filtered on will be validated based on the response pattern for the key being filtered. --- .../io/specmatic/core/QueryParameters.kt | 15 ++++- .../io/specmatic/core/pattern/AnyPattern.kt | 8 +++ .../io/specmatic/core/pattern/Grammar.kt | 15 ++++- .../stub/stateful/StatefulHttpStub.kt | 60 +++++++++++++++---- .../stub/stateful/StatefulHttpStubTest.kt | 36 +++++++++++ 5 files changed, 117 insertions(+), 17 deletions(-) diff --git a/core/src/main/kotlin/io/specmatic/core/QueryParameters.kt b/core/src/main/kotlin/io/specmatic/core/QueryParameters.kt index 8444ab17f..b6398e3d9 100644 --- a/core/src/main/kotlin/io/specmatic/core/QueryParameters.kt +++ b/core/src/main/kotlin/io/specmatic/core/QueryParameters.kt @@ -1,8 +1,7 @@ package io.specmatic.core -import io.specmatic.core.pattern.Pattern -import io.specmatic.core.pattern.parsedJSONArray -import io.specmatic.core.value.Value +import io.specmatic.core.pattern.* +import io.specmatic.core.value.* data class QueryParameters(val paramPairs: List> = emptyList()) { @@ -48,6 +47,16 @@ data class QueryParameters(val paramPairs: List> = emptyLis }.toMap() } + fun asValueMap(): Map { + return paramPairs.groupBy { it.first }.map { (parameterName, parameterValues) -> + if (parameterValues.size > 1) { + parameterName to JSONArrayValue(parameterValues.map { parsedScalarValue(it.second) }) + } else { + parameterName to parsedScalarValue(parameterValues.first().second) + } + }.toMap() + } + fun getValues(key: String): List { return paramPairs.filter { it.first == key }.map { it.second } } 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 0ec7b29b7..aa459d527 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/AnyPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/AnyPattern.kt @@ -163,6 +163,14 @@ data class AnyPattern( return Result.fromFailures(failuresWithUpdatedBreadcrumbs) } + fun getUpdatedPattern(resolver: Resolver): List { + return if (discriminator != null) { + discriminator.updatePatternsWithDiscriminator(pattern, resolver).listFold().takeIf { + it is HasValue> + }?.value ?: return emptyList() + } else pattern + } + override fun generate(resolver: Resolver): Value { return resolver.resolveExample(example, pattern) ?: generateValue(resolver) diff --git a/core/src/main/kotlin/io/specmatic/core/pattern/Grammar.kt b/core/src/main/kotlin/io/specmatic/core/pattern/Grammar.kt index 76c8f6a67..fea0f17fd 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/Grammar.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/Grammar.kt @@ -5,6 +5,7 @@ import io.specmatic.core.MismatchMessages import io.specmatic.core.utilities.jsonStringToValueArray import io.specmatic.core.utilities.jsonStringToValueMap import io.specmatic.core.value.* +import javax.validation.constraints.Null const val XML_ATTR_OPTIONAL_SUFFIX = ".opt" const val DEFAULT_OPTIONAL_SUFFIX = "?" @@ -360,4 +361,16 @@ fun parsedValue(content: String?): Value { StringValue(it) } } ?: EmptyString -} \ No newline at end of file +} + +fun parsedScalarValue(content: String?): Value { + val trimmed = content?.trim() ?: return NullValue + return when { + trimmed.toIntOrNull() != null -> NumberValue(trimmed.toInt()) + trimmed.toLongOrNull() != null -> NumberValue(trimmed.toLong()) + trimmed.toFloatOrNull() != null -> NumberValue(trimmed.toFloat()) + trimmed.toDoubleOrNull() != null -> NumberValue(trimmed.toDouble()) + trimmed.lowercase() in setOf("true", "false") -> BooleanValue(trimmed.toBoolean()) + else -> StringValue(trimmed) + } +} diff --git a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt index 0d8538a19..b9386cd17 100644 --- a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt @@ -14,21 +14,11 @@ import io.specmatic.conversions.OpenApiSpecification.Companion.applyOverlay import io.specmatic.core.* import io.specmatic.core.log.HttpLogMessage import io.specmatic.core.log.logger -import io.specmatic.core.pattern.ContractException -import io.specmatic.core.pattern.IgnoreUnexpectedKeys -import io.specmatic.core.pattern.JSONObjectPattern -import io.specmatic.core.pattern.Pattern -import io.specmatic.core.pattern.PossibleJsonObjectPatternContainer -import io.specmatic.core.pattern.StringPattern -import io.specmatic.core.pattern.resolvedHop -import io.specmatic.core.pattern.withoutOptionality +import io.specmatic.core.pattern.* import io.specmatic.core.route.modules.HealthCheckModule.Companion.configureHealthCheckModule import io.specmatic.core.route.modules.HealthCheckModule.Companion.isHealthCheckRequest import io.specmatic.core.utilities.exceptionCauseMessage -import io.specmatic.core.value.JSONArrayValue -import io.specmatic.core.value.JSONObjectValue -import io.specmatic.core.value.StringValue -import io.specmatic.core.value.Value +import io.specmatic.core.value.* import io.specmatic.mock.ScenarioStub import io.specmatic.stub.ContractAndRequestsMismatch import io.specmatic.stub.ContractStub @@ -320,6 +310,17 @@ class StatefulHttpStub( } if(method == "GET" && pathSegments.size == 1) { + val result = scenario.httpResponsePattern.body.validateAttributeFilters(httpRequest, scenario.resolver) + + if (result is Result.Failure) { + return generate4xxResponseWithMessage( + responses.responseWithStatusCodeStartingWith("400")?.successResponse?.responseBodyPattern, + scenario, + message = result.reportString(), + statusCode = 400 + ) + } + val keysToFilterOut = scenario.httpRequestPattern.httpQueryParamPattern.queryKeyNames.map { withoutOptionality(it) }.plus(scenario.attributeSelectionPattern.queryParamKey) @@ -643,6 +644,39 @@ class StatefulHttpStub( } else null } - return Result.fromResults(results).breadCrumb("REQUEST.BODY") + return Result.fromResults(results).breadCrumb("BODY").breadCrumb("REQUEST") + } + + private fun Pattern.validateAttributeFilters(httpRequest: HttpRequest, resolver: Resolver): Result { + if (this !is PossibleJsonObjectPatternContainer) return Result.Success() + + val queryParametersValue = httpRequest.queryParams.asValueMap() + val adjustedResolver = features.first().flagsBased.update(resolver).let { + it.copy(findKeyErrorCheck = it.findKeyErrorCheck.copy(patternKeyCheck = noPatternKeyCheck)) + } + + val results = queryParametersValue.entries.mapNotNull { (key, value) -> + val pattern = this.getKeyPattern(key, resolver) ?: return@mapNotNull null + pattern.matches(value.getMatchingValue(pattern), adjustedResolver) + } + + return Result.fromResults(results).breadCrumb("QUERY-PARAMS").breadCrumb("REQUEST") + } + + private fun Pattern.getKeyPattern(key: String, resolver: Resolver): Pattern? { + return when(this) { + is DeferredPattern -> resolvedHop(this, resolver).getKeyPattern(key, resolver) + is ListPattern -> this.pattern.getKeyPattern(key, resolver) + is AnyPattern -> this.getUpdatedPattern(resolver).firstNotNullOfOrNull { it.getKeyPattern(key, resolver) } + is JSONObjectPattern -> this.pattern[key] ?: this.pattern["$key?"] + else -> null + } + } + + private fun Value.getMatchingValue(pattern: Pattern): Value { + return when(pattern) { + is NumberPattern, is BooleanPattern -> this + else -> StringValue(this.toStringLiteral()) + } } } \ No newline at end of file diff --git a/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt index b73e7ede4..8292987ee 100644 --- a/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt @@ -628,6 +628,42 @@ class StatefulHttpStubAttributeFilteringTest { assertThat(responseBody.list.size).isEqualTo(1) assertThat((responseBody.list.first() as JSONObjectValue).findFirstChildByPath("name")!!.toStringLiteral()).isEqualTo("iPhone 16") } + + @Test + fun `filtering with invalid value should result in a 400 response, string in-place of number`() { + val response = httpStub.client.execute( + HttpRequest( + method = "GET", + path = "/products", + queryParams = QueryParameters(mapOf("price" to "abcd")), + ) + ) + + val responseBody = response.body as StringValue + println(response.toLogString()) + + assertThat(response.status).isEqualTo(400) + assertThat(responseBody.toStringLiteral()) + .contains(">> REQUEST.QUERY-PARAMS.price") + .contains("Expected number, actual was \"abcd\"") + } + + @Test + fun `filtering with number in-place of string should not result in a 400 response, the number should be casted`() { + val response = httpStub.client.execute( + HttpRequest( + method = "GET", + path = "/products", + queryParams = QueryParameters(mapOf("name" to "100")), + ) + ) + + val responseBody = response.body as JSONArrayValue + println(response.toLogString()) + + assertThat(response.status).isEqualTo(200) + assertThat(responseBody.list.size).isEqualTo(0) + } } class StatefulHttpStubSeedDataFromExamplesTest { From 7556ed64874c15a448c7cdea2a3a3c9f42ce672b Mon Sep 17 00:00:00 2001 From: Sufiyan Date: Fri, 3 Jan 2025 20:21:08 +0530 Subject: [PATCH 6/8] Fix attribute filter validation breadCrumbs --- .../main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt index b9386cd17..d92847714 100644 --- a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt @@ -657,7 +657,7 @@ class StatefulHttpStub( val results = queryParametersValue.entries.mapNotNull { (key, value) -> val pattern = this.getKeyPattern(key, resolver) ?: return@mapNotNull null - pattern.matches(value.getMatchingValue(pattern), adjustedResolver) + pattern.matches(value.getMatchingValue(pattern), adjustedResolver).breadCrumb(key) } return Result.fromResults(results).breadCrumb("QUERY-PARAMS").breadCrumb("REQUEST") From ebf6d8c4af241cfce304389425fb90246f17c6b0 Mon Sep 17 00:00:00 2001 From: Sufiyan Date: Fri, 3 Jan 2025 21:12:10 +0530 Subject: [PATCH 7/8] attribute selection AnyPattern validation improvements - Validate against all key patterns from all patterns of an AnyPattern instead of just choosing the first one that has the key. --- .../stub/stateful/StatefulHttpStub.kt | 12 ++--- .../stub/stateful/StatefulHttpStubTest.kt | 54 ++++++++++++++++++- .../spec_with_strictly_restful_apis.yaml | 47 ++++++++++++++++ .../get_all_orders.json | 25 +++++++++ 4 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/get_all_orders.json diff --git a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt index d92847714..f327eb1e0 100644 --- a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt @@ -656,20 +656,20 @@ class StatefulHttpStub( } val results = queryParametersValue.entries.mapNotNull { (key, value) -> - val pattern = this.getKeyPattern(key, resolver) ?: return@mapNotNull null - pattern.matches(value.getMatchingValue(pattern), adjustedResolver).breadCrumb(key) + val patterns = this.getKeyPattern(key, resolver).takeIf { it.isNotEmpty() } ?: return@mapNotNull null + patterns.map { it.matches(value.getMatchingValue(it), adjustedResolver) }.let { Results(it).toResultIfAny() } } return Result.fromResults(results).breadCrumb("QUERY-PARAMS").breadCrumb("REQUEST") } - private fun Pattern.getKeyPattern(key: String, resolver: Resolver): Pattern? { + private fun Pattern.getKeyPattern(key: String, resolver: Resolver): List { return when(this) { is DeferredPattern -> resolvedHop(this, resolver).getKeyPattern(key, resolver) is ListPattern -> this.pattern.getKeyPattern(key, resolver) - is AnyPattern -> this.getUpdatedPattern(resolver).firstNotNullOfOrNull { it.getKeyPattern(key, resolver) } - is JSONObjectPattern -> this.pattern[key] ?: this.pattern["$key?"] - else -> null + is AnyPattern -> this.getUpdatedPattern(resolver).flatMap { it.getKeyPattern(key, resolver) } + is JSONObjectPattern -> listOfNotNull(this.pattern[key] ?: this.pattern["$key?"]) + else -> emptyList() } } diff --git a/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt index 8292987ee..a3343e2ac 100644 --- a/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt @@ -510,6 +510,7 @@ class StatefulHttpStubAttributeFilteringTest { private val API_DIR = File("src/test/resources/openapi/spec_with_strictly_restful_apis") private val API_SPEC = API_DIR.resolve("spec_with_strictly_restful_apis.yaml") private val POST_EXAMPLE = API_DIR.resolve("spec_with_strictly_restful_apis_examples/post_iphone_product_with_id_300.json") + private val ORDERS_GET_EXAMPLE = API_DIR.resolve("spec_with_strictly_restful_apis_examples/get_all_orders.json") private fun getExtendedPostExample(): ScenarioStub { val example = ScenarioStub.readFromFile(POST_EXAMPLE) @@ -524,13 +525,15 @@ class StatefulHttpStubAttributeFilteringTest { fun setup() { System.setProperty(EXTENSIBLE_SCHEMA, "true") val extendedPostExample = getExtendedPostExample() + val ordersGetExample = ScenarioStub.readFromFile(ORDERS_GET_EXAMPLE) + val feature = OpenApiSpecification.fromFile(API_SPEC.canonicalPath).toFeature() val scenariosWithAttrSelection = feature.scenarios.map { it.copy(attributeSelectionPattern = AttributeSelectionPattern(queryParamKey = "columns")) } httpStub = StatefulHttpStub( features = listOf(feature.copy(scenarios = scenariosWithAttrSelection)), - scenarioStubs = listOf(extendedPostExample) + scenarioStubs = listOf(extendedPostExample, ordersGetExample) ) } @@ -664,6 +667,55 @@ class StatefulHttpStubAttributeFilteringTest { assertThat(response.status).isEqualTo(200) assertThat(responseBody.list.size).isEqualTo(0) } + + @Test + fun `should get the list of orders`() { + val response = httpStub.client.execute( + HttpRequest( + method = "GET", + path = "/orders" + ) + ) + + val responseBody = response.body as JSONArrayValue + println(response.toLogString()) + + assertThat(response.status).isEqualTo(200) + assertThat(responseBody.list.size).isEqualTo(2) + } + + @Test + fun `should not result in an error when value matches at-least one pattern key in an any pattern schema`() { + val response = httpStub.client.execute( + HttpRequest( + method = "GET", + path = "/orders", + queryParams = QueryParameters(mapOf("units" to "20")), + ) + ) + + val responseBody = response.body as JSONArrayValue + println(response.toLogString()) + + assertThat(response.status).isEqualTo(200) + assertThat(responseBody.list.size).isEqualTo(1) + } + + @Test + fun `should result in an error when the value doesn't mach any of the pattern keys in an any pattern schema`() { + val response = httpStub.client.execute( + HttpRequest( + method = "GET", + path = "/orders", + queryParams = QueryParameters(mapOf("units" to "99999999")), + ) + ) + + val responseBody = response.body as StringValue + println(response.toLogString()) + + assertThat(response.status).isEqualTo(400) + } } class StatefulHttpStubSeedDataFromExamplesTest { diff --git a/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis.yaml b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis.yaml index 3a2839533..24538abc2 100644 --- a/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis.yaml +++ b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis.yaml @@ -151,6 +151,19 @@ paths: description: Product deleted successfully '404': description: Product not found + /orders: + get: + summary: Get all orders + description: Retrieve a list of all orders. + responses: + '200': + description: A list of orders + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Order' components: schemas: @@ -212,6 +225,40 @@ components: type: boolean example: false + Order: + allOf: + - $ref: '#/components/schemas/SmallOrder' + - $ref: '#/components/schemas/BigOrder' + discriminator: + propertyName: type + mapping: + small: '#/components/schemas/SmallOrder' + large: '#/components/schemas/LargeOrder' + + SmallOrder: + type: object + properties: + id: + type: integer + type: + type: string + units: + type: integer + minimum: 1 + maximum: 10 + + LargeOrder: + type: object + properties: + id: + type: integer + type: + type: string + units: + type: integer + minimum: 10 + maximum: 100 + NotFoundError: type: object properties: diff --git a/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/get_all_orders.json b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/get_all_orders.json new file mode 100644 index 000000000..793db2553 --- /dev/null +++ b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/get_all_orders.json @@ -0,0 +1,25 @@ +{ + "http-request": { + "path": "/orders", + "method": "GET" + }, + "http-response": { + "status": 200, + "body": [ + { + "id": 100, + "type": "small", + "units": 5 + }, + { + "id": 200, + "type": "large", + "units": 20 + } + ], + "status-text": "OK", + "headers": { + "Content-Type": "application/json" + } + } +} From f3acbc31bffed9c559abbb2d774c32db8dca9aa5 Mon Sep 17 00:00:00 2001 From: Sufiyan Date: Fri, 3 Jan 2025 21:19:34 +0530 Subject: [PATCH 8/8] Add missing asserts and breadCrumb on attribute filtering --- .../kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt | 4 +++- .../io/specmatic/stub/stateful/StatefulHttpStubTest.kt | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt index f327eb1e0..f769d5873 100644 --- a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt @@ -657,7 +657,9 @@ class StatefulHttpStub( val results = queryParametersValue.entries.mapNotNull { (key, value) -> val patterns = this.getKeyPattern(key, resolver).takeIf { it.isNotEmpty() } ?: return@mapNotNull null - patterns.map { it.matches(value.getMatchingValue(it), adjustedResolver) }.let { Results(it).toResultIfAny() } + patterns.map { + it.matches(value.getMatchingValue(it), adjustedResolver).breadCrumb(key) + }.let { Results(it).toResultIfAny() } } return Result.fromResults(results).breadCrumb("QUERY-PARAMS").breadCrumb("REQUEST") diff --git a/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt index a3343e2ac..5d972a948 100644 --- a/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt @@ -699,6 +699,7 @@ class StatefulHttpStubAttributeFilteringTest { assertThat(response.status).isEqualTo(200) assertThat(responseBody.list.size).isEqualTo(1) + assertThat((responseBody.list.first() as JSONObjectValue).getStringValue("units")).isEqualTo("20") } @Test @@ -715,6 +716,10 @@ class StatefulHttpStubAttributeFilteringTest { println(response.toLogString()) assertThat(response.status).isEqualTo(400) + assertThat(responseBody.toStringLiteral()) + .contains(">> REQUEST.QUERY-PARAMS").contains(">> units") + .contains("Expected number <= 10, actual was 99999999 (number)") + .contains("Expected number <= 100, actual was 99999999 (number)") } }