From 0730669cc80e6767c7eed6a35f453444e1b4ba35 Mon Sep 17 00:00:00 2001 From: Alphonse Bendt Date: Mon, 6 May 2024 11:10:51 +0200 Subject: [PATCH] fix non suspended case --- build.gradle.kts | 3 + .../arrow/ArrowEitherConverterFactory.kt | 22 +- src/main/kotlin/demo/ExampleApi.kt | 3 + src/test/kotlin/demo/ExampleApiSpec.kt | 352 ++++++++++-------- 4 files changed, 224 insertions(+), 156 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index c0af8b4..9bdfc56 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,6 +33,9 @@ dependencies { testImplementation("io.ktor:ktor-serialization-kotlinx-json") testImplementation("io.ktor:ktor-client-logging") + + testImplementation("io.kotest.extensions:kotest-extensions-wiremock:3.0.1") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-debug:1.8.0") } spotless { diff --git a/src/main/kotlin/arrow/ArrowEitherConverterFactory.kt b/src/main/kotlin/arrow/ArrowEitherConverterFactory.kt index a780d1b..7f9003a 100644 --- a/src/main/kotlin/arrow/ArrowEitherConverterFactory.kt +++ b/src/main/kotlin/arrow/ArrowEitherConverterFactory.kt @@ -16,10 +16,10 @@ class ArrowEitherConverterFactory : Converter.Factory { override fun suspendResponseConverter( typeData: TypeData, ktorfit: Ktorfit, - ): Converter.SuspendResponseConverter>? { + ): Converter.SuspendResponseConverter>? { if (typeData.typeInfo.type == Either::class) { - return object : Converter.SuspendResponseConverter> { - override suspend fun convert(result: KtorfitResult): Either = + return object : Converter.SuspendResponseConverter> { + override suspend fun convert(result: KtorfitResult): Either = result.fold(::Left) { readBody(it, typeData) } @@ -36,9 +36,9 @@ class ArrowEitherConverterFactory : Converter.Factory { private suspend fun readBody( httpResponse: HttpResponse, typeData: TypeData, - ): Either = + ): Either = try { - httpResponse.body>(typeData.typeArgs[1].typeInfo).right() + httpResponse.body(typeData.typeArgs[1].typeInfo).right() } catch (ex: Exception) { ex.left() } @@ -46,12 +46,16 @@ class ArrowEitherConverterFactory : Converter.Factory { override fun responseConverter( typeData: TypeData, ktorfit: Ktorfit, - ): Converter.ResponseConverter>? { + ): Converter.ResponseConverter>? { if (typeData.typeInfo.type == Either::class) { - return object : Converter.ResponseConverter> { - override fun convert(getResponse: suspend () -> HttpResponse): Either = + return object : Converter.ResponseConverter> { + override fun convert(getResponse: suspend () -> HttpResponse): Either = runBlocking { - readBody(getResponse(), typeData) + try { + readBody(getResponse(), typeData) + } catch (ex: Exception) { + ex.left() + } } } } diff --git a/src/main/kotlin/demo/ExampleApi.kt b/src/main/kotlin/demo/ExampleApi.kt index 0debabf..35123c3 100644 --- a/src/main/kotlin/demo/ExampleApi.kt +++ b/src/main/kotlin/demo/ExampleApi.kt @@ -38,3 +38,6 @@ interface ExampleApi { @Serializable data class PersonResponse(val name: String, val birth_year: String, val films: List) + +@Serializable +data class PersonResponse2(val missingField: String) diff --git a/src/test/kotlin/demo/ExampleApiSpec.kt b/src/test/kotlin/demo/ExampleApiSpec.kt index 1f93426..60869b4 100644 --- a/src/test/kotlin/demo/ExampleApiSpec.kt +++ b/src/test/kotlin/demo/ExampleApiSpec.kt @@ -1,15 +1,21 @@ package demo import arrow.ArrowEitherConverterFactory +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.core.WireMockConfiguration import de.jensklingenberg.ktorfit.Ktorfit import io.kotest.assertions.arrow.core.shouldBeLeft import io.kotest.assertions.arrow.core.shouldBeRight -import io.kotest.core.spec.style.StringSpec +import io.kotest.core.spec.style.FreeSpec +import io.kotest.extensions.wiremock.ListenerMode +import io.kotest.extensions.wiremock.WireMockListener import io.kotest.matchers.collections.shouldNotBeEmpty import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain import io.kotest.matchers.types.shouldBeInstanceOf import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig import io.ktor.client.plugins.ClientRequestException import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.logging.LogLevel @@ -19,194 +25,246 @@ import io.ktor.http.HttpStatusCode import io.ktor.serialization.JsonConvertException import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json +import org.intellij.lang.annotations.Language -class ExampleApiSpec : StringSpec({ +class ExampleApiSpec : FreeSpec({ - "suspend GET -> String" { - val exampleApi = buildApiClient() + val wireMock = WireMockServer(WireMockConfiguration.options().dynamicPort()) + register(WireMockListener(wireMock, ListenerMode.PER_TEST)) - exampleApi.getPersonAsString("1") shouldContain "Luke Skywalker" + fun buildApiClient(customizer: (Ktorfit.Builder) -> Unit = {}): ExampleApi { + val ktorfit = + Ktorfit.Builder().baseUrl(wireMock.baseUrl() + "/") + .also(customizer) + .build() + + return ktorfit.create() } - "suspend GET -> KotlinX Serializable" { - val ktorClient = - HttpClient { - install(ContentNegotiation) { - json( - Json { - isLenient = true - ignoreUnknownKeys = true - }, - ) - } - } + @Language("json") + fun aPersonJson(): String = """{"name": "Luke Skywalker", "birth_year": "19BBY", "films": ["film1"]}""" - val exampleApi = - buildApiClient { - it.httpClient(ktorClient) - } + fun givenPersonResponse( + status: Int, + @Language("json") body: String = aPersonJson(), + ) { + wireMock.stubFor( + WireMock.get(WireMock.anyUrl()).willReturn( + WireMock.aResponse() + .withHeader("Content-Type", "application/json") + .withStatus(status) + .withBody(body), + ), + ) + } - exampleApi.getPersonAsSerializable("1").let { - it.name shouldBe "Luke Skywalker" - it.birth_year shouldBe "19BBY" - it.films.shouldNotBeEmpty() + "suspend GET" - { + "return as String" { + givenPersonResponse(200) + + val exampleApi = buildApiClient() + + exampleApi.getPersonAsString("1") shouldContain "Luke Skywalker" } - } - "suspend GET -> Either" { + "return as KotlinX Serializable" { + val ktorClient = + HttpClient { + installJson() + } - val ktorClient = - HttpClient { - install(ContentNegotiation) { - json( - Json { - isLenient = true - ignoreUnknownKeys = true - }, - ) + val exampleApi = + buildApiClient { + it.httpClient(ktorClient) } - } - val exampleApi = - buildApiClient { - it.httpClient(ktorClient) - .converterFactories(ArrowEitherConverterFactory()) - } + givenPersonResponse(200) - exampleApi.getPersonAsEither("1").shouldBeRight() - .let { + exampleApi.getPersonAsSerializable("1").let { it.name shouldBe "Luke Skywalker" it.birth_year shouldBe "19BBY" it.films.shouldNotBeEmpty() } - } + } - "GET -> Either" { - val ktorClient = - HttpClient { - install(ContentNegotiation) { - json( - Json { - isLenient = true - ignoreUnknownKeys = true - }, - ) + "return as Either" { + val ktorClient = + HttpClient { + installJson() } - } - val exampleApi = - buildApiClient { - it.httpClient(ktorClient) - .converterFactories(ArrowEitherConverterFactory()) - } + val exampleApi = + buildApiClient { + it.httpClient(ktorClient) + .converterFactories(ArrowEitherConverterFactory()) + } - exampleApi.nonSuspendedGetPersonAsEither("1").shouldBeRight() - .let { - it.name shouldBe "Luke Skywalker" - it.birth_year shouldBe "19BBY" - it.films.shouldNotBeEmpty() - } - } + givenPersonResponse(200) - "suspend GET -> Either" { - val ktorClient = - HttpClient { - install(ContentNegotiation) { - json( - Json { - isLenient = true - ignoreUnknownKeys = true - }, - ) + exampleApi.getPersonAsEither("1").shouldBeRight() + .let { + it.name shouldBe "Luke Skywalker" + it.birth_year shouldBe "19BBY" + it.films.shouldNotBeEmpty() } - } + } - val exampleApi = - buildApiClient { - it.httpClient(ktorClient) - .converterFactories(ArrowEitherConverterFactory()) - } + "return as Either" { + val ktorClient = + HttpClient { + installJson() + } - exampleApi.getPersonAsHttpResponse("1") - .shouldBeRight().let { - it.status shouldBe HttpStatusCode.OK - } - } + val exampleApi = + buildApiClient { + it.httpClient(ktorClient) + .converterFactories(ArrowEitherConverterFactory()) + } + + givenPersonResponse(200) + + exampleApi.getPersonAsHttpResponse("1") + .shouldBeRight().let { + it.status shouldBe HttpStatusCode.OK + } + } - "Either.Left contains HTTP error" { - val ktorClient = - HttpClient { + "can return HTTP errors on Left" { + val ktorClient = + HttpClient { + expectSuccess = true - expectSuccess = true + installJson() - install(ContentNegotiation) { - json( - Json { - isLenient = true - ignoreUnknownKeys = true - }, - ) + installLogging() } - install(Logging) { - level = LogLevel.ALL + val exampleApi = + buildApiClient { + it.httpClient(ktorClient) + .converterFactories(ArrowEitherConverterFactory()) + } + + givenPersonResponse(404) - logger = - object : Logger { - override fun log(message: String) { - println(message) - } - } + exampleApi.getPersonAsEither("1") + .shouldBeLeft() + .shouldBeInstanceOf() + .let { + it.response.status shouldBe HttpStatusCode.NotFound } - } + } - val exampleApi = - buildApiClient { - it.httpClient(ktorClient) - .converterFactories(ArrowEitherConverterFactory()) - } + "can return Serialization errors on Left" { + val ktorClient = + HttpClient { + installJson() + } - exampleApi.getPersonAsEither("doesNotExist") - .shouldBeLeft() - .let { - it.shouldBeInstanceOf() + val exampleApi = + buildApiClient { + it.httpClient(ktorClient) + .converterFactories(ArrowEitherConverterFactory()) + } - it.response.status shouldBe HttpStatusCode.NotFound - } + givenPersonResponse(404, "{}") + + exampleApi.getPersonAsEither("doesNotExist") + .shouldBeLeft() + .let { + it.shouldBeInstanceOf() + } + } } - "Either.Left contains Serialization Error" { - val ktorClient = - HttpClient { - install(ContentNegotiation) { - json( - Json { - isLenient = true - ignoreUnknownKeys = true - }, - ) + "non suspended GET" - { + "return as Either" { + val ktorClient = + HttpClient { + installJson() } - } - val exampleApi = - buildApiClient { - it.httpClient(ktorClient) - .converterFactories(ArrowEitherConverterFactory()) - } + val exampleApi = + buildApiClient { + it.httpClient(ktorClient) + .converterFactories(ArrowEitherConverterFactory()) + } - exampleApi.getPersonAsEither("doesNotExist") - .shouldBeLeft() - .let { - it.shouldBeInstanceOf() - } + givenPersonResponse(200) + + exampleApi.nonSuspendedGetPersonAsEither("1").shouldBeRight() + .let { + it.name shouldBe "Luke Skywalker" + it.birth_year shouldBe "19BBY" + it.films.shouldNotBeEmpty() + } + } + + "can return HTTP errors on Left" { + val ktorClient = + HttpClient { + expectSuccess = true + installJson() + } + + val exampleApi = + buildApiClient { + it.httpClient(ktorClient) + .converterFactories(ArrowEitherConverterFactory()) + } + + givenPersonResponse(404) + + exampleApi.nonSuspendedGetPersonAsEither("1") + .shouldBeLeft() + .shouldBeInstanceOf() + } + + "can return Serialization errors on Left" { + val ktorClient = + HttpClient { + expectSuccess = true + + installJson() + + // installLogging() + } + + val exampleApi = + buildApiClient { + it.httpClient(ktorClient) + .converterFactories(ArrowEitherConverterFactory()) + } + + givenPersonResponse(200, "{}") + + exampleApi.nonSuspendedGetPersonAsEither("1") + .shouldBeLeft() + .shouldBeInstanceOf() + } } }) -private fun buildApiClient(customizer: (Ktorfit.Builder) -> Unit = {}): ExampleApi { - val ktorfit = - Ktorfit.Builder().baseUrl("https://swapi.dev/api/") - .also(customizer) - .build() +private fun HttpClientConfig<*>.installLogging() { + install(Logging) { + level = LogLevel.ALL - return ktorfit.create() + logger = + object : Logger { + override fun log(message: String) { + println(message) + } + } + } +} + +private fun HttpClientConfig<*>.installJson() { + install(ContentNegotiation) { + json( + Json { + isLenient = true + ignoreUnknownKeys = true + }, + ) + } }