diff --git a/kbdd/src/main/kotlin/ru/fix/kbdd/rest/Rest.kt b/kbdd/src/main/kotlin/ru/fix/kbdd/rest/Rest.kt index 280e0ce..032fcef 100644 --- a/kbdd/src/main/kotlin/ru/fix/kbdd/rest/Rest.kt +++ b/kbdd/src/main/kotlin/ru/fix/kbdd/rest/Rest.kt @@ -13,7 +13,7 @@ import io.restassured.path.json.config.JsonPathConfig import io.restassured.response.Response import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.withContext +import kotlinx.coroutines.runInterruptible import mu.KotlinLogging import ru.fix.corounit.allure.AllureStep import ru.fix.kbdd.asserts.AlluredKPath @@ -31,22 +31,23 @@ private val log = KotlinLogging.logger { } object Rest { private val defaultMapper = jacksonObjectMapper() private val doNotSendNullsMapper = defaultMapper.copy() - .setSerializationInclusion(JsonInclude.Include.NON_NULL) + .setSerializationInclusion(JsonInclude.Include.NON_NULL) private fun selectMapper(sendNulls: Boolean = true) = - if (sendNulls) { - defaultMapper - } else { - doNotSendNullsMapper - } + if (sendNulls) { + defaultMapper + } else { + doNotSendNullsMapper + } private val lastResponse = ThreadLocal() var threadPoolSize = 10 + var restAssuredConfigCustomizer: RestAssuredConfig.() -> RestAssuredConfig = { this } private val dispatcher by lazy { - Executors.newFixedThreadPool(10).asCoroutineDispatcher() + + Executors.newFixedThreadPool(threadPoolSize).asCoroutineDispatcher() + CoroutineExceptionHandler { _, thr -> log.error(thr) {} } } @@ -60,86 +61,88 @@ object Rest { val dsl = RequestDsl().apply(request) val config = RestAssuredConfig.config() - .encoderConfig( - EncoderConfig.encoderConfig().defaultContentCharset(Charsets.UTF_8) - .defaultCharsetForContentType(Charsets.UTF_8, ContentType.JSON) - ) - .decoderConfig( - DecoderConfig.decoderConfig().defaultContentCharset(Charsets.UTF_8) - .defaultCharsetForContentType(Charsets.UTF_8, ContentType.JSON) - ) - .jsonConfig( - // Default value FLOAT_AND_DOUBLE results in rounded fractional values. - // Conversion happens in ConfigurableJsonSlurper. If value fits into Float it converts it by - // calling BigDecimal.floatValue(). - // From floatValue() javadoc: "Note that even when the return - // value is finite, this conversion can lose information about the precision". - JsonConfig.jsonConfig().numberReturnType(JsonPathConfig.NumberReturnType.DOUBLE) - ) - .let { c -> - val followRedirects = dsl.followRedirects - ?: return@let c - - c.redirect(c.redirectConfig.followRedirects(followRedirects)) - } + .encoderConfig( + EncoderConfig.encoderConfig() + .defaultContentCharset(Charsets.UTF_8) + .defaultCharsetForContentType(Charsets.UTF_8, ContentType.JSON) + ) + .decoderConfig( + DecoderConfig.decoderConfig() + .defaultContentCharset(Charsets.UTF_8) + .defaultCharsetForContentType(Charsets.UTF_8, ContentType.JSON) + ) + .jsonConfig( + // Default value FLOAT_AND_DOUBLE results in rounded fractional values. + // Conversion happens in ConfigurableJsonSlurper. If value fits into Float it converts it by + // calling BigDecimal.floatValue(). + // From floatValue() javadoc: "Note that even when the return + // value is finite, this conversion can lose information about the precision". + JsonConfig.jsonConfig().numberReturnType(JsonPathConfig.NumberReturnType.DOUBLE) + ) + .let { config -> + val followRedirects = dsl.followRedirects + ?: return@let config + + config.redirect(config.redirectConfig.followRedirects(followRedirects)) + }.restAssuredConfigCustomizer() val allureStep = AllureStep.fromCurrentCoroutineContext() val spec = given() - .filter(HttpAllureAttachmentFilter(allureStep)) - .run { - when { - dsl.formParams != null -> contentType(ContentType.URLENC) - dsl.bodyJsonDsl != null -> contentType(ContentType.JSON) - dsl.bodyString != null -> contentType(ContentType.JSON) - dsl.bodyXml != null -> contentType(ContentType.XML) - else -> this - } - } - .config(config) - .run { - dsl.headers?.let { headers(it) } ?: this - } - .run { - dsl.baseUrl?.let { baseUri(it) } ?: this + .filter(HttpAllureAttachmentFilter(allureStep)) + .run { + when { + dsl.formParams != null -> contentType(ContentType.URLENC) + dsl.bodyJsonDsl != null -> contentType(ContentType.JSON) + dsl.bodyString != null -> contentType(ContentType.JSON) + dsl.bodyXml != null -> contentType(ContentType.XML) + else -> this } - .run { - when { - dsl.bodyJsonDsl != null -> { - val selectedMapper = selectMapper(dsl.bodyJsonSendNulls) - val objectNode = selectedMapper.json(dsl.bodyJsonDsl!!) - if (!dsl.bodyJsonSendNulls) { - removeNullFiledsInObjectNodes(listOf(objectNode)) - } - val content = selectedMapper.writeValueAsString(objectNode) - body(content) + } + .config(config) + .run { + dsl.headers?.let { headers(it) } ?: this + } + .run { + dsl.baseUrl?.let { baseUri(it) } ?: this + } + .run { + when { + dsl.bodyJsonDsl != null -> { + val selectedMapper = selectMapper(dsl.bodyJsonSendNulls) + val objectNode = selectedMapper.json(dsl.bodyJsonDsl!!) + if (!dsl.bodyJsonSendNulls) { + removeNullFiledsInObjectNodes(listOf(objectNode)) } + val content = selectedMapper.writeValueAsString(objectNode) + body(content) + } - dsl.bodyString != null -> - body(dsl.bodyString!!) + dsl.bodyString != null -> + body(dsl.bodyString!!) - dsl.bodyXml != null -> - body(dsl.bodyXml!!) + dsl.bodyXml != null -> + body(dsl.bodyXml!!) - else -> this - } - } - .run { - dsl.formParams?.let { formParams(it) } ?: this - } - .run { - dsl.queryParams?.let { queryParams(it) } ?: this - } - .run { - dsl.filename?.let { name -> - dsl.fileContent?.let { content -> - multiPart("file", name, content) - } - } ?: this + else -> this } + } + .run { + dsl.formParams?.let { formParams(it) } ?: this + } + .run { + dsl.queryParams?.let { queryParams(it) } ?: this + } + .run { + dsl.filename?.let { name -> + dsl.fileContent?.let { content -> + multiPart("file", name, content) + } + } ?: this + } - val response = withContext(dispatcher) { + val response = runInterruptible(dispatcher) { try { when { dsl.post != null -> spec.post(dsl.post) @@ -147,12 +150,15 @@ object Rest { dsl.delete != null -> spec.delete(dsl.delete) dsl.put != null -> spec.put(dsl.put) else -> throw IllegalArgumentException( - "Neither post, get, delete or put method was declared in request") + "Neither post, get, delete or put method was declared in request" + ) } } catch (exc: Exception) { - throw RuntimeException("Failed to execute request with" + - " baseUrl: ${dsl.baseUrl}" + - ", path: ${dsl.post ?: dsl.get ?: dsl.delete ?: dsl.put}", exc) + throw RuntimeException( + "Failed to execute request with" + + " baseUrl: ${dsl.baseUrl}" + + ", path: ${dsl.post ?: dsl.get ?: dsl.delete ?: dsl.put}", exc + ) } } @@ -284,7 +290,7 @@ object Rest { */ fun json(json: Json.() -> Unit): ObjectNode = defaultMapper.json(json) - private suspend fun rawResponse() = lastResponse.get() ?: throw IllegalStateException("Previous response not found") + private fun rawResponse() = lastResponse.get() ?: throw IllegalStateException("Previous response not found") /** * provide access to response status code @@ -292,10 +298,10 @@ object Rest { suspend fun statusCode(): Checkable { val response = rawResponse() return AlluredKPath( - parentStep = AllureStep.fromCurrentCoroutineContext(), - node = response.statusCode, - mode = KPath.Mode.IMMEDIATE_ASSERT, - path = "statusCode()" + parentStep = AllureStep.fromCurrentCoroutineContext(), + node = response.statusCode, + mode = KPath.Mode.IMMEDIATE_ASSERT, + path = "statusCode()" ) } @@ -305,10 +311,10 @@ object Rest { suspend fun statusLine(): Checkable { val response = rawResponse() return AlluredKPath( - parentStep = AllureStep.fromCurrentCoroutineContext(), - node = response.statusLine, - mode = KPath.Mode.IMMEDIATE_ASSERT, - path = "statusLine()" + parentStep = AllureStep.fromCurrentCoroutineContext(), + node = response.statusLine, + mode = KPath.Mode.IMMEDIATE_ASSERT, + path = "statusLine()" ) } @@ -318,10 +324,10 @@ object Rest { suspend fun bodyString(): Checkable { val response = rawResponse() return AlluredKPath( - parentStep = AllureStep.fromCurrentCoroutineContext(), - node = response.body().asString(), - mode = KPath.Mode.IMMEDIATE_ASSERT, - path = "bodyString()" + parentStep = AllureStep.fromCurrentCoroutineContext(), + node = response.body().asString(), + mode = KPath.Mode.IMMEDIATE_ASSERT, + path = "bodyString()" ) } @@ -331,46 +337,46 @@ object Rest { suspend fun bodyJson(): Explorable { val response = rawResponse() return AlluredKPath( - parentStep = AllureStep.fromCurrentCoroutineContext(), - node = response.jsonPath().get()!!, - mode = KPath.Mode.IMMEDIATE_ASSERT, - path = "bodyJson()" + parentStep = AllureStep.fromCurrentCoroutineContext(), + node = response.jsonPath().get()!!, + mode = KPath.Mode.IMMEDIATE_ASSERT, + path = "bodyJson()" ) } suspend fun bodyXml(): Explorable { val response = rawResponse() return AlluredKPath( - parentStep = AllureStep.fromCurrentCoroutineContext(), - node = response.xmlPath(), - mode = KPath.Mode.IMMEDIATE_ASSERT, - path = "bodyXml()" + parentStep = AllureStep.fromCurrentCoroutineContext(), + node = response.xmlPath(), + mode = KPath.Mode.IMMEDIATE_ASSERT, + path = "bodyXml()" ) } suspend fun cookie(): Explorable { val response = rawResponse() return AlluredKPath( - parentStep = AllureStep.fromCurrentCoroutineContext(), - node = response.cookies, - mode = KPath.Mode.IMMEDIATE_ASSERT, - path = "cookie()" + parentStep = AllureStep.fromCurrentCoroutineContext(), + node = response.cookies, + mode = KPath.Mode.IMMEDIATE_ASSERT, + path = "cookie()" ) } suspend fun headers(): Explorable { val response = rawResponse() return AlluredKPath( - parentStep = AllureStep.fromCurrentCoroutineContext(), - node = response.headers.groupBy { - it.name - }.mapValues { - it.value.joinToString { header -> - header.value - } - }, - mode = KPath.Mode.IMMEDIATE_ASSERT, - path = "headers()" + parentStep = AllureStep.fromCurrentCoroutineContext(), + node = response.headers.groupBy { + it.name + }.mapValues { + it.value.joinToString { header -> + header.value + } + }, + mode = KPath.Mode.IMMEDIATE_ASSERT, + path = "headers()" ) } diff --git a/kbdd/src/test/kotlin/ru/fix/kbdd/rest/RestTest.kt b/kbdd/src/test/kotlin/ru/fix/kbdd/rest/RestTest.kt index 872a829..5a52ebf 100644 --- a/kbdd/src/test/kotlin/ru/fix/kbdd/rest/RestTest.kt +++ b/kbdd/src/test/kotlin/ru/fix/kbdd/rest/RestTest.kt @@ -2,8 +2,15 @@ package ru.fix.kbdd.rest import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.WireMock.* +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.assertions.throwables.shouldThrowAny import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.throwable.shouldHaveCause +import io.kotest.matchers.throwable.shouldHaveCauseInstanceOf +import io.kotest.matchers.throwable.shouldHaveCauseOfType +import io.restassured.config.HttpClientConfig import mu.KotlinLogging +import org.apache.http.params.CoreConnectionPNames import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.fail @@ -14,6 +21,8 @@ import ru.fix.kbdd.rest.Rest.request import ru.fix.kbdd.rest.Rest.statusCode import ru.fix.kbdd.rest.Rest.statusLine import ru.fix.stdlib.socket.SocketChecker +import java.net.SocketTimeoutException +import java.time.Duration private val log = KotlinLogging.logger { } @@ -21,18 +30,26 @@ private val log = KotlinLogging.logger { } class RestTest { private lateinit var server: WireMockServer + companion object { + val HUGE_DELAY_REST_DELAY: Duration = Duration.ofMinutes(1) + } + suspend fun beforeAll() { server = WireMockServer(SocketChecker.getAvailableRandomPort()) server.start() for (path in listOf( - "/json-post-request", - "/json-post-from-string-request")) { - - server.stubFor(post(urlPathEqualTo(path)) - .willReturn(aResponse() + "/json-post-request", + "/json-post-from-string-request" + )) { + + server.stubFor( + post(urlPathEqualTo(path)) + .willReturn( + aResponse() .withHeader("Content-Type", "application/json") - .withBody("""{ + .withBody( + """{ "data":{ "entries":[ { @@ -45,29 +62,52 @@ class RestTest { }], "result":56 } - }"""))) + }""" + ) + ) + ) } for (path in listOf( - "/post-form-data-request", - "/json-post-without-nulls", - "/json-post-with-nulls", - "/json-post-dto-as-part-of-json-dsl", - "/json-post-dto-object-in-body", - "/json-post-dto-with-nulls", - "/json-post-dsl-dto-with-nulls", - "/json-post-dsl-dto-without-nulls", - "/json-post-dto-without-nulls")) { - - server.stubFor(post(urlPathEqualTo(path)) - .willReturn(aResponse() + "/post-form-data-request", + "/json-post-without-nulls", + "/json-post-with-nulls", + "/json-post-dto-as-part-of-json-dsl", + "/json-post-dto-object-in-body", + "/json-post-dto-with-nulls", + "/json-post-dsl-dto-with-nulls", + "/json-post-dsl-dto-without-nulls", + "/json-post-dto-without-nulls" + )) { + + server.stubFor( + post(urlPathEqualTo(path)) + .willReturn( + aResponse() .withHeader("Content-Type", "application/json") - .withBody("""{ + .withBody( + """{ "status":"success" - }"""))) + }""" + ) + ) + ) } + + server.stubFor( + get(urlPathEqualTo("/get-with-huge-delay")) + .willReturn( + aResponse() + .withFixedDelay(HUGE_DELAY_REST_DELAY.toMillis().toInt()) + .withBody( + """{ + "status":"success" + }""" + ) + ) + ) } suspend fun afterAll() { @@ -89,14 +129,14 @@ class RestTest { body { "data" { "entries" % array( - { - "name" % "one" - "value" % 1 - }, - { - "name" % "two" - "value" % 2 - } + { + "name" % "one" + "value" % 1 + }, + { + "name" % "two" + "value" % 2 + } ) } @@ -113,19 +153,19 @@ class RestTest { }.single()["value"].isEquals(2) server.verify( - postRequestedFor(urlPathEqualTo("/json-post-request")) - .withQueryParam("queryParam10", equalTo("10")) - .withQueryParam("queryParam11", equalTo("11")) - .withHeader("my-header1", equalTo("header-value1")) - .withHeader("my-header2", equalTo("header-value2")) + postRequestedFor(urlPathEqualTo("/json-post-request")) + .withQueryParam("queryParam10", equalTo("10")) + .withQueryParam("queryParam11", equalTo("11")) + .withHeader("my-header1", equalTo("header-value1")) + .withHeader("my-header2", equalTo("header-value2")) ) try { bodyJson()["data"]["entries"] - .first { it["value"].isEquals(2) }["name"] - .isEquals("one") + .first { it["value"].isEquals(2) }["name"] + .isEquals("one") fail("there should be an assertion error, but was none") } catch (err: AssertionError) { err.message.shouldContain("first") @@ -158,13 +198,13 @@ class RestTest { statusLine().isContains("OK") server.verify( - postRequestedFor(urlPathEqualTo("/json-post-from-string-request")) - .withQueryParam("queryParam10", equalTo("10")) - .withQueryParam("queryParam11", equalTo("11")) - .withHeader("my-header1", equalTo("header-value1")) - .withHeader("my-header2", equalTo("header-value2")) - .withHeader("Content-Type", containing("application/json")) - .withRequestBody(equalToJson(json)) + postRequestedFor(urlPathEqualTo("/json-post-from-string-request")) + .withQueryParam("queryParam10", equalTo("10")) + .withQueryParam("queryParam11", equalTo("11")) + .withHeader("my-header1", equalTo("header-value1")) + .withHeader("my-header2", equalTo("header-value2")) + .withHeader("Content-Type", containing("application/json")) + .withRequestBody(equalToJson(json)) ) } @@ -182,10 +222,10 @@ class RestTest { bodyJson()["status"].isEquals("success") server.verify( - postRequestedFor(urlPathEqualTo("/post-form-data-request")) - .withRequestBody(containing("formParam10=10")) - .withRequestBody(containing("formParam11=11")) - .withHeader("my-header", equalTo("header-value")) + postRequestedFor(urlPathEqualTo("/post-form-data-request")) + .withRequestBody(containing("formParam10=10")) + .withRequestBody(containing("formParam11=11")) + .withHeader("my-header", equalTo("header-value")) ) } @@ -208,8 +248,8 @@ class RestTest { } """ server.verify( - postRequestedFor(urlPathEqualTo("/json-post-dto-object-in-body")) - .withRequestBody(equalToJson(json)) + postRequestedFor(urlPathEqualTo("/json-post-dto-object-in-body")) + .withRequestBody(equalToJson(json)) ) } @@ -236,8 +276,8 @@ class RestTest { } """ server.verify( - postRequestedFor(urlPathEqualTo("/json-post-dto-as-part-of-json-dsl")) - .withRequestBody(equalToJson(json)) + postRequestedFor(urlPathEqualTo("/json-post-dto-as-part-of-json-dsl")) + .withRequestBody(equalToJson(json)) ) } @@ -255,13 +295,17 @@ class RestTest { } statusCode().isEquals(200) server.verify( - postRequestedFor(urlPathEqualTo("/json-post-with-nulls")) - .withRequestBody(equalToJson(""" + postRequestedFor(urlPathEqualTo("/json-post-with-nulls")) + .withRequestBody( + equalToJson( + """ { "one": 1, "two": null } - """)) + """ + ) + ) ) } @@ -279,12 +323,16 @@ class RestTest { } statusCode().isEquals(200) server.verify( - postRequestedFor(urlPathEqualTo("/json-post-without-nulls")) - .withRequestBody(equalToJson(""" + postRequestedFor(urlPathEqualTo("/json-post-without-nulls")) + .withRequestBody( + equalToJson( + """ { "one": 1 } - """)) + """ + ) + ) ) } @@ -302,14 +350,18 @@ class RestTest { } statusCode().isEquals(200) server.verify( - postRequestedFor(urlPathEqualTo("/json-post-dto-without-nulls")) - .withRequestBody(equalToJson(""" + postRequestedFor(urlPathEqualTo("/json-post-dto-without-nulls")) + .withRequestBody( + equalToJson( + """ { "myObject": { "foo": "foo" } } - """)) + """ + ) + ) ) } @@ -325,13 +377,17 @@ class RestTest { } statusCode().isEquals(200) server.verify( - postRequestedFor(urlPathEqualTo("/json-post-dto-with-nulls")) - .withRequestBody(equalToJson(""" + postRequestedFor(urlPathEqualTo("/json-post-dto-with-nulls")) + .withRequestBody( + equalToJson( + """ { "foo": "foo", "bar": null } - """)) + """ + ) + ) ) } @@ -351,8 +407,10 @@ class RestTest { } statusCode().isEquals(200) server.verify( - postRequestedFor(urlPathEqualTo("/json-post-dsl-dto-with-nulls")) - .withRequestBody(equalToJson(""" + postRequestedFor(urlPathEqualTo("/json-post-dsl-dto-with-nulls")) + .withRequestBody( + equalToJson( + """ { "one": 1, "two": null, @@ -361,7 +419,9 @@ class RestTest { "bar": null } } - """)) + """ + ) + ) ) } @@ -381,18 +441,24 @@ class RestTest { } statusCode().isEquals(200) server.verify( - postRequestedFor(urlPathEqualTo("/json-post-dsl-dto-without-nulls")) - .withRequestBody(equalToJson(""" + postRequestedFor(urlPathEqualTo("/json-post-dsl-dto-without-nulls")) + .withRequestBody( + equalToJson( + """ { "one": 1, "myObject": { "foo": "foo" } } - """)) + """ + ) + ) ) } + + @Test suspend fun `post multipart data`() { request { @@ -406,18 +472,42 @@ class RestTest { bodyJson()["status"].isEquals("success") server.verify( - postRequestedFor(urlPathEqualTo("/post-form-data-request")) - .withHeader("my-header", equalTo("header-value")) - .withHeader("Content-Type", containing("multipart/form-data")) - .withRequestBodyPart( - aMultipart() - .withHeader("Content-Disposition", containing("form-data")) - .withHeader("Content-Disposition", containing("name=\"file\"")) - .withHeader("Content-Disposition", containing("filename=\"multipart-data-file.json\"")) - .withHeader("Content-Type", equalTo("application/octet-stream")) - .withBody(equalTo("{\n \"parameter\": \"value\"\n}")) - .build() - ) + postRequestedFor(urlPathEqualTo("/post-form-data-request")) + .withHeader("my-header", equalTo("header-value")) + .withHeader("Content-Type", containing("multipart/form-data")) + .withRequestBodyPart( + aMultipart() + .withHeader("Content-Disposition", containing("form-data")) + .withHeader("Content-Disposition", containing("name=\"file\"")) + .withHeader("Content-Disposition", containing("filename=\"multipart-data-file.json\"")) + .withHeader("Content-Type", equalTo("application/octet-stream")) + .withBody(equalTo("{\n \"parameter\": \"value\"\n}")) + .build() + ) ) } + + @Test + suspend fun `rest client timeout works`() { + val restClientTimeout: Int = Duration.ofSeconds(2).toMillis().toInt() + Rest.restAssuredConfigCustomizer = { + httpClient( + HttpClientConfig + .httpClientConfig() + .setParam(CoreConnectionPNames.SO_TIMEOUT, restClientTimeout) + .setParam(CoreConnectionPNames.CONNECTION_TIMEOUT, restClientTimeout) + ) + } + + val thrownError = shouldThrowAny { + request { + baseUri(server.baseUrl()) + get("/get-with-huge-delay") + } + } + log.info("Test thrown error: ", thrownError) + thrownError.shouldHaveCause { + it.shouldHaveCauseInstanceOf() + } + } } \ No newline at end of file