diff --git a/modules/integrations/arrow-integrations-retrofit-adapter/build.gradle b/modules/integrations/arrow-integrations-retrofit-adapter/build.gradle new file mode 100644 index 00000000000..84d3a4f25aa --- /dev/null +++ b/modules/integrations/arrow-integrations-retrofit-adapter/build.gradle @@ -0,0 +1,22 @@ +def retrofitVersion = "2.4.0" + +dependencies { + compile project(':arrow-effects') + compile project(':arrow-typeclasses') + compile "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" + compile project(':arrow-annotations') + compile "com.squareup.retrofit2:retrofit:$retrofitVersion" + kapt project(':arrow-annotations-processor') + kaptTest project(':arrow-annotations-processor') + compileOnly project(':arrow-annotations-processor') + testCompileOnly project(':arrow-annotations-processor') + testCompile project(':arrow-test') + testCompile "com.squareup.retrofit2:converter-gson:$retrofitVersion" + testCompile 'com.squareup.okhttp3:mockwebserver:3.11.0' + testCompile project(':arrow-effects-rx2') + testCompile project(':arrow-effects-rx2-instances') + testCompile project(':arrow-effects-instances') +} + +apply from: rootProject.file('gradle/gradle-mvn-push.gradle') +apply plugin: 'kotlin-kapt' \ No newline at end of file diff --git a/modules/integrations/arrow-integrations-retrofit-adapter/gradle.properties b/modules/integrations/arrow-integrations-retrofit-adapter/gradle.properties new file mode 100644 index 00000000000..10160a6a5c7 --- /dev/null +++ b/modules/integrations/arrow-integrations-retrofit-adapter/gradle.properties @@ -0,0 +1,4 @@ +# Maven publishing configuration +POM_NAME=Arrow-Integrations-Retrofit-Adapter +POM_ARTIFACT_ID=arrow-integration-retrofit-adapter +POM_PACKAGING=jar diff --git a/modules/integrations/arrow-integrations-retrofit-adapter/src/main/kotlin/arrow/integrations/retrofit/adapter/CallK.kt b/modules/integrations/arrow-integrations-retrofit-adapter/src/main/kotlin/arrow/integrations/retrofit/adapter/CallK.kt new file mode 100644 index 00000000000..2a62f29bf20 --- /dev/null +++ b/modules/integrations/arrow-integrations-retrofit-adapter/src/main/kotlin/arrow/integrations/retrofit/adapter/CallK.kt @@ -0,0 +1,16 @@ +package arrow.integrations.retrofit.adapter + +import arrow.Kind +import arrow.effects.typeclasses.Async +import arrow.effects.typeclasses.MonadDefer +import arrow.typeclasses.MonadError +import retrofit2.Call +import retrofit2.Response + +data class CallK(val call: Call) { + fun async(AC: Async): Kind> = call.runAsync(AC) + + fun defer(defer: MonadDefer): Kind> = call.runSyncDeferred(defer) + + fun catch(monadError: MonadError): Kind> = call.runSyncCatch(monadError) +} diff --git a/modules/integrations/arrow-integrations-retrofit-adapter/src/main/kotlin/arrow/integrations/retrofit/adapter/CallKind2CallAdapter.kt b/modules/integrations/arrow-integrations-retrofit-adapter/src/main/kotlin/arrow/integrations/retrofit/adapter/CallKind2CallAdapter.kt new file mode 100644 index 00000000000..a13166718cf --- /dev/null +++ b/modules/integrations/arrow-integrations-retrofit-adapter/src/main/kotlin/arrow/integrations/retrofit/adapter/CallKind2CallAdapter.kt @@ -0,0 +1,11 @@ +package arrow.integrations.retrofit.adapter + +import retrofit2.Call +import retrofit2.CallAdapter +import java.lang.reflect.Type + +class CallKind2CallAdapter(private val type: Type) : CallAdapter> { + override fun adapt(call: Call): CallK = CallK(call) + + override fun responseType(): Type = type +} diff --git a/modules/integrations/arrow-integrations-retrofit-adapter/src/main/kotlin/arrow/integrations/retrofit/adapter/CallKindAdapterFactory.kt b/modules/integrations/arrow-integrations-retrofit-adapter/src/main/kotlin/arrow/integrations/retrofit/adapter/CallKindAdapterFactory.kt new file mode 100644 index 00000000000..42a32187f86 --- /dev/null +++ b/modules/integrations/arrow-integrations-retrofit-adapter/src/main/kotlin/arrow/integrations/retrofit/adapter/CallKindAdapterFactory.kt @@ -0,0 +1,36 @@ +package arrow.integrations.retrofit.adapter + +import retrofit2.CallAdapter +import retrofit2.Retrofit +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + +class Async2CallAdapterFactory : CallAdapter.Factory() { + + companion object { + fun create(): Async2CallAdapterFactory = Async2CallAdapterFactory() + } + + override fun get(returnType: Type, annotations: Array, retrofit: Retrofit): CallAdapter<*, *>? { + val rawType = CallAdapter.Factory.getRawType(returnType) + + if (returnType !is ParameterizedType) { + val name = parseTypeName(returnType) + throw IllegalArgumentException("Return type must be parameterized as " + + "$name or $name") + } + + val effectType = CallAdapter.Factory.getParameterUpperBound(0, returnType) + + return if (rawType == CallK::class.java) { + CallKind2CallAdapter(effectType) + } else { + null + } + } +} + +private fun parseTypeName(type: Type) = + type.typeName + .split(".") + .last() diff --git a/modules/integrations/arrow-integrations-retrofit-adapter/src/main/kotlin/arrow/integrations/retrofit/adapter/ResponseCallback.kt b/modules/integrations/arrow-integrations-retrofit-adapter/src/main/kotlin/arrow/integrations/retrofit/adapter/ResponseCallback.kt new file mode 100644 index 00000000000..c4a4ccf4672 --- /dev/null +++ b/modules/integrations/arrow-integrations-retrofit-adapter/src/main/kotlin/arrow/integrations/retrofit/adapter/ResponseCallback.kt @@ -0,0 +1,18 @@ +package arrow.integrations.retrofit.adapter + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +class ResponseCallback(private val proc: (Either>) -> Unit) : Callback { + override fun onResponse(call: Call, response: Response) { + proc(response.right()) + } + + override fun onFailure(call: Call, throwable: Throwable) { + proc(throwable.left()) + } +} diff --git a/modules/integrations/arrow-integrations-retrofit-adapter/src/main/kotlin/arrow/integrations/retrofit/adapter/syntax.kt b/modules/integrations/arrow-integrations-retrofit-adapter/src/main/kotlin/arrow/integrations/retrofit/adapter/syntax.kt new file mode 100644 index 00000000000..9d9e94aa57b --- /dev/null +++ b/modules/integrations/arrow-integrations-retrofit-adapter/src/main/kotlin/arrow/integrations/retrofit/adapter/syntax.kt @@ -0,0 +1,36 @@ +package arrow.integrations.retrofit.adapter + +import arrow.Kind +import arrow.effects.typeclasses.Async +import arrow.effects.typeclasses.MonadDefer +import arrow.typeclasses.ApplicativeError +import arrow.typeclasses.MonadError +import retrofit2.Call +import retrofit2.HttpException +import retrofit2.Response + +fun Response.unwrapBody(apError: ApplicativeError): Kind = + if (this.isSuccessful) { + val body = this.body() + if (body != null) { + apError.just(body) + } else { + apError.raiseError(IllegalStateException("The request returned a null body")) + } + } else { + apError.raiseError(HttpException(this)) + } + +fun Call.runAsync(AC: Async): Kind> = + AC.async { callback -> + enqueue(ResponseCallback(callback)) + } + +fun Call.runSyncDeferred(defer: MonadDefer): Kind> = defer { execute() } + +fun Call.runSyncCatch(monadError: MonadError): Kind> = + monadError.run { + catch { + execute() + } + } diff --git a/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/Async2CallAdapterFactoryTest.kt b/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/Async2CallAdapterFactoryTest.kt new file mode 100644 index 00000000000..3fb80bd5fc1 --- /dev/null +++ b/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/Async2CallAdapterFactoryTest.kt @@ -0,0 +1,42 @@ +package arrow.integrations.retrofit.adapter + +import arrow.effects.IO +import arrow.integrations.retrofit.adapter.retrofit.retrofit +import arrow.test.UnitSpec +import com.google.common.reflect.TypeToken +import io.kotlintest.KTestJUnitRunner +import io.kotlintest.matchers.shouldBe +import io.kotlintest.matchers.shouldThrow +import okhttp3.HttpUrl +import org.junit.runner.RunWith + +private val NO_ANNOTATIONS = emptyArray() + +private val retrofit = retrofit(HttpUrl.parse("http://localhost:1")!!) +private val factory = Async2CallAdapterFactory.create() + +@RunWith(KTestJUnitRunner::class) +class Async2CallAdapterFactoryTest : UnitSpec() { + init { + "Non CallK Class should return null" { + factory.get(object : TypeToken>() {}.type, NO_ANNOTATIONS, retrofit) shouldBe null + } + + "Non parametrized type should throw exception" { + val exceptionList = shouldThrow { + factory.get(List::class.java, NO_ANNOTATIONS, retrofit) + } + exceptionList.message shouldBe "Return type must be parameterized as List or List" + + val exceptionIO = shouldThrow { + factory.get(IO::class.java, NO_ANNOTATIONS, retrofit) + } + exceptionIO.message shouldBe "Return type must be parameterized as IO or IO" + } + + "Should work for CallK types" { + factory.get(object : TypeToken>() {}.type, NO_ANNOTATIONS, retrofit)!! + .responseType() shouldBe String::class.java + } + } +} diff --git a/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/ProcCallBackTest.kt b/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/ProcCallBackTest.kt new file mode 100644 index 00000000000..9f1faa058f9 --- /dev/null +++ b/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/ProcCallBackTest.kt @@ -0,0 +1,74 @@ +package arrow.integrations.retrofit.adapter + +import arrow.core.Either +import arrow.core.fix +import arrow.effects.IO +import arrow.effects.ObservableK +import arrow.effects.fix +import arrow.effects.instances.io.async.async +import arrow.effects.observablek.applicativeError.applicativeError +import arrow.effects.observablek.monadDefer.monadDefer +import arrow.instances.either.applicativeError.applicativeError +import arrow.integrations.retrofit.adapter.mock.ResponseMock +import arrow.integrations.retrofit.adapter.retrofit.ApiClientTest +import arrow.integrations.retrofit.adapter.retrofit.retrofit +import arrow.test.UnitSpec +import io.kotlintest.KTestJUnitRunner +import io.kotlintest.matchers.fail +import okhttp3.HttpUrl +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertEquals +import org.junit.runner.RunWith + +@RunWith(KTestJUnitRunner::class) +class ProcCallBackTest : UnitSpec() { + private val server = MockWebServer().apply { + enqueue(MockResponse().setBody("{\"response\": \"hello, world!\"}").setResponseCode(200)) + start() + } + + private val baseUrl: HttpUrl = server.url("/") + + init { + "should be able to parse answer with ForIO" { + createApiClientTest(baseUrl) + .testCallK() + .async(IO.async()) + .fix() + .unsafeRunSync() + .unwrapBody(Either.applicativeError()) + .fix() + .fold({ throwable -> + fail("The requested terminated with an exception. Message: ${throwable.message}") + }, { responseMock -> + assertEquals(ResponseMock("hello, world!"), responseMock) + }) + } + + "should be able to parse answer with ForObservableK" { + with(ObservableK) { + createApiClientTest(baseUrl) + .testCallK() + .defer(monadDefer()) + .fix() + .flatMap { response -> + response.unwrapBody(applicativeError()).fix() + } + .observable + .test() + .assertValue(ResponseMock("hello, world!")) + } + } + + "should be able to run a POST with UNIT as response" { + createApiClientTest(baseUrl) + .testIOResponsePost() + .async(IO.async()) + .fix() + .unsafeRunSync() + } + } +} + +private fun createApiClientTest(baseUrl: HttpUrl) = retrofit(baseUrl).create(ApiClientTest::class.java) diff --git a/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/ResponseCallbackTest.kt b/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/ResponseCallbackTest.kt new file mode 100644 index 00000000000..24c4a4b8e89 --- /dev/null +++ b/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/ResponseCallbackTest.kt @@ -0,0 +1,41 @@ +package arrow.integrations.retrofit.adapter + +import arrow.effects.IO +import arrow.effects.fix +import arrow.effects.instances.io.async.async +import arrow.integrations.retrofit.adapter.retrofit.ApiClientTest +import arrow.integrations.retrofit.adapter.retrofit.retrofit +import arrow.test.UnitSpec +import io.kotlintest.KTestJUnitRunner +import io.kotlintest.matchers.fail +import okhttp3.HttpUrl +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.runner.RunWith + +@RunWith(KTestJUnitRunner::class) +class ResponseCallbackTest : UnitSpec() { + private val server = MockWebServer().apply { + enqueue(MockResponse().setBody("{response: \"hello, world!\"}").setResponseCode(200)) + start() + } + + private val baseUrl: HttpUrl = server.url("/") + + init { + "bad deserialization should return Either.Left" { + createApiClientTest(baseUrl) + .testCallK() + .async(IO.async()) + .fix() + .attempt() + .unsafeRunSync() + .fold({ + }, { + fail("The request should have not terminated correctly") + }) + } + } +} + +private fun createApiClientTest(baseUrl: HttpUrl) = retrofit(baseUrl).create(ApiClientTest::class.java) diff --git a/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/mock/ResponseMock.kt b/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/mock/ResponseMock.kt new file mode 100644 index 00000000000..9bef63d208b --- /dev/null +++ b/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/mock/ResponseMock.kt @@ -0,0 +1,3 @@ +package arrow.integrations.retrofit.adapter.mock + +data class ResponseMock(val response: String) diff --git a/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/retrofit/ApiClientTest.kt b/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/retrofit/ApiClientTest.kt new file mode 100644 index 00000000000..2897e792f11 --- /dev/null +++ b/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/retrofit/ApiClientTest.kt @@ -0,0 +1,19 @@ +package arrow.integrations.retrofit.adapter.retrofit + +import arrow.integrations.retrofit.adapter.CallK +import arrow.integrations.retrofit.adapter.mock.ResponseMock +import retrofit2.http.GET +import retrofit2.http.POST + +interface ApiClientTest { + + @GET("test") + fun testCallK(): CallK + + @GET("testCallKResponse") + fun testCallKResponse(): CallK + + @POST("testIOResponsePOST") + fun testIOResponsePost(): CallK + +} diff --git a/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/retrofit/Retrofit.kt b/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/retrofit/Retrofit.kt new file mode 100644 index 00000000000..e079cfc80a6 --- /dev/null +++ b/modules/integrations/arrow-integrations-retrofit-adapter/src/test/kotlin/arrow/integrations/retrofit/adapter/retrofit/Retrofit.kt @@ -0,0 +1,20 @@ +package arrow.integrations.retrofit.adapter.retrofit + +import arrow.integrations.retrofit.adapter.Async2CallAdapterFactory +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +private fun provideOkHttpClient(): OkHttpClient = + OkHttpClient.Builder().build() + +private fun configRetrofit(retrofitBuilder: Retrofit.Builder) = + retrofitBuilder + .addCallAdapterFactory(Async2CallAdapterFactory.create()) + .addConverterFactory(GsonConverterFactory.create()) + .client(provideOkHttpClient()) + +private fun getRetrofitBuilderDefaults(baseUrl: HttpUrl) = Retrofit.Builder().baseUrl(baseUrl) + +fun retrofit(baseUrl: HttpUrl): Retrofit = configRetrofit(getRetrofitBuilderDefaults(baseUrl)).build() diff --git a/settings.gradle b/settings.gradle index 234a9fd6a83..44609c66d7a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -65,6 +65,10 @@ include { _ 'effects-reactor-instances' } + integrations { + _ 'integrations-retrofit-adapter' + } + folder('recursion-schemes') { _ 'recursion' _ 'instances-recursion'