Leveraging ZIO and zio-http with Sttp Client to create a client API

754 Views Asked by At

I'm building a library that uses Sttp.client3 as the foundation that can be implemented with Synchronous and Asynchronous environments, I'm using zio-http for my service and sttp-client to interact with other services.

I have the following trait:

trait CoingeckoApiClient extends CoingeckoClient {

  .....
   override def ping: Either[CoingeckoApiError, PingResponse] =
    get[PingResponse](endpoint = "ping", QueryParams())

  def get[T](endpoint: String, queryParams: QueryParams)(
      using Format[T]
  ): Either[CoingeckoApiError, T]
}

And the API

class CoingeckoApi[F[_], P](using val backend: SttpBackend[F, P]) {
  def get(endpoint: String, params: QueryParams): F[Response[Either[String, String]]] = {
      val apiUrl = s"${CoingeckoApi.baseUrl}/$endpoint"
      basicRequest
        .get(
          uri"$apiUrl"
            .withParams(params)
        )
        .send(backend)
  }
}

A synchronous implementation is as follows:

class CoingeckoApiBasic(api: CoingeckoApi[Identity, Any]) extends CoingeckoApiClient {
  def get[T](endpoint: String, queryParams: QueryParams)(using Format[T]): Either[CoingeckoApiError, T] =
    api.get(endpoint, queryParams).body match {
      case Left(json) =>
        Json.parse(json).validate[CoingeckoApiError] match {
          case JsSuccess(value, _) => Left(value)
          case JsError(errors) =>
            Left(CoingeckoApiError.internalApiError(Some("Unknown Api Error")))
        }

      case Right(json) =>
        Json.parse(json).validate[T] match {
          case JsSuccess(value, _) =>
            Right(value)
          case JsError(errors) =>
            Left(
              CoingeckoApiError
                .internalApiError(Some(s"Invalid Response for $endpoint"))
            )
        }
    }
}

So I'm looking to offer an asyncrhonous implementation with ZIO

class CoingeckoApiZIO(api: CoingeckoApi[UIO, Any]) extends CoingeckoApiClient {
  def get[T](endpoint: String, queryParams: QueryParams)(using Format[T]): Either[CoingeckoApiError, T] =
  Runtime.unsafeRun {
    api.get(endpoint, queryParams).map(r => r.body match {
      case Left(json) =>
        Json.parse(json).validate[CoingeckoApiError] match {
          case JsSuccess(value, _) => Left(value)
          case JsError(errors) =>
            Left(CoingeckoApiError.internalApiError(Some("Unknown Api Error")))
        }

      case Right(json) =>
        Json.parse(json).validate[T] match {
          case JsSuccess(value, _) =>
            Right(value)
          case JsError(errors) =>
            Left(
              CoingeckoApiError
                .internalApiError(Some(s"Invalid Response for $endpoint"))
            )
        }
    })
  }
}

Does that mean, I need to provide a Runtime at this level? It seems to me that is a bit harder to offer an API that is flexible enough to be used by ZIO, Future and others, and probably I'm missing something important here.

I probably need to change the signature of class CoingeckoApi[F[_], P] to support an environment?

I'm trying to follow in the steps of sttp that can use multiple backends, but It seems it's a bit difficult to scale or I need to rewrite my API.

1

There are 1 best solutions below

0
On

So the main issue here is that you aren't actually providing an async interface, you are still forcing ZIO to execute synchronously which will block the calling thread. You'll need your return types to be "lifted" into the effect type that your underlying client is using.

And since it seems like you will be passing the backend to the type rather than doing callsite variance that you will need to make the trait vary on the effect type:

trait CoingeckoApiClient[F[_]] extends CoingeckoClient[F] {

  .....
 override def ping: F[Either[CoingeckoApiError, PingResponse]] =
  get[PingResponse](endpoint = "ping", QueryParams())

 def get[T](endpoint: String, queryParams: QueryParams)(
  using Format[T]
 ): Either[CoingeckoApiError, T]
}

Then your ZIO API would look like this

class CoingeckoApiZIO(api: CoingeckoApi[Task, Any]) extends CoingeckoApiClient[Task] {
  def get[T](endpoint: String, queryParams: QueryParams)(using Format[T]): Task[Either[CoingeckoApiError, T]] =
    api.get(endpoint, queryParams).map(r => r.body match {
      case Left(json) =>
        Json.parse(json).validate[CoingeckoApiError] match {
          case JsSuccess(value, _) => Left(value)
          case JsError(errors) =>
            Left(CoingeckoApiError.internalApiError(Some("Unknown Api Error")))
        }

      case Right(json) =>
        Json.parse(json).validate[T] match {
          case JsSuccess(value, _) =>
            Right(value)
          case JsError(errors) =>
            Left(
              CoingeckoApiError
                .internalApiError(Some(s"Invalid Response for $endpoint"))
            )
        }
    })
}

Then your caller would be able to call this method by doing

AsyncHttpZioBackend().flatMap { backend =>
  val client = new CoingeckoApiZIO(new CoingeckoApi(backend))
  client.get("", QueryParams(...))
}

However, I'd still find this a little convoluted as an interface. Sttp comes with a built in MonadError type which you can use generically instead of creating a backend implementation for each effect type, and you can separate the request-building part from the execution part.

So I would suggest instead writing it like this:

object CoingeckoRequests {
  final val baseUrl = "https://api.coingecko.com/api/v3"

  def get(endpoint: String, params: QueryParams): Request[Either[String, String]], Any] = {
      val apiUrl = s"${CoingeckoApi.baseUrl}/$endpoint"
      basicRequest
        .get(uri"$apiUrl?params")
  }
}

class CoingeckoApiClientImpl[F[_]](backend: SttpBackend[F, Any]) extends CoingeckoApiClient[F] {
    def get[T](endpoint: String, queryParams: QueryParams)(using Format[T]): F[Either[CoingeckoApiError, T]] =
      backend.send(
        CoingeckoRequests.get(endpoint, queryParams)
          .response(asJson[T])
         // Handle your error response here
      )
}