softwaremill / sttp

The Scala HTTP client you always wanted!
https://sttp.softwaremill.com
Apache License 2.0
1.43k stars 299 forks source link

converting Future backend to cats' IO backend, referential transparency and `defer` #907

Open bwiercinski opened 3 years ago

bwiercinski commented 3 years ago

Hello In my application I'm using AkkaHttpBackend which is SttpBackend[Future,...]. In the logic of the application I'm using cats.effect.IO. The sttp3 provides new syntax for my use case, which is mapK, so I was happy to use that while I was migrating from sttp2 to sttp3. After mapping I have SttpBackend[IO, Any], so my expectation was: if I invoke .send then I have got IO[Response[...]] and since IO is referential transparent I can use the benefits of that in my application.

The bellow example shows that it is not true:

import cats.effect._
import cats.~>
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AsyncWordSpec
import sttp.capabilities.Effect
import sttp.client3._
import sttp.client3.asynchttpclient.future.AsyncHttpClientFutureBackend
import sttp.client3.impl.cats.implicits._
import sttp.model.Uri
import scala.concurrent._

class DeferringFutureBackendSpec extends AsyncWordSpec with Matchers {
  implicit val contextShift: ContextShift[IO] = IO.contextShift(ExecutionContext.global)

  def createBackend(): SttpBackend[IO, Any] = {
    val futureToIO = λ[Future ~> IO](future => Async.fromFuture(IO(future)))
    val ioToFuture = λ[IO ~> Future](_.unsafeToFuture())
    val mockBackend: SttpBackend[Future, Any] = AsyncHttpClientFutureBackend.stub().whenAnyRequest.thenRespondCyclic("1", "2")
    val ioBackend: SttpBackend[IO, Any] = mockBackend.mapK(futureToIO, ioToFuture)

    ioBackend
    // new DeferringSttpBackend(ioBackend) // this will work
  }

  class DeferringSttpBackend(delegate: SttpBackend[IO, Any]) extends DelegateSttpBackend(delegate) {
    override def send[T, R >: Effect[IO]](request: Request[T, R]): IO[Response[T]] = Sync[IO].defer(delegate.send(request))
    override def close(): IO[Unit] = Sync[IO].defer(delegate.close())
  }

  "defer action for each request" in {
    val backend: SttpBackend[IO, Any] = createBackend()
    val sendAction: IO[Response[String]] = backend.send(basicRequest.response(asStringAlways).get(Uri("localhost")))
    for {
      a <- sendAction
      b <- sendAction
    } yield {
      a.body shouldBe "1"
      b.body shouldBe "2" // TestFailedException: expected "2" actual "1"
    }
  }.unsafeToFuture()
}

after wrapping ioBackend using DeferringSttpBackend the test is passing.

after thinking about it for a while I understand why it is happening, because futureToIO is impure.

my questions are:

adamw commented 3 years ago

Nice catch :) I think the problem here is that you can't use FunctionK to transform a Future into an IO, as it doesn't use by-name parameters. And that's equally true for sttp client and any other usage.

At least documenting how to convert a future-based backend into any cast-effect-supported effect is certainly a good idea. I'd probably just implement the backend, though, as a backend wrapper, without using .mapK (but copying & improving its implementation - the MappedKSttpBackend).