In this post I want to give a simple example of how to use ZIO modules together with STTP. We will see how to wire different components together and in particular how to unit test the http layer. I assume basic ZIO concepts such as environment, layer, error and ZIO aliases (RIO, UIO etc) are already known to the reader, who can refer to ZIO documentation for further details. The code used along this article is available here.

Our client is working on a marketing campaign. Colleagues from marketing department prepared a list of CustomerId, and we are tasked to send each user an email with a promotion for the hot product of our catalogue. The user information is stored in a CRM application that exposes a REST api.

To implement this, for each CustomerId we need to:

  • lookup for the customer in the CRM
  • if customer exists and permission has been granted, send the email
  • otherwise just write a log message

The CRM application

We have a simple implementation of the CRM api we want to use, i.e. an endpoint to fetch a user given a userId. You can run a trivial implementation of such an application with sbt runMain io.tuliplogic.CRMApp. Let’s test the endpoint with httpPie:

http http://localhost:8080/users/123123
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 73
Content-Type: text/plain
Date: Sat, 11 Jul 2020 14:25:19 GMT

{
    "email": "weierstrass@maths.com",
    "promotionOptIn": true,
    "userId": "123123"
}

If a user is not found in CRM, the service returns a 404 with emtpy body instead.

The campaign

Our campaign program is given a val customerIds: List[CustomerId] and a simple function to send email def sendEmail(email: Email): URIO[Console, Unit] = ???. We will focus on the service responsible for retrieving customer information. Writing a module that exposes such a functionality is pretty straightforward:

type CustomerDataService = Has[CustomerDataService.Service]

object CustomerBaseService {
  trait Service {
    def getCustomer(customerId: CustomerId): IO[Error, Option[Customer]]
  }

  def getCustomer(customerId: CustomerId): ZIO[CustomerBaseService, Error, Option[Customer]] =
    ZIO.accessM(_.get.getCustomer(customerId))
}

Based solely on the shape of our interface (we didn’t bother yet thinking about the concrete implementation), we can write the campaign program as

val program: ZIO[Console with CustomerDataService, CustomerDataService.Error, Unit] =
  ZIO.foreach(customerIds) { customerId =>
    CustomerDataService.getCustomer(customerId).flatMap {
      _.fold(console.putStrLn(s"Customer $customerId not found"))( cust =>
        if (cust.promotionOptIn) sendEmail(cust.email)
        else console.putStrLn(s"Not sending email to $customerId")
      )
    }
  }.unit

The type of program tells us it requires Console with CustomerDataService, Console being provided by ZEnv and CustomerDataService must be provided by us.

A live implementation of CustomerDataService layer must perform an http call to our CRM (configurable) endpoint, therefore it depends on a horizontal composition of a Config layer that provides the base url and port, together with SttpClient layer provided by sttp-zio. In order to parse the response properly, we use sttp-circe.

val live: URLayer[SttpClient with Config, CustomerDataService] =
  ZLayer.fromServiceM[io.tuliplogic.Config, SttpClient, Nothing, Service] { config =>
    ZIO.fromFunction[SttpClient, Service] { sttpClient =>
      new Service {
        override def getCustomer(customerId: CustomerId): IO[Error, Option[Customer]] = {
          val request = basicRequest
            .get(uri"${config.baseUrl}:${config.port}/customers/${customerId.value}")
            .response(asJson[Customer])
          (
            for {
              resp <- SttpClient.send(request)
                .mapError(t => Error("Connection Error", t))
              res <- (resp.code, resp.body) match {
                case (StatusCode.NotFound, _) => ZIO.none
                case (_, Right(customer))         => ZIO.some(customer)
                case (_, Left(e))             => ZIO.fail(Error("API error", e))
              }
            } yield res
          ).provide(sttpClient)
        }
      }
    }
  }

sttp allows us to describe how we send a request and we parse the response. This description is then interpreted in the SttpClient.send that returns a ZIO effect. Here we are transforming the response with 404 status code into a None, the 200 response into a Some[Customer], otherwise we fail the effect using the ZIO error channel.

In the main program we need to satisfy the requirements of program using the live layer, after we compose the requirements horizontally with ++ and vertically with >>> (see here for further details).

program.provideSomeLayer[Console](
  (ZLayer.succeed(Config("http://localhost", 8080)) ++ AsyncHttpClientZioBackend.layer()) >>> CustomerDataService.live
)

Unit testing the live layer

Given how we composed the different parts of our program, we are pretty confident it will work, just it would be nice to make sure the http part has been implemented correctly, in CustomerDataService.live. In particular we want to make sure we are processing correctly the faulty situations.

With sttp and ZIO mocking http endpoints is extremely simple. I used ScalaTest but a similar approach can be followed with pretty much all the testing libraries out there. The http dependency in live is provided by SttpClient layer, so to mock a situation where the CRM endpoint returns a 404 we build an sttp layer as

val notFoundSttp: ULayer[SttpClient] = ZLayer.succeed(
  AsyncHttpClientZioBackend.stub
    .whenRequestMatches(req => req.uri.toString == "http://localhost:8080/customers/123122")
    .thenRespondNotFound()
)

and in the test we inject this layer as SttpClient and run the method under test

"return None when customer not found" in new TestScope {
  val res = unsafeRun(
    CustomerDataService.getCustomer(CustomerId("123122")).provideLayer(
      (configLayer ++ notFoundSttp  ) >>> CustomerDataService.live
    ).either
  )

  val expected = Right(None)
  res shouldEqual expected
}

Conclusion

Through this simple example, we saw how we can use sttp together with ZIO using the ZLayer approach, and we have shown how to unit test the http client without having to spin any server, by just providing a properly setup SttpClient.

Referential Transparency

One foundation of Functional Programming is Referential Transparency (RT). Purely functional languages (like Haskell), and purely functio...… Continue reading

Getting started with Apache Camel

Published on July 22, 2015

Scala.js for dynamic systems simulation

Published on April 12, 2015