Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding integration for Retrofit #1037

Merged
merged 20 commits into from Oct 24, 2018
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion infographic/arrow-infographic.txt
Expand Up @@ -41,7 +41,7 @@
[<typeclasses>Functor]<-[<typeclasses>Comonad]
[<typeclasses>Functor]<-[<typeclasses>FunctorFilter]
[<typeclasses>Functor]<-[<typeclasses>Traverse]
[<typeclasses>Index]<-[<instances>Index Instances|ListKIndexInstance|MapKIndexInstance|NonEmptyListIndexInstance|SequenceKIndexInstance|ListKFilterIndexInstance|MapKFilterIndexInstance|NonEmptyListFilterIndexInstance|SequenceKFilterIndexInstance]
[<typeclasses>Index]<-[<instances>Index Instances|ListKIndexInstance|MapKIndexInstance|NonEmptyListIndexInstance|SequenceKIndexInstance]
[<typeclasses>Invariant]<-[<instances>Invariant Instances|ConstInvariant|MonoidInvariantInstance]
[<typeclasses>Invariant]<-[<typeclasses>Contravariant]
[<typeclasses>Invariant]<-[<typeclasses>Functor]
Expand Down
@@ -0,0 +1,19 @@
def retrofitVersion = "2.4.0"

dependencies {
compile project(':arrow-effects')
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')
}

apply from: rootProject.file('gradle/gradle-mvn-push.gradle')
apply plugin: 'kotlin-kapt'
@@ -0,0 +1,4 @@
# Maven publishing configuration
POM_NAME=Arrow-Integrations-Retrofit-Adapter
POM_ARTIFACT_ID=arrow-integration-retrofit-adapter
POM_PACKAGING=jar
@@ -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() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that we don't do IO, what about just CallKindAdapterFactory?


companion object {
fun create(): Async2CallAdapterFactory = Async2CallAdapterFactory()
}

override fun get(returnType: Type, annotations: Array<Annotation>, 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<Foo> or $name<out Foo>")
}

val effectType = CallAdapter.Factory.getParameterUpperBound(0, returnType)

return if (rawType == CallK::class.java) {
CallKind2CallAdapter<Type>(effectType)
} else {
null
}
}
}

private fun parseTypeName(type: Type) =
type.typeName
.split(".")
.last()
@@ -0,0 +1,30 @@
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<R>(val call: Call<R>) {
fun <F> async(AC: Async<F>): Kind<F, Response<R>> = call.runInAsyncContext(AC)

fun <F> defer(defer: MonadDefer<F>): Kind<F, Response<R>> = call.runSyncDeferred(defer)

fun <F> catch(monadError: MonadError<F, Throwable>): Kind<F, Response<R>> = call.runSyncCatch(monadError)
}

fun <F, A> Call<A>.runInAsyncContext(AC: Async<F>): Kind<F, Response<A>> =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move these to Ext.kt probably.

AC.async { callback ->
enqueue(ResponseCallback(callback))
}

fun <F, A> Call<A>.runSyncDeferred(defer: MonadDefer<F>): Kind<F, Response<A>> = defer { execute() }

fun <F, A> Call<A>.runSyncCatch(monadError: MonadError<F, Throwable>): Kind<F, Response<A>> =
monadError.run {
catch {
execute()
}
}
@@ -0,0 +1,11 @@
package arrow.integrations.retrofit.adapter

import retrofit2.Call
import retrofit2.CallAdapter
import java.lang.reflect.Type

class CallKind2CallAdapter<R>(private val type: Type) : CallAdapter<R, CallK<R>> {
override fun adapt(call: Call<R>): CallK<R> = CallK(call)

override fun responseType(): Type = type
}
@@ -0,0 +1,18 @@
package arrow.integrations.retrofit.adapter
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth renaming to syntax.kt to be consistent


import arrow.Kind
import arrow.typeclasses.ApplicativeError
import retrofit2.HttpException
import retrofit2.Response

fun <F, R> Response<R>.unwrapBody(apError: ApplicativeError<F, Throwable>): Kind<F, R> =
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))
}
@@ -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<R>(private val proc: (Either<Throwable, Response<R>>) -> Unit) : Callback<R> {
override fun onResponse(call: Call<R>, response: Response<R>) {
proc(response.right())
}

override fun onFailure(call: Call<R>, throwable: Throwable) {
proc(throwable.left())
}
}
@@ -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<Annotation>()

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<List<String>>() {}.type, NO_ANNOTATIONS, retrofit) shouldBe null
}

"Non parametrized type should throw exception" {
val exceptionList = shouldThrow<IllegalArgumentException> {
factory.get(List::class.java, NO_ANNOTATIONS, retrofit)
}
exceptionList.message shouldBe "Return type must be parameterized as List<Foo> or List<out Foo>"

val exceptionIO = shouldThrow<IllegalArgumentException> {
factory.get(IO::class.java, NO_ANNOTATIONS, retrofit)
}
exceptionIO.message shouldBe "Return type must be parameterized as IO<Foo> or IO<out Foo>"
}

"Should work for CallK types" {
factory.get(object : TypeToken<CallK<String>>() {}.type, NO_ANNOTATIONS, retrofit)!!
.responseType() shouldBe String::class.java
}
}
}
@@ -0,0 +1,74 @@
package arrow.integrations.retrofit.adapter

import arrow.core.Either
import arrow.core.applicativeError
import arrow.core.fix
import arrow.effects.IO
import arrow.effects.ObservableK
import arrow.effects.applicativeError
import arrow.effects.async
import arrow.effects.fix
import arrow.effects.monadDefer
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)
@@ -0,0 +1,41 @@
package arrow.integrations.retrofit.adapter

import arrow.effects.IO
import arrow.effects.async
import arrow.effects.fix
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)
@@ -0,0 +1,3 @@
package arrow.integrations.retrofit.adapter.mock

data class ResponseMock(val response: String)
@@ -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<ResponseMock>

@GET("testCallKResponse")
fun testCallKResponse(): CallK<ResponseMock>

@POST("testIOResponsePOST")
fun testIOResponsePost(): CallK<Unit>

}
@@ -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()
4 changes: 4 additions & 0 deletions settings.gradle
Expand Up @@ -60,6 +60,10 @@ include {
_ 'effects-reactor'
}

integrations {
_ 'integrations-retrofit-adapter'
}

folder('recursion-schemes') {
_ 'recursion'
}
Expand Down