diff --git a/application/src/main/kotlin/application/SpecmaticCommand.kt b/application/src/main/kotlin/application/SpecmaticCommand.kt index d6591fe32..665d082c5 100644 --- a/application/src/main/kotlin/application/SpecmaticCommand.kt +++ b/application/src/main/kotlin/application/SpecmaticCommand.kt @@ -30,6 +30,7 @@ import java.util.concurrent.Callable ExamplesCommand::class, SamplesCommand::class, StubCommand::class, + VirtualServiceCommand::class, SubscribeCommand::class, TestCommand::class, ValidateViaLogs::class, diff --git a/application/src/main/kotlin/application/VirtualServiceCommand.kt b/application/src/main/kotlin/application/VirtualServiceCommand.kt new file mode 100644 index 000000000..e3786b415 --- /dev/null +++ b/application/src/main/kotlin/application/VirtualServiceCommand.kt @@ -0,0 +1,116 @@ +package application + +import io.specmatic.core.Configuration +import io.specmatic.core.Configuration.Companion.DEFAULT_HTTP_STUB_HOST +import io.specmatic.core.Configuration.Companion.DEFAULT_HTTP_STUB_PORT +import io.specmatic.core.DEFAULT_WORKING_DIRECTORY +import io.specmatic.core.Feature +import io.specmatic.core.log.StringLog +import io.specmatic.core.log.consoleLog +import io.specmatic.core.log.logger +import io.specmatic.core.utilities.ContractPathData +import io.specmatic.core.utilities.contractFilePathsFrom +import io.specmatic.core.utilities.contractStubPaths +import io.specmatic.core.utilities.exitIfAnyDoNotExist +import io.specmatic.mock.ScenarioStub +import io.specmatic.stub.stateful.StatefulHttpStub +import picocli.CommandLine.Command +import picocli.CommandLine.Option +import java.util.concurrent.Callable +import java.util.concurrent.CountDownLatch + +@Command( + name = "virtual-service", + mixinStandardHelpOptions = true, + description = ["Start a stateful virtual service with contract"] +) +class VirtualServiceCommand : Callable { + + @Option(names = ["--host"], description = ["Host for the http stub"], defaultValue = DEFAULT_HTTP_STUB_HOST) + lateinit var host: String + + @Option(names = ["--port"], description = ["Port for the http stub"], defaultValue = DEFAULT_HTTP_STUB_PORT) + var port: Int = 0 + + private val stubLoaderEngine = StubLoaderEngine() + private var server: StatefulHttpStub? = null + private val latch = CountDownLatch(1) + + override fun call(): Int { + setup() + + try { + startServer() + } catch(e: Exception) { + logger.log("An error occurred while starting the virtual service: ${e.message}") + return 1 + } + + return 0 + } + + private fun setup() { + exitIfContractPathsDoNotExist() + addShutdownHook() + } + + private fun exitIfContractPathsDoNotExist() { + val contractPaths = contractStubPaths(Configuration.configFilePath).map { it.path } + exitIfAnyDoNotExist("The following specifications do not exist", contractPaths) + } + + private fun addShutdownHook() { + Runtime.getRuntime().addShutdownHook(object : Thread() { + override fun run() { + try { + latch.countDown() + consoleLog(StringLog("Shutting down the virtual service")) + server?.close() + } catch (e: InterruptedException) { + currentThread().interrupt() + } + } + }) + } + + private fun stubContractPathData(): List { + return contractFilePathsFrom(Configuration.configFilePath, DEFAULT_WORKING_DIRECTORY) { + source -> source.stubContracts + } + } + + private fun startServer() { + val stubData: List>> = stubLoaderEngine.loadStubs( + stubContractPathData(), + emptyList(), // TODO - to be replaced with exampleDirs + Configuration.configFilePath, + false + ) + + server = StatefulHttpStub( + host, + port, + stubData.map { it.first }, + Configuration.configFilePath + ) + logger.log("Virtual service started on http://$host:$port") + latch.await() + } +} + + + + + + + + + + + + + + + + + diff --git a/core/src/main/kotlin/io/specmatic/core/ResponseBuilder.kt b/core/src/main/kotlin/io/specmatic/core/ResponseBuilder.kt index 256ea4c33..9a0dc06f1 100644 --- a/core/src/main/kotlin/io/specmatic/core/ResponseBuilder.kt +++ b/core/src/main/kotlin/io/specmatic/core/ResponseBuilder.kt @@ -4,6 +4,9 @@ import io.specmatic.core.value.Value import io.specmatic.stub.RequestContext class ResponseBuilder(val scenario: Scenario, val serverState: Map) { + val responseBodyPattern = scenario.httpResponsePattern.body + val resolver = scenario.resolver + fun build(requestContext: RequestContext): HttpResponse { return scenario.generateHttpResponse(serverState, requestContext) } diff --git a/core/src/main/kotlin/io/specmatic/core/SpecmaticConfig.kt b/core/src/main/kotlin/io/specmatic/core/SpecmaticConfig.kt index a1323deed..f5b867be5 100644 --- a/core/src/main/kotlin/io/specmatic/core/SpecmaticConfig.kt +++ b/core/src/main/kotlin/io/specmatic/core/SpecmaticConfig.kt @@ -77,7 +77,7 @@ data class StubConfiguration( val generative: Boolean? = false, val delayInMilliseconds: Long? = getLongValue(SPECMATIC_STUB_DELAY), val dictionary: String? = getStringValue(SPECMATIC_STUB_DICTIONARY), - val stateful: Boolean? = false + val includeMandatoryAndRequestedKeysInResponse: Boolean? = false ) data class WorkflowIDOperation( 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 f927682ce..76db6398b 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/Grammar.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/Grammar.kt @@ -17,6 +17,13 @@ internal fun withoutOptionality(key: String): String { } } +internal fun withOptionality(key: String): String { + return when { + key.endsWith(DEFAULT_OPTIONAL_SUFFIX) -> key + else -> "$key$DEFAULT_OPTIONAL_SUFFIX" + } +} + internal fun isOptional(key: String): Boolean = key.endsWith(DEFAULT_OPTIONAL_SUFFIX) || key.endsWith(XML_ATTR_OPTIONAL_SUFFIX) 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 0b9453bd2..8c56fe68e 100644 --- a/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt +++ b/core/src/main/kotlin/io/specmatic/core/pattern/JSONObjectPattern.kt @@ -393,6 +393,14 @@ data class JSONObjectPattern( } override val typeName: String = "json object" + + fun keysInNonOptionalFormat(): Set { + return this.pattern.map { withoutOptionality(it.key) }.toSet() + } + + fun patternForKey(key: String): Pattern? { + return pattern[withoutOptionality(key)] ?: pattern[withOptionality(key)] + } } fun generate(jsonPattern: Map, resolver: Resolver, typeAlias: String?): Map { diff --git a/core/src/main/kotlin/io/specmatic/core/value/JSONObjectValue.kt b/core/src/main/kotlin/io/specmatic/core/value/JSONObjectValue.kt index f88136a14..05520aa29 100644 --- a/core/src/main/kotlin/io/specmatic/core/value/JSONObjectValue.kt +++ b/core/src/main/kotlin/io/specmatic/core/value/JSONObjectValue.kt @@ -95,6 +95,9 @@ data class JSONObjectValue(val jsonObject: Map = emptyMap()) : Va fun findFirstChildByName(name: String): Value? = jsonObject[name] + + fun keys() = jsonObject.keys + } internal fun dictionaryToDeclarations(jsonObject: Map, types: Map, exampleDeclarations: ExampleDeclarations): Triple, Map, ExampleDeclarations> { diff --git a/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt index a633bb58a..8d731ac82 100644 --- a/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt +++ b/core/src/main/kotlin/io/specmatic/stub/HttpStub.kt @@ -6,24 +6,72 @@ import io.ktor.http.content.* import io.ktor.server.application.* import io.ktor.server.engine.* import io.ktor.server.netty.* -import io.ktor.server.plugins.cors.CORS +import io.ktor.server.plugins.cors.* import io.ktor.server.plugins.doublereceive.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.util.* -import io.specmatic.core.* -import io.specmatic.core.log.* +import io.specmatic.core.APPLICATION_NAME +import io.specmatic.core.APPLICATION_NAME_LOWER_CASE +import io.specmatic.core.ContractAndStubMismatchMessages +import io.specmatic.core.Feature +import io.specmatic.core.HttpRequest +import io.specmatic.core.HttpResponse +import io.specmatic.core.KeyData +import io.specmatic.core.MismatchMessages +import io.specmatic.core.MissingDataException +import io.specmatic.core.MultiPartContent +import io.specmatic.core.MultiPartContentValue +import io.specmatic.core.MultiPartFileValue +import io.specmatic.core.MultiPartFormDataValue +import io.specmatic.core.NoBodyValue +import io.specmatic.core.QueryParameters +import io.specmatic.core.ResponseBuilder +import io.specmatic.core.Result +import io.specmatic.core.Results +import io.specmatic.core.SPECMATIC_RESULT_HEADER +import io.specmatic.core.Scenario +import io.specmatic.core.SpecmaticConfig +import io.specmatic.core.WorkingDirectory +import io.specmatic.core.listOfExcludedHeaders +import io.specmatic.core.loadSpecmaticConfig +import io.specmatic.core.log.HttpLogMessage +import io.specmatic.core.log.LogMessage +import io.specmatic.core.log.LogTail +import io.specmatic.core.log.dontPrintToConsole +import io.specmatic.core.log.logger +import io.specmatic.core.parseGherkinStringToFeature 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.* -import io.specmatic.stub.report.* +import io.specmatic.core.urlDecodePathSegments +import io.specmatic.core.utilities.capitalizeFirstChar +import io.specmatic.core.utilities.exceptionCauseMessage +import io.specmatic.core.utilities.jsonStringToValueMap +import io.specmatic.core.utilities.saveJsonFile +import io.specmatic.core.utilities.toMap +import io.specmatic.core.value.EmptyString +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.toXMLNode +import io.specmatic.mock.NoMatchingScenario +import io.specmatic.mock.ScenarioStub +import io.specmatic.mock.TRANSIENT_MOCK +import io.specmatic.mock.mockFromJSON +import io.specmatic.mock.validateMock +import io.specmatic.stub.report.StubEndpoint +import io.specmatic.stub.report.StubUsageReport +import io.specmatic.stub.report.StubUsageReportJson import io.specmatic.test.HttpClient import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.* +import kotlinx.coroutines.channels.BroadcastChannel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.broadcast import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import kotlinx.serialization.SerializationException @@ -179,8 +227,6 @@ class HttpStub( val endPoint = endPointFromHostAndPort(host, port, keyData) - private val stubCache = StubCache() - override val client = HttpClient(this.endPoint) private val sseBuffer: SSEBuffer = SSEBuffer() @@ -341,7 +387,6 @@ class HttpStub( passThroughTargetBase, httpClientFactory, specmaticConfig, - stubCache ) result.log(_logs, httpRequest) @@ -773,13 +818,8 @@ fun getHttpResponse( passThroughTargetBase: String = "", httpClientFactory: HttpClientFactory? = null, specmaticConfig: SpecmaticConfig = SpecmaticConfig(), - stubCache: StubCache = StubCache() ): StubbedResponseResult { try { - if(specmaticConfig.stub.stateful == true) { - return cachedHttpResponse(features, httpRequest, specmaticConfig, stubCache) - } - val (matchResults, matchingStubResponse) = stubbedResponse(threadSafeStubs, threadSafeStubQueue, httpRequest) if(matchingStubResponse != null) { val (httpStubResponse, httpStubData) = matchingStubResponse @@ -893,7 +933,7 @@ object ContractAndRequestsMismatch : MismatchMessages { data class ResponseDetails(val feature: Feature, val successResponse: ResponseBuilder?, val results: Results) -private fun fakeHttpResponse( +fun fakeHttpResponse( features: List, httpRequest: HttpRequest, specmaticConfig: SpecmaticConfig = SpecmaticConfig() @@ -948,7 +988,7 @@ private fun fakeHttpResponse( } } -private fun responseDetailsFrom(features: List, httpRequest: HttpRequest): List { +fun responseDetailsFrom(features: List, httpRequest: HttpRequest): List { return features.asSequence().map { feature -> feature.stubResponse(httpRequest, ContractAndRequestsMismatch).let { ResponseDetails(feature, it.first, it.second) @@ -956,155 +996,14 @@ private fun responseDetailsFrom(features: List, httpRequest: HttpReques }.toList() } -private fun List.successResponse(): ResponseDetails? { +fun List.successResponse(): ResponseDetails? { return this.find { it.successResponse != null } } -private fun generateHttpResponseFrom(fakeResponse: ResponseDetails, httpRequest: HttpRequest): HttpResponse { +fun generateHttpResponseFrom(fakeResponse: ResponseDetails, httpRequest: HttpRequest): HttpResponse { return fakeResponse.successResponse?.build(RequestContext(httpRequest))?.withRandomResultHeader()!! } -private fun cachedHttpResponse( - features: List, - httpRequest: HttpRequest, - specmaticConfig: SpecmaticConfig = SpecmaticConfig(), - stubCache: StubCache -): StubbedResponseResult { - if (features.isEmpty()) - return NotStubbed(HttpStubResponse(HttpResponse(400, "No valid API specifications loaded"))) - - val responses: List = responseDetailsFrom(features, httpRequest) - val fakeResponse = responses.successResponse() - ?: return fakeHttpResponse(features, httpRequest, specmaticConfig) - - val generatedResponse = generateHttpResponseFrom(fakeResponse, httpRequest) - val updatedResponse = cachedResponse(fakeResponse, httpRequest, stubCache) ?: generatedResponse - - return FoundStubbedResponse( - HttpStubResponse( - updatedResponse, - contractPath = fakeResponse.feature.path, - feature = fakeResponse.feature, - scenario = fakeResponse.successResponse?.scenario - ) - ) -} - -private fun cachedResponse(fakeResponse: ResponseDetails, httpRequest: HttpRequest, stubCache: StubCache): HttpResponse? { - val scenario = fakeResponse.successResponse?.scenario - val method = scenario?.method - val attributeSelectionKeys: Set = - scenario?.getFieldsToBeMadeMandatoryBasedOnAttributeSelection(httpRequest.queryParams).orEmpty() - - val generatedResponse = generateHttpResponseFrom(fakeResponse, httpRequest) - val pathSegments = httpRequest.path?.split("/")?.filter { it.isNotBlank() }.orEmpty() - - val unsupportedResponseBodyForCaching = - (generatedResponse.body is JSONObjectValue || - (method == "DELETE" && pathSegments.size > 1) || - (method == "GET" && - generatedResponse.body is JSONArrayValue && - generatedResponse.body.list.firstOrNull() is JSONObjectValue)).not() - - if(unsupportedResponseBodyForCaching) return null - - val resourcePath = "/${pathSegments.first()}" - val resourceId = pathSegments.last() - val resourceIdKey = resourceIdKeyFrom(scenario?.httpRequestPattern) - - if(method == "POST") { - val responseBody = - generateAndCachePostResponse(generatedResponse, httpRequest, stubCache, resourcePath) ?: return null - return generatedResponse.withUpdated(responseBody, attributeSelectionKeys) - } - - if(method == "PATCH" && pathSegments.size > 1) { - val responseBody = - generateAndCachePatchResponse(httpRequest, stubCache, resourcePath, resourceIdKey, resourceId) - ?: return null - - return generatedResponse.withUpdated(responseBody, attributeSelectionKeys) - } - - if(method == "GET" && pathSegments.size == 1) { - val responseBody = stubCache.findAllResponsesFor(resourcePath, attributeSelectionKeys) - return generatedResponse.withUpdated(responseBody, attributeSelectionKeys) - } - - if(method == "GET" && pathSegments.size > 1) { - val responseBody = - stubCache.findResponseFor(resourcePath, resourceIdKey, resourceId)?.responseBody - ?: return HttpResponse(404, "Resource with resourceId '$resourceId' not found") - - return generatedResponse.withUpdated(responseBody, attributeSelectionKeys) - } - - if(method == "DELETE" && pathSegments.size > 1) { - stubCache.deleteResponse(resourcePath, resourceIdKey, resourceId) - return generatedResponse - } - - return null -} - -private fun generateAndCachePostResponse( - generatedResponse: HttpResponse, - httpRequest: HttpRequest, - stubCache: StubCache, - resourcePath: String -): JSONObjectValue? { - if(generatedResponse.body !is JSONObjectValue || httpRequest.body !is JSONObjectValue) - return null - - val responseBody = generatedResponse.body - val responseBodyWithValuesFromRequest = responseBody.copy( - jsonObject = patchValuesFromRequestIntoResponse(httpRequest.body, responseBody) - ) - stubCache.addResponse(resourcePath, responseBodyWithValuesFromRequest) - return responseBodyWithValuesFromRequest -} - -private fun generateAndCachePatchResponse( - httpRequest: HttpRequest, - stubCache: StubCache, - resourcePath: String, - resourceIdKey: String, - resourceId: String -): JSONObjectValue? { - if(httpRequest.body !is JSONObjectValue) return null - - val cachedResponse = stubCache.findResponseFor(resourcePath, resourceIdKey, resourceId) - - val responseBody = cachedResponse?.responseBody ?: return null - - val updatedResponseBody = responseBody.copy( - jsonObject = patchValuesFromRequestIntoResponse(httpRequest.body, responseBody) - ) - stubCache.updateResponse(resourcePath, updatedResponseBody, resourceIdKey, resourceId) - - return updatedResponseBody -} - -private fun patchValuesFromRequestIntoResponse(requestBody: JSONObjectValue, responseBody: JSONObjectValue): Map { - return responseBody.jsonObject.mapValues { (key, value) -> - val patchValueFromRequest = requestBody.jsonObject.entries.firstOrNull { - it.key == key - }?.value ?: return@mapValues value - - if(patchValueFromRequest::class.java == value::class.java) return@mapValues patchValueFromRequest - value - } -} - -private fun resourceIdKeyFrom(httpRequestPattern: HttpRequestPattern?): String { - return httpRequestPattern?.getPathSegmentPatterns()?.last()?.key.orEmpty() -} - -private fun HttpResponse.withUpdated(body: Value, attributeSelectionKeys: Set): HttpResponse { - if(body !is JSONObjectValue) return this.copy(body = body) - return this.copy(body = body.removeKeysNotPresentIn(attributeSelectionKeys)) -} - fun dumpIntoFirstAvailableStringField(httpResponse: HttpResponse, stringValue: String): HttpResponse { val responseBody = httpResponse.body diff --git a/core/src/main/kotlin/io/specmatic/stub/HttpStubResponse.kt b/core/src/main/kotlin/io/specmatic/stub/HttpStubResponse.kt index 6b4d8ee1d..544f2c30a 100644 --- a/core/src/main/kotlin/io/specmatic/stub/HttpStubResponse.kt +++ b/core/src/main/kotlin/io/specmatic/stub/HttpStubResponse.kt @@ -11,6 +11,8 @@ data class HttpStubResponse( val feature: Feature? = null, val scenario: Scenario? = null ) { + val responseBody = response.body + fun resolveSubstitutions( request: HttpRequest, originalRequest: HttpRequest, diff --git a/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt new file mode 100644 index 000000000..97dc250cd --- /dev/null +++ b/core/src/main/kotlin/io/specmatic/stub/stateful/StatefulHttpStub.kt @@ -0,0 +1,379 @@ +package io.specmatic.stub.stateful + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.cors.* +import io.ktor.server.plugins.doublereceive.* +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.SpecmaticConfig +import io.specmatic.core.loadSpecmaticConfig +import io.specmatic.core.log.HttpLogMessage +import io.specmatic.core.log.logger +import io.specmatic.core.pattern.ContractException +import io.specmatic.core.pattern.JSONObjectPattern +import io.specmatic.core.pattern.Pattern +import io.specmatic.core.pattern.resolvedHop +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.Value +import io.specmatic.stub.ContractStub +import io.specmatic.stub.CouldNotParseRequest +import io.specmatic.stub.FoundStubbedResponse +import io.specmatic.stub.HttpStubResponse +import io.specmatic.stub.NotStubbed +import io.specmatic.stub.ResponseDetails +import io.specmatic.stub.StubbedResponseResult +import io.specmatic.stub.badRequest +import io.specmatic.stub.endPointFromHostAndPort +import io.specmatic.stub.fakeHttpResponse +import io.specmatic.stub.generateHttpResponseFrom +import io.specmatic.stub.internalServerError +import io.specmatic.stub.ktorHttpRequestToHttpRequest +import io.specmatic.stub.respondToKtorHttpResponse +import io.specmatic.stub.responseDetailsFrom +import io.specmatic.stub.successResponse +import io.specmatic.test.HttpClient +import java.io.File + +class StatefulHttpStub( + host: String = "127.0.0.1", + port: Int = 9000, + private val features: List, + private val specmaticConfigPath: String? = null, + private val timeoutMillis: Long = 2000 +): ContractStub { + + private val environment = applicationEngineEnvironment { + module { + install(DoubleReceive) + + install(CORS) { + allowMethod(HttpMethod.Options) + allowMethod(HttpMethod.Get) + allowMethod(HttpMethod.Post) + allowMethod(HttpMethod.Put) + allowMethod(HttpMethod.Delete) + allowMethod(HttpMethod.Patch) + + allowHeaders { + true + } + + allowCredentials = true + allowNonSimpleContentTypes = true + + anyHost() + } + + intercept(ApplicationCallPipeline.Call) { + val httpLogMessage = HttpLogMessage() + + try { + val rawHttpRequest = ktorHttpRequestToHttpRequest(call) + httpLogMessage.addRequest(rawHttpRequest) + + if(rawHttpRequest.isHealthCheckRequest()) return@intercept + + val httpStubResponse: HttpStubResponse = cachedHttpResponse(rawHttpRequest).response + + respondToKtorHttpResponse( + call, + httpStubResponse.response, + httpStubResponse.delayInMilliSeconds, + specmaticConfig + ) + httpLogMessage.addResponse(httpStubResponse) + } catch (e: ContractException) { + val response = badRequest(e.report()) + httpLogMessage.addResponse(response) + respondToKtorHttpResponse(call, response) + } catch (e: CouldNotParseRequest) { + val response = badRequest("Could not parse request") + httpLogMessage.addResponse(response) + + respondToKtorHttpResponse(call, response) + } catch (e: Throwable) { + val response = internalServerError(exceptionCauseMessage(e) + "\n\n" + e.stackTraceToString()) + httpLogMessage.addResponse(response) + + respondToKtorHttpResponse(call, response) + } + + logger.log(httpLogMessage) + } + + configureHealthCheckModule() + + connector { + this.host = host + this.port = port + } + } + + } + + private val server: ApplicationEngine = embeddedServer(Netty, environment, configure = { + this.callGroupSize = 20 + }) + + init { + server.start() + } + + override val client = HttpClient(endPointFromHostAndPort(host, port, null)) + + override fun setExpectation(json: String) { + return + } + + override fun close() { + server.stop(gracePeriodMillis = timeoutMillis, timeoutMillis = timeoutMillis) + } + + private val specmaticConfig = loadSpecmaticConfig() + private val stubCache = StubCache() + + private fun cachedHttpResponse( + httpRequest: HttpRequest, + ): StubbedResponseResult { + if (features.isEmpty()) + return NotStubbed(HttpStubResponse(HttpResponse(400, "No valid API specifications loaded"))) + + val responses: List = responseDetailsFrom(features, httpRequest) + val fakeResponse = responses.successResponse() + ?: return fakeHttpResponse(features, httpRequest, specmaticConfig) + + val generatedResponse = generateHttpResponseFrom(fakeResponse, httpRequest) + val updatedResponse = cachedResponse( + fakeResponse, + httpRequest, + specmaticConfig.stub.includeMandatoryAndRequestedKeysInResponse + ) ?: generatedResponse + + return FoundStubbedResponse( + HttpStubResponse( + updatedResponse, + contractPath = fakeResponse.feature.path, + feature = fakeResponse.feature, + scenario = fakeResponse.successResponse?.scenario + ) + ) + } + + private fun cachedResponse( + fakeResponse: ResponseDetails, + httpRequest: HttpRequest, + includeMandatoryAndRequestedKeysInResponse: Boolean? + ): HttpResponse? { + val scenario = fakeResponse.successResponse?.scenario + + val generatedResponse = generateHttpResponseFrom(fakeResponse, httpRequest) + val method = scenario?.method + val pathSegments = httpRequest.pathSegments() + if(isUnsupportedResponseBodyForCaching(generatedResponse, method, pathSegments)) return null + + val (resourcePath, resourceId) = resourcePathAndIdFrom(pathSegments) + val resourceIdKey = resourceIdKeyFrom(scenario?.httpRequestPattern) + val attributeSelectionKeys: Set = + scenario?.getFieldsToBeMadeMandatoryBasedOnAttributeSelection(httpRequest.queryParams).orEmpty() + + if (method == "POST") { + val responseBody = + generatePostResponse(generatedResponse, httpRequest)?.includeMandatoryAndRequestedKeys( + fakeResponse, + httpRequest, + includeMandatoryAndRequestedKeysInResponse + ) ?: return null + + stubCache.addResponse(resourcePath, responseBody) + return generatedResponse.withUpdated(responseBody, attributeSelectionKeys) + } + + if(method == "PATCH" && pathSegments.size > 1) { + val responseBody = + generatePatchResponse( + httpRequest, + resourcePath, + resourceIdKey, + resourceId, + fakeResponse + ) ?: return null + + stubCache.updateResponse(resourcePath, responseBody, resourceIdKey, resourceId) + return generatedResponse.withUpdated(responseBody, attributeSelectionKeys) + } + + if(method == "GET" && pathSegments.size == 1) { + val responseBody = stubCache.findAllResponsesFor(resourcePath, attributeSelectionKeys) + return generatedResponse.withUpdated(responseBody, attributeSelectionKeys) + } + + if(method == "GET" && pathSegments.size > 1) { + val responseBody = + stubCache.findResponseFor(resourcePath, resourceIdKey, resourceId)?.responseBody + ?: return HttpResponse(404, "Resource with resourceId '$resourceId' not found") + + return generatedResponse.withUpdated(responseBody, attributeSelectionKeys) + } + + if(method == "DELETE" && pathSegments.size > 1) { + stubCache.deleteResponse(resourcePath, resourceIdKey, resourceId) + return generatedResponse + } + + return null + } + + private fun resourcePathAndIdFrom(pathSegments: List): Pair { + val resourcePath = "/${pathSegments.first()}" + val resourceId = pathSegments.last() + return Pair(resourcePath, resourceId) + } + + private fun HttpRequest.pathSegments(): List { + return this.path?.split("/")?.filter { it.isNotBlank() }.orEmpty() + } + + private fun isUnsupportedResponseBodyForCaching( + generatedResponse: HttpResponse, + method: String?, + pathSegments: List + ): Boolean { + return (generatedResponse.body is JSONObjectValue || + (method == "DELETE" && pathSegments.size > 1) || + (method == "GET" && + generatedResponse.body is JSONArrayValue && + generatedResponse.body.list.firstOrNull() is JSONObjectValue)).not() + } + + private fun generatePostResponse( + generatedResponse: HttpResponse, + httpRequest: HttpRequest + ): JSONObjectValue? { + if (generatedResponse.body !is JSONObjectValue || httpRequest.body !is JSONObjectValue) + return null + + return generatedResponse.body.copy( + jsonObject = patchValuesFromRequestIntoResponse(httpRequest.body, generatedResponse.body) + ) + } + + private fun generatePatchResponse( + httpRequest: HttpRequest, + resourcePath: String, + resourceIdKey: String, + resourceId: String, + fakeResponse: ResponseDetails + ): JSONObjectValue? { + if (httpRequest.body !is JSONObjectValue) return null + + val responseBodyPattern = responseBodyPatternFrom(fakeResponse) ?: return null + if(responseBodyPattern !is JSONObjectPattern) return null + val resolver = fakeResponse.successResponse?.resolver ?: return null + + val cachedResponse = stubCache.findResponseFor(resourcePath, resourceIdKey, resourceId) + + val responseBody = cachedResponse?.responseBody ?: return null + + return responseBody.copy( + jsonObject = patchAndAppendValuesFromRequestIntoResponse( + httpRequest.body, + responseBody, + responseBodyPattern, + resolver + ) + ) + } + + private fun JSONObjectValue.includeMandatoryAndRequestedKeys( + fakeResponse: ResponseDetails, + httpRequest: HttpRequest, + includeMandatoryAndRequestedKeysInResponse: Boolean? + ): JSONObjectValue { + val responseBodyPattern = fakeResponse.successResponse?.responseBodyPattern ?: return this + val resolver = fakeResponse.successResponse.resolver + + val resolvedResponseBodyPattern = responseBodyPatternFrom(fakeResponse) + if(resolvedResponseBodyPattern !is JSONObjectPattern) return this + + if (includeMandatoryAndRequestedKeysInResponse == true && httpRequest.body is JSONObjectValue) { + return this.copy( + jsonObject = patchAndAppendValuesFromRequestIntoResponse( + httpRequest.body, + responseBodyPattern.eliminateOptionalKey(this, resolver) as JSONObjectValue, + resolvedResponseBodyPattern, + resolver + ) + ) + } + + return this + } + + private fun patchValuesFromRequestIntoResponse(requestBody: JSONObjectValue, responseBody: JSONObjectValue): Map { + return responseBody.jsonObject.mapValues { (key, value) -> + val patchValueFromRequest = requestBody.jsonObject.entries.firstOrNull { + it.key == key + }?.value ?: return@mapValues value + + if(patchValueFromRequest::class.java == value::class.java) return@mapValues patchValueFromRequest + value + } + } + + private fun patchAndAppendValuesFromRequestIntoResponse( + requestBody: JSONObjectValue, + responseBody: JSONObjectValue, + responseBodyPattern: JSONObjectPattern, + resolver: Resolver + ): Map { + val acceptedKeysInResponseBody = responseBodyPattern.keysInNonOptionalFormat() + + val entriesFromRequestMissingInTheResponse = requestBody.jsonObject.filter { + it.key in acceptedKeysInResponseBody + && responseBodyPattern.patternForKey(it.key)?.matches(it.value, resolver)?.isSuccess() == true + && responseBody.jsonObject.containsKey(it.key).not() + }.map { + it.key to it.value + }.toMap() + + return patchValuesFromRequestIntoResponse( + requestBody, + responseBody + ).plus(entriesFromRequestMissingInTheResponse) + } + + private fun responseBodyPatternFrom(fakeResponse: ResponseDetails): Pattern? { + val responseBodyPattern = fakeResponse.successResponse?.responseBodyPattern ?: return null + val resolver = fakeResponse.successResponse.resolver + + return resolver.withCyclePrevention(responseBodyPattern) { + resolvedHop(responseBodyPattern, it) + } + } + + private fun resourceIdKeyFrom(httpRequestPattern: HttpRequestPattern?): String { + return httpRequestPattern?.getPathSegmentPatterns()?.last()?.key.orEmpty() + } + + private fun HttpResponse.withUpdated(body: Value, attributeSelectionKeys: Set): HttpResponse { + if(body !is JSONObjectValue) return this.copy(body = body) + return this.copy(body = body.removeKeysNotPresentIn(attributeSelectionKeys)) + } + + private fun loadSpecmaticConfig(): SpecmaticConfig { + return if(specmaticConfigPath != null && File(specmaticConfigPath).exists()) + loadSpecmaticConfig(specmaticConfigPath) + else + SpecmaticConfig() + } +} \ No newline at end of file diff --git a/core/src/main/kotlin/io/specmatic/stub/StubCache.kt b/core/src/main/kotlin/io/specmatic/stub/stateful/StubCache.kt similarity index 97% rename from core/src/main/kotlin/io/specmatic/stub/StubCache.kt rename to core/src/main/kotlin/io/specmatic/stub/stateful/StubCache.kt index d46afa15d..3505d5bb6 100644 --- a/core/src/main/kotlin/io/specmatic/stub/StubCache.kt +++ b/core/src/main/kotlin/io/specmatic/stub/stateful/StubCache.kt @@ -1,4 +1,4 @@ -package io.specmatic.stub +package io.specmatic.stub.stateful import io.specmatic.core.value.JSONArrayValue import io.specmatic.core.value.JSONObjectValue diff --git a/core/src/test/kotlin/io/specmatic/stub/HttpStubKtTest.kt b/core/src/test/kotlin/io/specmatic/stub/HttpStubKtTest.kt index e0c5574fc..7fcda3f6c 100644 --- a/core/src/test/kotlin/io/specmatic/stub/HttpStubKtTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/HttpStubKtTest.kt @@ -1,31 +1,38 @@ package io.specmatic.stub +import io.mockk.InternalPlatformDsl.toStr import io.specmatic.conversions.OpenApiSpecification -import io.specmatic.core.* +import io.specmatic.core.APPLICATION_NAME_LOWER_CASE +import io.specmatic.core.HttpRequest +import io.specmatic.core.HttpResponse +import io.specmatic.core.HttpResponsePattern +import io.specmatic.core.KeyData +import io.specmatic.core.QueryParameters +import io.specmatic.core.Resolver +import io.specmatic.core.SPECMATIC_RESULT_HEADER import io.specmatic.core.log.consoleLog +import io.specmatic.core.parseGherkinStringToFeature import io.specmatic.core.pattern.ContractException import io.specmatic.core.pattern.parsedJSON import io.specmatic.core.pattern.parsedJSONObject import io.specmatic.core.pattern.parsedValue import io.specmatic.core.utilities.exceptionCauseMessage -import io.specmatic.core.value.* +import io.specmatic.core.value.JSONObjectValue +import io.specmatic.core.value.NumberValue +import io.specmatic.core.value.StringValue +import io.specmatic.core.value.XMLValue +import io.specmatic.core.value.toXMLNode import io.specmatic.mock.ScenarioStub import io.specmatic.stubResponse import io.specmatic.test.HttpClient import io.specmatic.trimmedLinesList -import io.mockk.InternalPlatformDsl.toStr import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy -import org.junit.jupiter.api.AfterAll -import org.junit.jupiter.api.BeforeAll -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 org.junit.jupiter.api.condition.DisabledOnOs import org.junit.jupiter.api.condition.OS import org.junit.jupiter.api.fail @@ -979,312 +986,4 @@ paths: assertThat(response.body.toString()).isNotEmpty } } -} - -@TestMethodOrder(MethodOrderer.OrderAnnotation::class) -class StatefulHttpStubTest { - companion object { - private lateinit var httpStub: ContractStub - private const val SPEC_DIR_PATH = "src/test/resources/openapi/spec_with_strictly_restful_apis" - private var resourceId = "" - - @JvmStatic - @BeforeAll - fun setup() { - httpStub = HttpStub( - specmaticConfigPath = "$SPEC_DIR_PATH/specmatic.yaml", - features = listOf( - OpenApiSpecification.fromFile( - "$SPEC_DIR_PATH/spec_with_strictly_restful_apis.yaml" - ).toFeature() - ) - ) - } - - @JvmStatic - @AfterAll - fun tearDown() { - httpStub.close() - } - } - - @Test - @Order(1) - fun `should post a product`() { - val response = httpStub.client.execute( - HttpRequest( - method = "POST", - path = "/products", - body = parsedJSONObject( - """ - { - "name": "Product A", - "description": "A detailed description of Product A.", - "price": 19.99, - "inStock": true - } - """.trimIndent() - ) - ) - ) - - assertThat(response.status).isEqualTo(201) - val responseBody = response.body as JSONObjectValue - - resourceId = responseBody.getStringValue("id").orEmpty() - - assertThat(responseBody.getStringValue("name")).isEqualTo("Product A") - assertThat(responseBody.getStringValue("description")).isEqualTo("A detailed description of Product A.") - assertThat(responseBody.getStringValue("price")).isEqualTo("19.99") - assertThat(responseBody.getStringValue("inStock")).isEqualTo("true") - } - - @Test - @Order(2) - 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 responseObjectFromResponseBody = (response.body as JSONArrayValue).list.first() as JSONObjectValue - - assertThat(responseObjectFromResponseBody.getStringValue("name")).isEqualTo("Product A") - assertThat(responseObjectFromResponseBody.getStringValue("description")).isEqualTo("A detailed description of Product A.") - assertThat(responseObjectFromResponseBody.getStringValue("price")).isEqualTo("19.99") - assertThat(responseObjectFromResponseBody.getStringValue("inStock")).isEqualTo("true") - } - - @Test - @Order(3) - fun `should update an existing product with patch`() { - val response = httpStub.client.execute( - HttpRequest( - method = "PATCH", - path = "/products/$resourceId", - body = parsedJSONObject( - """ - { - "name": "Product B", - "price": 100 - } - """.trimIndent() - ) - ) - ) - - assertThat(response.status).isEqualTo(200) - 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("description")).isEqualTo("A detailed description of Product A.") - assertThat(responseBody.getStringValue("inStock")).isEqualTo("true") - } - - @Test - @Order(4) - fun `should get the updated product`() { - val response = httpStub.client.execute( - HttpRequest( - method = "GET", - path = "/products/$resourceId" - ) - ) - - assertThat(response.status).isEqualTo(200) - 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("description")).isEqualTo("A detailed description of Product A.") - assertThat(responseBody.getStringValue("inStock")).isEqualTo("true") - } - - @Test - @Order(5) - fun `should delete a product`() { - val response = httpStub.client.execute( - HttpRequest( - method = "DELETE", - path = "/products/$resourceId" - ) - ) - - assertThat(response.status).isEqualTo(204) - - val getResponse = httpStub.client.execute( - HttpRequest( - method = "GET", - path = "/products/$resourceId" - ) - ) - - assertThat(getResponse.status).isEqualTo(404) - } - - private fun JSONObjectValue.getStringValue(key: String): String? { - return this.jsonObject[key]?.toStringLiteral() - } -} - -@TestMethodOrder(MethodOrderer.OrderAnnotation::class) -class StatefulHttpStubWithAttributeSelectionTest { - companion object { - private lateinit var httpStub: ContractStub - private const val SPEC_DIR_PATH = "src/test/resources/openapi/spec_with_strictly_restful_apis" - private var resourceId = "" - - @JvmStatic - @BeforeAll - fun setup() { - val feature = OpenApiSpecification.fromFile( - "$SPEC_DIR_PATH/spec_with_strictly_restful_apis.yaml" - ).toFeature() - val specmaticConfig = loadSpecmaticConfig("$SPEC_DIR_PATH/specmatic.yaml") - val scenarios = feature.scenarios.map { - it.copy(attributeSelectionPattern = specmaticConfig.attributeSelectionPattern) - } - httpStub = HttpStub( - specmaticConfigPath = "$SPEC_DIR_PATH/specmatic.yaml", - features = listOf(feature.copy(scenarios = scenarios)) - ) - } - - @JvmStatic - @AfterAll - fun tearDown() { - httpStub.close() - } - } - - @Test - @Order(1) - fun `should post a product`() { - val response = httpStub.client.execute( - HttpRequest( - method = "POST", - path = "/products", - body = parsedJSONObject( - """ - { - "name": "Product A", - "description": "A detailed description of Product A.", - "price": 19.99, - "inStock": true - } - """.trimIndent() - ), - queryParams = QueryParameters( - mapOf( - "columns" to "name,description" - ) - ) - ) - ) - - assertThat(response.status).isEqualTo(201) - val responseBody = response.body as JSONObjectValue - - assertThat(responseBody.jsonObject.keys).containsExactlyInAnyOrder("id", "name", "description") - - resourceId = responseBody.getStringValue("id").orEmpty() - - assertThat(responseBody.getStringValue("name")).isEqualTo("Product A") - assertThat(responseBody.getStringValue("description")).isEqualTo("A detailed description of Product A.") - } - - @Test - @Order(2) - fun `should get the list of products`() { - val response = httpStub.client.execute( - HttpRequest( - method = "GET", - path = "/products", - queryParams = QueryParameters( - mapOf( - "columns" to "price,inStock" - ) - ) - ) - ) - - assertThat(response.status).isEqualTo(200) - assertThat(response.body).isInstanceOf(JSONArrayValue::class.java) - - val responseObjectFromResponseBody = (response.body as JSONArrayValue).list.first() as JSONObjectValue - - assertThat(responseObjectFromResponseBody.jsonObject.keys).containsExactlyInAnyOrder("id", "price", "inStock") - - assertThat(responseObjectFromResponseBody.getStringValue("price")).isEqualTo("19.99") - assertThat(responseObjectFromResponseBody.getStringValue("inStock")).isEqualTo("true") - } - - @Test - @Order(3) - fun `should update an existing product with patch`() { - val response = httpStub.client.execute( - HttpRequest( - method = "PATCH", - path = "/products/$resourceId", - body = parsedJSONObject( - """ - { - "name": "Product B", - "price": 100 - } - """.trimIndent() - ), - queryParams = QueryParameters( - mapOf( - "columns" to "name,description" - ) - ) - ) - ) - - assertThat(response.status).isEqualTo(200) - val responseBody = response.body as JSONObjectValue - - assertThat(responseBody.jsonObject.keys).containsExactlyInAnyOrder("id", "name", "description") - assertThat(responseBody.getStringValue("id")).isEqualTo(resourceId) - assertThat(responseBody.getStringValue("name")).isEqualTo("Product B") - assertThat(responseBody.getStringValue("description")).isEqualTo("A detailed description of Product A.") - } - - @Test - @Order(4) - fun `should get the updated product`() { - val response = httpStub.client.execute( - HttpRequest( - method = "GET", - path = "/products/$resourceId", - queryParams = QueryParameters( - mapOf( - "columns" to "name,description,price" - ) - ) - ) - ) - - assertThat(response.status).isEqualTo(200) - val responseBody = response.body as JSONObjectValue - - assertThat(responseBody.jsonObject.keys).containsExactlyInAnyOrder("id", "name", "price", "description") - assertThat(responseBody.getStringValue("id")).isEqualTo(resourceId) - assertThat(responseBody.getStringValue("name")).isEqualTo("Product B") - assertThat(responseBody.getStringValue("price")).isEqualTo("100") - assertThat(responseBody.getStringValue("description")).isEqualTo("A detailed description of Product A.") - } - - private fun JSONObjectValue.getStringValue(key: String): String? { - return this.jsonObject[key]?.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 new file mode 100644 index 000000000..b2dee944a --- /dev/null +++ b/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt @@ -0,0 +1,323 @@ +package io.specmatic.stub.stateful + +import io.specmatic.conversions.OpenApiSpecification +import io.specmatic.core.* +import io.specmatic.core.pattern.parsedJSONObject +import io.specmatic.core.value.* +import io.specmatic.stub.ContractStub +import io.specmatic.stub.HttpStub +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestMethodOrder + +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) +class StatefulHttpStubTest { + companion object { + private lateinit var httpStub: ContractStub + private const val SPEC_DIR_PATH = "src/test/resources/openapi/spec_with_strictly_restful_apis" + private var resourceId = "" + + @JvmStatic + @BeforeAll + fun setup() { + httpStub = StatefulHttpStub( + specmaticConfigPath = "$SPEC_DIR_PATH/specmatic.yaml", + features = listOf( + OpenApiSpecification.fromFile( + "$SPEC_DIR_PATH/spec_with_strictly_restful_apis.yaml" + ).toFeature() + ) + ) + } + + @JvmStatic + @AfterAll + fun tearDown() { + httpStub.close() + } + } + + @Test + @Order(1) + fun `should post a product`() { + val response = httpStub.client.execute( + HttpRequest( + method = "POST", + path = "/products", + body = parsedJSONObject( + """ + { + "name": "Product A", + "description": "A detailed description of Product A.", + "price": 19.99, + "inStock": true + } + """.trimIndent() + ) + ) + ) + + assertThat(response.status).isEqualTo(201) + val responseBody = response.body as JSONObjectValue + + resourceId = responseBody.getStringValue("id").orEmpty() + + assertThat(responseBody.getStringValue("name")).isEqualTo("Product A") + assertThat(responseBody.getStringValue("description")).isEqualTo("A detailed description of Product A.") + assertThat(responseBody.getStringValue("price")).isEqualTo("19.99") + assertThat(responseBody.getStringValue("inStock")).isEqualTo("true") + } + + @Test + @Order(2) + 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 responseObjectFromResponseBody = (response.body as JSONArrayValue).list.first() as JSONObjectValue + + assertThat(responseObjectFromResponseBody.getStringValue("name")).isEqualTo("Product A") + assertThat(responseObjectFromResponseBody.getStringValue("description")).isEqualTo("A detailed description of Product A.") + assertThat(responseObjectFromResponseBody.getStringValue("price")).isEqualTo("19.99") + assertThat(responseObjectFromResponseBody.getStringValue("inStock")).isEqualTo("true") + } + + @Test + @Order(3) + fun `should update an existing product with patch`() { + val response = httpStub.client.execute( + HttpRequest( + method = "PATCH", + path = "/products/$resourceId", + body = parsedJSONObject( + """ + { + "name": "Product B", + "price": 100 + } + """.trimIndent() + ) + ) + ) + + assertThat(response.status).isEqualTo(200) + 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("description")).isEqualTo("A detailed description of Product A.") + assertThat(responseBody.getStringValue("inStock")).isEqualTo("true") + } + + @Test + @Order(4) + fun `should get the updated product`() { + val response = httpStub.client.execute( + HttpRequest( + method = "GET", + path = "/products/$resourceId" + ) + ) + + assertThat(response.status).isEqualTo(200) + 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("description")).isEqualTo("A detailed description of Product A.") + assertThat(responseBody.getStringValue("inStock")).isEqualTo("true") + } + + @Test + @Order(5) + fun `should delete a product`() { + val response = httpStub.client.execute( + HttpRequest( + method = "DELETE", + path = "/products/$resourceId" + ) + ) + + assertThat(response.status).isEqualTo(204) + + val getResponse = httpStub.client.execute( + HttpRequest( + method = "GET", + path = "/products/$resourceId" + ) + ) + + assertThat(getResponse.status).isEqualTo(404) + } + + private fun JSONObjectValue.getStringValue(key: String): String? { + return this.jsonObject[key]?.toStringLiteral() + } +} + +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) +class StatefulHttpStubWithAttributeSelectionTest { + companion object { + private lateinit var httpStub: ContractStub + private const val SPEC_DIR_PATH = "src/test/resources/openapi/spec_with_strictly_restful_apis" + private var resourceId = "" + + @JvmStatic + @BeforeAll + fun setup() { + val feature = OpenApiSpecification.fromFile( + "$SPEC_DIR_PATH/spec_with_strictly_restful_apis.yaml" + ).toFeature() + val specmaticConfig = loadSpecmaticConfig("$SPEC_DIR_PATH/specmatic.yaml") + val scenarios = feature.scenarios.map { + it.copy(attributeSelectionPattern = specmaticConfig.attributeSelectionPattern) + } + httpStub = StatefulHttpStub( + specmaticConfigPath = "$SPEC_DIR_PATH/specmatic.yaml", + features = listOf(feature.copy(scenarios = scenarios)) + ) + } + + @JvmStatic + @AfterAll + fun tearDown() { + httpStub.close() + } + } + + @Test + @Order(1) + fun `should post a product`() { + val response = httpStub.client.execute( + HttpRequest( + method = "POST", + path = "/products", + body = parsedJSONObject( + """ + { + "name": "Product A", + "description": "A detailed description of Product A.", + "price": 19.99, + "inStock": true + } + """.trimIndent() + ), + queryParams = QueryParameters( + mapOf( + "columns" to "name,description" + ) + ) + ) + ) + + assertThat(response.status).isEqualTo(201) + val responseBody = response.body as JSONObjectValue + + assertThat(responseBody.jsonObject.keys).containsExactlyInAnyOrder("id", "name", "description") + + resourceId = responseBody.getStringValue("id").orEmpty() + + assertThat(responseBody.getStringValue("name")).isEqualTo("Product A") + assertThat(responseBody.getStringValue("description")).isEqualTo("A detailed description of Product A.") + } + + @Test + @Order(2) + fun `should get the list of products`() { + val response = httpStub.client.execute( + HttpRequest( + method = "GET", + path = "/products", + queryParams = QueryParameters( + mapOf( + "columns" to "price,inStock" + ) + ) + ) + ) + + assertThat(response.status).isEqualTo(200) + assertThat(response.body).isInstanceOf(JSONArrayValue::class.java) + + val responseObjectFromResponseBody = (response.body as JSONArrayValue).list.first() as JSONObjectValue + + assertThat(responseObjectFromResponseBody.jsonObject.keys).containsExactlyInAnyOrder("id", "price", "inStock") + + assertThat(responseObjectFromResponseBody.getStringValue("price")).isEqualTo("19.99") + assertThat(responseObjectFromResponseBody.getStringValue("inStock")).isEqualTo("true") + } + + @Test + @Order(3) + fun `should update an existing product with patch`() { + val response = httpStub.client.execute( + HttpRequest( + method = "PATCH", + path = "/products/$resourceId", + body = parsedJSONObject( + """ + { + "name": "Product B", + "price": 100 + } + """.trimIndent() + ), + queryParams = QueryParameters( + mapOf( + "columns" to "name,description" + ) + ) + ) + ) + + assertThat(response.status).isEqualTo(200) + val responseBody = response.body as JSONObjectValue + + assertThat(responseBody.jsonObject.keys).containsExactlyInAnyOrder("id", "name", "description") + assertThat(responseBody.getStringValue("id")).isEqualTo(resourceId) + assertThat(responseBody.getStringValue("name")).isEqualTo("Product B") + assertThat(responseBody.getStringValue("description")).isEqualTo("A detailed description of Product A.") + } + + @Test + @Order(4) + fun `should get the updated product`() { + val response = httpStub.client.execute( + HttpRequest( + method = "GET", + path = "/products/$resourceId", + queryParams = QueryParameters( + mapOf( + "columns" to "name,description,price" + ) + ) + ) + ) + + assertThat(response.status).isEqualTo(200) + val responseBody = response.body as JSONObjectValue + + assertThat(responseBody.jsonObject.keys).containsExactlyInAnyOrder("id", "name", "price", "description") + assertThat(responseBody.getStringValue("id")).isEqualTo(resourceId) + assertThat(responseBody.getStringValue("name")).isEqualTo("Product B") + assertThat(responseBody.getStringValue("price")).isEqualTo("100") + assertThat(responseBody.getStringValue("description")).isEqualTo("A detailed description of Product A.") + } + + 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/specmatic.yaml b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/specmatic.yaml index 916683747..1ba8e0707 100644 --- a/core/src/test/resources/openapi/spec_with_strictly_restful_apis/specmatic.yaml +++ b/core/src/test/resources/openapi/spec_with_strictly_restful_apis/specmatic.yaml @@ -3,9 +3,6 @@ sources: stub: - api.yaml -stub: - stateful: true - attributeSelectionPattern: defaultFields: - id