when is FP a good idea? an easy, functional web client using dotty, zio, sttp, circe, aws
I often need a REST’ish web client to connect to a service and push/pull processing results from my ML algorithm. Usually, there are java facades available for a service, but they are quite laborious to use and lack scala idioms. While there may be scala facades, they may not use the effect and HTTP client library that I want to use in the rest of my application. There are other scala, web client libraries like http4s (a large list is here) but they do not cover the specific domain of interest. Fortunately, I found it easy in one project to create a simple client library using dotty/scala3 to combine a few standalone libraries that met my needs.
This blog post covers my design. At the end, I ask whether the functional features in my web client application layer are worth using vs. using a traditional approach. The short answer is that it depends (yes, I am a consultant). What’s also important and became obvious is that dotty enables both approaches so I can choose which one works for me. I was fortunate to select libraries to build on that were already functional. Dotty made the remaining functional design easy enough that the option of rolling a custom solution to integrate the libraries was viable.
Traditional Client Approach
While I am not an everything-must-be-functionally-pure type of person, functional styles are useful, and they help me develop algorithms easier. In REST interfaces, a functional style employs an immutable “request” data-only object for the “command” vocabulary. Operations, such as sending and processing return values, are created as values and execute as an effect. Typeclasses separate out “commands” and commands are written independently from each other. Inheritance is avoided and along with it, variance or type specification scenarios that often lead to runtime type casting and decreases our trust in our code.
The more common, non-functional approach is to create a “Client” class that knows not only how to connect to an endpoint but also includes the “runner/effect” concept. Since dotty can do OOP, this approach can be implemented in dotty/scala3 as well:
class Client(endpoint: Endpoint, ...more config params e.g. retry...) {
// lots of initialization processing around
// the REST endpoint
// ...
// methods, one for each "command"
def index(name: String): ErrorOrResult[Throwable, R] = { ... }
// Async verison is often right next to it. Note that Task is a value
// and must still be run. Client + async methods are more functional.
def asyncIndex(name: String): Task[ErrorOrResult[Throwable, R]] ...
}
// or you have to pass in a client or pass in a client per method
class Requests(client: Client) {
def index...
// sometimes like this, without the class level Client
def index2(client:Client, ...)...
}
This design often requires a factory to create clients.
There is nothing wrong with this approach, but even in an “end-of-the-world” application (vs. a library) we may want some flexibility to change components out. If we’re going to adhere to a more functional style, we need to break out the “command” methods and define new commands without extending or changing the Client class each time. If different “runner” strategies are allowed, it is often a parameter to the class object. Using an approach like class Requests
is more extensible than the first approach and is more functional. However, we can take it one step further.
A functional approach is helpful when a new “module” needs to be added but we want to avoid touching the core program code, e.g., change or extend the Client class. There are many java, python, and other programming languages’ "Client"s that follow the standard approach described above. A functional approach is more like the “Requests” class. However, we can get rid of the Requests “container” class structure.
Using a functional design avoids the “class Client/Requests,” breaks out the “concerns,” but will make the usage unfamiliar to non-functional programmers. That’s a trade-off. However, dotty/scala3 makes it easy to implement a functional design, especially when we want to combine parts that are not already pre-integrated.
By enabling the easy combination of non-integrated parts, functional programming can theoretically achieve higher levels of innovation and specialization for problem-solving. If someone has already written a “Client” class for you and it does everything you need exactly the way you want, use it. If you’re going to target a vast audience with a library, you could choose to use the standard approach and bake in the subcomponents that everyone agrees are the right ones. Of course, an agreement is hard.
Functional Programming Design
Let’s assume that we want to connect to AWS service elasticsarch which has a few REST commands such as PUT indexname, DELETE indexname, HEAD indexname and so on. I would like to have something easy to use such as shown below:
// indexes.exists() creates the service specific "request" object as an
// immutable value that could be rused.
val effect = indexes.exists("movies").bundle
// Run the effect, handle result.
runtime.unsafeRunAsync(effect){ ... }
While in the code above, I immediately converted the command request into an effect, that is not typical in my codebase, and I use it for illustration purposes. Creating a command as a value allows me to create it next to other data (such as ML data) on a separate thread, and execute it later. Or, I can use it as a “base” request in a “builder” approach that allows simpler and cheaper customization later in the data pipeline. I can also build commands on other commands (combinators) with consistent syntax that hides many of the sttp, aws, circe and execution details. In the above, I never need to explicitly “mix” in a Client object so the “reading” burden is less.
My program is an app, not a library, so I will dictate specific components I want to use. I do not need to code everything to a generic effect or HTTP library model so I can skip more clever functional, but complicated, constructs. I want to use ZIO for effects, sttp for HTTP communication, circe for json processing. All of these libraries are already functionally designed. All I need is a layer on top.
“[command].bundle” needs to act on a data “request” command object, infuse it with knowledge about the backend then bundle the request into an effect. I want to run the effect based on my chosen runtime customized for error handling and parallelism. I will also need to combine effects to create new logic, etc.
Since the application needs to connect to AWS, all requests must be signed. Signing processes and transforms a basic request object into a new request object. While sttp has a ZIO asynchronous backend that is quite flexible, due to sttp’s design, it cannot be parameterized with an execution environment, R, that ZIO could use to “inject” into the effect. Hence, we need a convenient way to create a reader monad-like environment. sttp uses a variant of ZIO, IO[Throwable, R]
. AWS signing requires a fully formed request object to act on, so let’s define a few types that make up the “client” design. The design below uses several dotty specific features:
/** Request type for sttp. Id indicates that the URI is known and
* AWS signing requires a fully specified request object.
*/
type RequestType = [T] =>> RequestT[Id, T, Nothing]
/** The bare minimum to prep a general HTTP request object for transport to the
* web service--essentially a HTTP request pipeline interceptor/transformer.
* Since sending/receiving a HTTP request is highly backend specific, that
* concern is external to the trait.
*
* The transform should be applied, typically, as the last change to the request
* prior to sending if e.g. this signs the request with your access key.
*/
trait Endpoint {
/** Prep a request object for transport to an endpoint e.g. security. */
def prep[T](r: RequestType[T]): RequestType[T]
/** Endoint for URI construction. */
def host: String
}
/** Execute a HTTP request, given a context, with ZIO effects and stttp. Use
* this implicit function like a Reader monad on the "send" implementations.
* The return value is *not* sttp.Response[T] but a ZIO effect.
*
* @tparam T Effect output value.
*/
type Executor[T] = given Env => zio.IO[Throwable, T]
/** Execution environment. */
case class Env(
endpoint: Endpoint,
backend: SttpBackend[[X] =>> zio.IO[Throwable, X], Nothing]
){
// convenience exports to avoid env.endpoint.host expressions
export endpoint._
export backend.send}
/** A command, such as an REST request, has a return type RT. For convenience. */
trait Command[R] { type RT = R }
/** Given a command object provide the capability
* to create an effect by "bundling" everything together.
*/
trait Bundler[C <: { type RT}] {
def bundle(c: C): Executor[c.RT]
}
/** Postfix version e.g. `createCommand().bundle`. Both a Env and a Bundler must
* be in given scope.
*/
def (c: C) bundle[C <: {type RT}] given (r: Bundler[C], e: Env) = r.bundle(c)
dotty features used above include:
- extension methods
- dependent types
- given definitions/instances
- exports
- implicit functions
- objectless toplevel definitions and types
Well is this better? If you squint hard enough you will see that the Env object is really a Client class
that was covered earlier. It combines all of the “concerns” together needed to turn a request into a response. Using the implicit functions is just a way to avoid passing the client around for every command and removes the need to define large, complex client objects.
Error Channels
sttp Response[T] objects have a body of Either[String, T] so there is an error channel in the response itself. ZIO has an error channel in the effect, Throwable. Also, if we need to parse the result from JSON, circe has an error channel in its ParseResult
parse method return value. That’s three levels of error channels when working with json. That’s unfortunate, but let’s just define absolve functions that crunch error channels down:
/** `Either[String, R]` is the body of a sttp.Response. */
def (effect: zio.IO[Throwable, Either[String, R]]) absolveLeft[R]: zio.IO[Throwable, R] =
effect
.flatMap {
case Left(errmsg) => zio.IO.fail(new RuntimeException(errmsg))
case Right(v) => zio.IO.succeed(v)
}
/** Collapse a return value from sttp and circe parsing.
* DeserializationError is not derived from Throwable, but io.circe.Error is.
*/
def (effect: zio.IO[Throwable, Either[String, Either[DeserializationError[io.circe.Error], R]]]) absolveCirceParse[R]: zio.IO[Throwable, R] =
effect
.flatMap {
case Left(errmsg) => zio.IO.fail(new RuntimeException(errmsg))
case Right(more) => more match {
// We are throwing away the body here, may want to keep it
// by wrapping th circe error (ce). msg is Show on the response.
case Left(DeserializationError(orig, ce, msg)) => zio.IO.fail(ce)
case Right(r) => zio.IO.succeed(r)
}
}
We can use this inside our bundling functions and simplify response handling in our code.
Bundling Commands => Effects
Let’s define bundling typeclass instances that implement our REST client logic:
sealed trait indexes[R] extends Command[R]
object indexes {
case class exists(name: String) extends indexes[Boolean]
// ...more commands here
}
// types are shown explicitly
given as Bundler[indexes.exists] {
def bundle(c: indexes.exists): Executor[Boolean] =
given (e: Env) => {
val x: zio.IO[Throwable, Response[Boolean]] =
e.send(e.prep(
sttp
.head(uri"${e.host}/${c.name}")
.response(trueIfIs200)
))
val y: zio.IO[Throwable, Boolean] = x.map(_.body).absolveLeft
y
}
/* short version that I normally write:
given e => {
e.send(e.prep(
sttp
.head(uri"${e.host}/${c.name}")
.response(trueIfIs200)
)).map(_.body).absolveLeft
}
*/
}
// For every command we create, create a Bundler typeclass instance.
// No need to touch any existing classes.
// ...
/** sttp ResponseAs: convert to simple boolean if 200 */
def trueIfIs200 = ignore.mapWithMetadata((_,m) => m.is200)
That’s it for our functional web client. I’ve not shown more advanced error handling so if something fails, ZIO will report out the error. I also added retry and a few other concerns that are not shown in the above code.
To create and use an environment:
given env as Env = Env(
AwsEndpoint.unsafeCreate("es"), // not shown, custom class
AsyncHttpClientZioBackend(), // from sttp
)
// ...
val runtime = new DefaultRuntime {}
// ...
// issue commands, as promised
val effect = indexes.exists("movies").bundle
runtime.unsafeRunAsync(effect) {
// ...process ZIO result
}
Kicker Thought
Well here’s the kicker thought. While we accomplished the objective and dotty made it easy, the level of abstraction is significantly higher.
- Is this more functional design for my “web client application layer” worth it? If so, under what scenarios is it worth it?
- Client class => typeclass, one instance per command, with injection of the “context” for bundling, etc.
- Builder pattern => immutable data and copying like a prototype based language
- Commands are values run as effects.
- Is it over-engineered?
- Should a
Client
orRequests
design, which may be easier for non-functional engineers to understand, be used instead? - Were the sttp and ZIO abstractions already enough? Did more help?
I had some unique requirements for processing based on the ML algorithm I developed and my needs for processing efficiency. I needed to pull apart a few aspects of a HTTP client and in ~30 lines (excluding the commands) created the infrastructure for what I wanted. This approach was useful to me when I needed to innovate. It may not be the right approach in all situations. I was able to build on other functional libraries, although I could have selected non-functional libraries and reskinned them to be functional. I still had to choose whether to be more functional in my web client application layer. I benefited from the lower layers being functional.
I felt that dotty/scala gave me a choice for this project. Sometimes I choose a design as described above, and sometimes I do not care and can use a traditional design. Or, I may blend the approaches depending on project needs. To me, that is the usefulness of scala. I can use either method or mix them, and I can worry less about programming languages and focus on my problem.
P.S. Thanks sttp, zio, circe!
Nice! Is the code for this project somewhere public, like GitHub?
ReplyDelete