From a1869a63ef490efc0cff3c20a2c1293554657ac7 Mon Sep 17 00:00:00 2001 From: Yogesh Ananda Nikam Date: Mon, 18 Nov 2024 13:35:22 +0530 Subject: [PATCH] Inject example data as seed data into the stub cache for virtual-service --- .../application/VirtualServiceCommand.kt | 18 +++- .../stub/stateful/StatefulHttpStub.kt | 42 ++++++++- .../stub/stateful/StatefulHttpStubTest.kt | 91 ++++++++++++++++++- .../spec_with_strictly_restful_apis.yaml | 2 + .../delete_product_with_id_100.json | 10 ++ .../get_all_products.json | 29 ++++++ .../get_product_with_id_400.json | 20 ++++ .../patch_product_with_id_100.json | 28 ++++++ .../post_product.json | 29 ++++++ 9 files changed, 257 insertions(+), 12 deletions(-) create mode 100644 core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/delete_product_with_id_100.json create mode 100644 core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/get_all_products.json create mode 100644 core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/get_product_with_id_400.json create mode 100644 core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/patch_product_with_id_100.json create mode 100644 core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/post_product.json diff --git a/application/src/main/kotlin/application/VirtualServiceCommand.kt b/application/src/main/kotlin/application/VirtualServiceCommand.kt index e3786b415..cfd83b452 100644 --- a/application/src/main/kotlin/application/VirtualServiceCommand.kt +++ b/application/src/main/kotlin/application/VirtualServiceCommand.kt @@ -26,15 +26,19 @@ import java.util.concurrent.CountDownLatch ) class VirtualServiceCommand : Callable { - @Option(names = ["--host"], description = ["Host for the http stub"], defaultValue = DEFAULT_HTTP_STUB_HOST) + @Option(names = ["--host"], description = ["Host for the virtual service"], defaultValue = DEFAULT_HTTP_STUB_HOST) lateinit var host: String - @Option(names = ["--port"], description = ["Port for the http stub"], defaultValue = DEFAULT_HTTP_STUB_PORT) + @Option(names = ["--port"], description = ["Port for the virtual service"], defaultValue = DEFAULT_HTTP_STUB_PORT) var port: Int = 0 + @Option(names = ["--examples"], description = ["Directories containing JSON examples"], required = false) + var exampleDirs: List = mutableListOf() + private val stubLoaderEngine = StubLoaderEngine() private var server: StatefulHttpStub? = null private val latch = CountDownLatch(1) + private val newLine = System.lineSeparator() override fun call(): Int { setup() @@ -82,7 +86,7 @@ class VirtualServiceCommand : Callable { private fun startServer() { val stubData: List>> = stubLoaderEngine.loadStubs( stubContractPathData(), - emptyList(), // TODO - to be replaced with exampleDirs + exampleDirs, Configuration.configFilePath, false ) @@ -91,11 +95,17 @@ class VirtualServiceCommand : Callable { host, port, stubData.map { it.first }, - Configuration.configFilePath + Configuration.configFilePath, + stubData.flatMap { it.second }.also { it.logExamplesCachedAsSeedData() } ) logger.log("Virtual service started on http://$host:$port") latch.await() } + + private fun List.logExamplesCachedAsSeedData() { + logger.log("${newLine}Injecting the data read from the following stub files into the stub server's state..".prependIndent(" ")) + this.forEach { logger.log(it.filePath.orEmpty().prependIndent(" ")) } + } } 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 97dc250cd..974da68a9 100644 --- a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt @@ -25,6 +25,7 @@ import io.specmatic.core.utilities.exceptionCauseMessage import io.specmatic.core.value.JSONArrayValue import io.specmatic.core.value.JSONObjectValue import io.specmatic.core.value.Value +import io.specmatic.mock.ScenarioStub import io.specmatic.stub.ContractStub import io.specmatic.stub.CouldNotParseRequest import io.specmatic.stub.FoundStubbedResponse @@ -49,7 +50,8 @@ class StatefulHttpStub( port: Int = 9000, private val features: List, private val specmaticConfigPath: String? = null, - private val timeoutMillis: Long = 2000 + private val scenarioStubs: List = emptyList(), + private val timeoutMillis: Long = 2000, ): ContractStub { private val environment = applicationEngineEnvironment { @@ -140,7 +142,7 @@ class StatefulHttpStub( } private val specmaticConfig = loadSpecmaticConfig() - private val stubCache = StubCache() + private val stubCache = stubCacheWithExampleData() private fun cachedHttpResponse( httpRequest: HttpRequest, @@ -181,7 +183,7 @@ class StatefulHttpStub( val pathSegments = httpRequest.pathSegments() if(isUnsupportedResponseBodyForCaching(generatedResponse, method, pathSegments)) return null - val (resourcePath, resourceId) = resourcePathAndIdFrom(pathSegments) + val (resourcePath, resourceId) = resourcePathAndIdFrom(httpRequest) val resourceIdKey = resourceIdKeyFrom(scenario?.httpRequestPattern) val attributeSelectionKeys: Set = scenario?.getFieldsToBeMadeMandatoryBasedOnAttributeSelection(httpRequest.queryParams).orEmpty() @@ -233,7 +235,8 @@ class StatefulHttpStub( return null } - private fun resourcePathAndIdFrom(pathSegments: List): Pair { + private fun resourcePathAndIdFrom(httpRequest: HttpRequest): Pair { + val pathSegments = httpRequest.pathSegments() val resourcePath = "/${pathSegments.first()}" val resourceId = pathSegments.last() return Pair(resourcePath, resourceId) @@ -376,4 +379,35 @@ class StatefulHttpStub( else SpecmaticConfig() } + + private fun stubCacheWithExampleData(): StubCache { + val stubCache = StubCache() + + scenarioStubs.forEach { + val httpRequest = it.request + if (httpRequest.method !in setOf("GET", "POST")) return@forEach + if (isUnsupportedResponseBodyForCaching( + generatedResponse = it.response, + method = httpRequest.method, + pathSegments = httpRequest.pathSegments() + ) + ) return@forEach + + val (resourcePath, _) = resourcePathAndIdFrom(httpRequest) + val responseBody = it.response.body + if (httpRequest.method == "GET" && httpRequest.pathSegments().size == 1) { + val responseBodies = (it.response.body as JSONArrayValue).list.filterIsInstance() + responseBodies.forEach { body -> + stubCache.addResponse(resourcePath, body) + } + } else { + if (responseBody !is JSONObjectValue) return@forEach + if(httpRequest.method == "POST" && httpRequest.body !is JSONObjectValue) return@forEach + + stubCache.addResponse(resourcePath, responseBody) + } + } + + return stubCache + } } \ 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 b2dee944a..a40c96eba 100644 --- a/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt @@ -3,9 +3,12 @@ package io.specmatic.stub.stateful 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.value.* import io.specmatic.stub.ContractStub -import io.specmatic.stub.HttpStub +import io.specmatic.stub.loadContractStubsFromImplicitPaths +import io.specmatic.stub.stateful.StatefulHttpStubTest.Companion +import io.specmatic.stub.stateful.StatefulHttpStubTest.Companion.resourceId import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeAll @@ -163,9 +166,6 @@ class StatefulHttpStubTest { assertThat(getResponse.status).isEqualTo(404) } - private fun JSONObjectValue.getStringValue(key: String): String? { - return this.jsonObject[key]?.toStringLiteral() - } } @TestMethodOrder(MethodOrderer.OrderAnnotation::class) @@ -321,3 +321,86 @@ class StatefulHttpStubWithAttributeSelectionTest { return this.jsonObject[key]?.toStringLiteral() } } + +class StatefulHttpStubSeedDataFromExamplesTest { + companion object { + private lateinit var httpStub: ContractStub + private const val SPEC_DIR_PATH = "src/test/resources/openapi/spec_with_strictly_restful_apis" + + @JvmStatic + @BeforeAll + fun setup() { + val specPath = "$SPEC_DIR_PATH/spec_with_strictly_restful_apis.yaml" + + val scenarioStubs = loadContractStubsFromImplicitPaths( + contractPathDataList = listOf(ContractPathData("", specPath)), + specmaticConfig = loadSpecmaticConfig("$SPEC_DIR_PATH/specmatic.yaml") + ).flatMap { it.second } + + httpStub = StatefulHttpStub( + specmaticConfigPath = "$SPEC_DIR_PATH/specmatic.yaml", + features = listOf( + OpenApiSpecification.fromFile(specPath).toFeature() + ), + scenarioStubs = scenarioStubs + ) + } + + @JvmStatic + @AfterAll + fun tearDown() { + httpStub.close() + } + } + + @Test + fun `should get the list of products from seed data loaded from examples`() { + 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(4) + + val responseObjectFromResponseBody = (response.body as JSONArrayValue) + .list.filterIsInstance().first { it.getStringValue("id") == "300" } + + assertThat(responseObjectFromResponseBody.getStringValue("id")).isEqualTo("300") + assertThat(responseObjectFromResponseBody.getStringValue("name")).isEqualTo("iPhone 16") + assertThat(responseObjectFromResponseBody.getStringValue("description")).isEqualTo("New iPhone 16") + assertThat(responseObjectFromResponseBody.getStringValue("price")).isEqualTo("942") + assertThat(responseObjectFromResponseBody.getStringValue("inStock")).isEqualTo("true") + } + + + @Test + fun `should get the product from seed data loaded from examples`() { + val response = httpStub.client.execute( + HttpRequest( + method = "GET", + path = "/products/300" + ) + ) + + assertThat(response.status).isEqualTo(200) + val responseBody = response.body as JSONObjectValue + + assertThat(responseBody.getStringValue("id")).isEqualTo("300") + assertThat(responseBody.getStringValue("name")).isEqualTo("iPhone 16") + assertThat(responseBody.getStringValue("description")).isEqualTo("New iPhone 16") + assertThat(responseBody.getStringValue("price")).isEqualTo("942") + assertThat(responseBody.getStringValue("inStock")).isEqualTo("true") + } + + +} + +private fun JSONObjectValue.getStringValue(key: String): String? { + return this.jsonObject[key]?.toStringLiteral() +} 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 5344c4b4e..e67dcadff 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 @@ -145,6 +145,8 @@ components: inStock: type: boolean example: true + required: + - id ProductInput: type: object diff --git a/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/delete_product_with_id_100.json b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/delete_product_with_id_100.json new file mode 100644 index 000000000..740678f29 --- /dev/null +++ b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/delete_product_with_id_100.json @@ -0,0 +1,10 @@ +{ + "http-request": { + "path": "/products/100", + "method": "DELETE" + }, + "http-response": { + "status": 204, + "status-text": "No Content" + } +} diff --git a/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/get_all_products.json b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/get_all_products.json new file mode 100644 index 000000000..dd24b80e3 --- /dev/null +++ b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/get_all_products.json @@ -0,0 +1,29 @@ +{ + "http-request": { + "path": "/products", + "method": "GET" + }, + "http-response": { + "status": 200, + "body": [ + { + "id": 100, + "name": "iPhone", + "description": "Description for an iPhone", + "price": 636, + "inStock": true + }, + { + "id": 200, + "name": "iPhone Pro Max", + "description": "Description for an iPhone Pro Max", + "price": 866, + "inStock": false + } + ], + "status-text": "OK", + "headers": { + "Content-Type": "application/json" + } + } +} diff --git a/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/get_product_with_id_400.json b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/get_product_with_id_400.json new file mode 100644 index 000000000..cab340fb3 --- /dev/null +++ b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/get_product_with_id_400.json @@ -0,0 +1,20 @@ +{ + "http-request": { + "path": "/products/400", + "method": "GET" + }, + "http-response": { + "status": 200, + "body": { + "id": 400, + "name": "Macbook Pro", + "description": "Description for a Macbook Pro", + "price": 1000, + "inStock": true + }, + "status-text": "OK", + "headers": { + "Content-Type": "application/json" + } + } +} diff --git a/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/patch_product_with_id_100.json b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/patch_product_with_id_100.json new file mode 100644 index 000000000..f0cadfcca --- /dev/null +++ b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/patch_product_with_id_100.json @@ -0,0 +1,28 @@ +{ + "http-request": { + "path": "/products/100", + "method": "PATCH", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "description": "Patched description for iPhone", + "price": 300, + "inStock": false + } + }, + "http-response": { + "status": 200, + "body": { + "id": 100, + "name": "iPhone", + "description": "Patched description for iPhone", + "price": 300, + "inStock": false + }, + "status-text": "OK", + "headers": { + "Content-Type": "application/json" + } + } +} diff --git a/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/post_product.json b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/post_product.json new file mode 100644 index 000000000..890783663 --- /dev/null +++ b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/spec_with_strictly_restful_apis_examples/post_product.json @@ -0,0 +1,29 @@ +{ + "http-request": { + "path": "/products", + "method": "POST", + "headers": { + "Content-Type": "application/json" + }, + "body": { + "name": "iPhone 16", + "description": "New iPhone 16", + "price": 942, + "inStock": true + } + }, + "http-response": { + "status": 201, + "body": { + "id": 300, + "name": "iPhone 16", + "description": "New iPhone 16", + "price": 942, + "inStock": true + }, + "status-text": "Created", + "headers": { + "Content-Type": "application/json" + } + } +}