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 e605d84d9..f769d5873 100644 --- a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt @@ -11,31 +11,14 @@ 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 -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 @@ -181,7 +164,7 @@ class StatefulHttpStub( fakeResponse, httpRequest, specmaticConfig.stub.includeMandatoryAndRequestedKeysInResponse, - responses.responseWithStatusCodeStartingWith("404")?.successResponse?.responseBodyPattern + responses ) ?: generateHttpResponseFrom(fakeResponse, httpRequest) return FoundStubbedResponse( @@ -251,7 +234,7 @@ class StatefulHttpStub( fakeResponse: ResponseDetails, httpRequest: HttpRequest, includeMandatoryAndRequestedKeysInResponse: Boolean?, - notFoundResponseBodyPattern: Pattern? + responses: Map = emptyMap() ): HttpResponse? { val scenario = fakeResponse.successResponse?.scenario @@ -267,7 +250,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 +279,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(409, responses.responseWithStatusCodeStartingWith("409")?.successResponse?.responseBodyPattern) + + return generate4xxResponseWithMessage( + errorResponseBodyPattern, + scenario, + result.reportString(), + errorStatusCode + ) + } + val responseBody = generatePatchResponse( httpRequest, @@ -310,11 +310,28 @@ 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) + val responseBody = stubCache.findAllResponsesFor( resourcePath, attributeSelectionKeys, - httpRequest.queryParams.asMap() + httpRequest.queryParams.asMap(), + ifKeyNotExist = { key -> key in keysToFilterOut } ) + return generatedResponse.withUpdated(responseBody, attributeSelectionKeys) } @@ -617,4 +634,51 @@ 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 && 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 + } + + 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 patterns = this.getKeyPattern(key, resolver).takeIf { it.isNotEmpty() } ?: return@mapNotNull null + patterns.map { + it.matches(value.getMatchingValue(it), adjustedResolver).breadCrumb(key) + }.let { Results(it).toResultIfAny() } + } + + return Result.fromResults(results).breadCrumb("QUERY-PARAMS").breadCrumb("REQUEST") + } + + 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).flatMap { it.getKeyPattern(key, resolver) } + is JSONObjectPattern -> listOfNotNull(this.pattern[key] ?: this.pattern["$key?"]) + else -> emptyList() + } + } + + 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/main/kotlin/io/specmatic/stub/stateful/StubCache.kt b/core/src/main/kotlin/io/specmatic/stub/stateful/StubCache.kt index d1e329f30..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 true + if(this.containsKey(filterKey).not()) return@all ifKeyNotExist(filterKey) 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 04c194f72..5d972a948 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 @@ -143,7 +146,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 +154,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 +167,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 +213,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 +242,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 +273,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 +299,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 +317,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 +333,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( @@ -474,6 +503,226 @@ 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 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) + 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 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, ordersGetExample) + ) + } + + @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") + } + + @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) + } + + @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) + assertThat((responseBody.list.first() as JSONObjectValue).getStringValue("units")).isEqualTo("20") + } + + @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) + 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)") + } +} + class StatefulHttpStubSeedDataFromExamplesTest { companion object { private lateinit var httpStub: ContractStub 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..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 @@ -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 @@ -145,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: @@ -206,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: @@ -221,3 +274,11 @@ components: type: string reason: type: string + + UnprocessableEntityError: + type: object + properties: + error: + type: string + reason: + type: string 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" + } + } +}