Let’s apply functional programming to solve a practical problem
By: Adam Warski
Tags: Scala, Http4s, Functional Programming, Tapir, Websocket

Let’s apply functional programming to a practical problem. Can you guess what it’s going to be?

We’ll walk step-by-step through the implementation of a simple web socket application, written using “functional” libraries. All of the code is on GitHub, if you’d like to follow having the code in your favorite IDE!

Here are the ingredients that we are going to use:

Scala, a type-safe, high-level functional language

sttp tapir to define the web socket server

sttp client to interact with the web socket as a client

http4s to expose the server to the outside world

Monix to manage and capture side-effects

fs2 to define asynchronous, effectful streams

The problem

When I said that we’ll apply FP to an everyday problem, I meant it. We’re going to prepare some pancakes.

As everything nowadays, due to well-known restrictions, our pancakes are going to be virtual. On the server side, we’ll expose a web socket endpoint, which will accept three types of messages, or should we say, ingredients: flour, milk and eggs. Each message must be formatted as JSON, e.g.:

{ "ingredient": "flour", "grams": 400 }

{ "ingredient": "milk", "liters": 0.2 }

{ "ingredient": "eggs", "count": 2 }

The server will respond with an acknowledgment of received ingredients. Once enough ingredients are accumulated, frying will start, and the client will receive fresh pancakes in regular time intervals (frying takes time). Server responses are also JSON values:

{ "status": "ingredient-received", "ingredient": { ... } }

{ "status": "pancake-ready" }

From the client’s perspective, we’ll be connecting to the websocket exposed by the server. Which ingredients to send and when is entirely up to the client. Don’t expect any pancakes, though, if not all ingredients are sent!

Finally, we’ll also want to expose documentation of our web socket endpoint, so that clients can easily understand the protocol they need to follow if they want to get fresh pancakes.

Protocol data

Before we get to defining servers and clients, we need a representation of the data classes corresponding to possible requests and responses. Both of these form a closed hierarchy of data classes (in other words, an ADT):

sealed trait PancakeIngredient

case class Flour(grams: Int) extends PancakeIngredient

case class Milk(liters: Double) extends PancakeIngredient

case class Eggs(count: Int) extends PancakeIngredient sealed trait PancakeStatus

case class IngredientReceived(ingredient: PancakeIngredient)

extends PancakeStatus

case object PancakeReady extends PancakeStatus

Each class defined above is immutable, can be easily constructed, deconstructed, and modified by creating a copy with changed data. However, given an instance, we can be sure that it will never change, making it safe to use it in multiple threads, without any need for synchronization or complex reasoning about threading interactions.

Next, we’ll need to define how to serialise and deserialise these values to/from JSON. There’s a lot of choices (too many!); here we’ll use the circe library. In Scala, we have the possibility of running code at compile-time, which generates more Scala code, which is then compiled to bytecode. In other words, Scala contains macros (which will be significantly improved in Scala 3!).

Circe and other JSON libraries take advantage of macros, offering automatic derivation of encoders/decoders for a type. This is done by inspecting the type’s fields at compile-time and generating the code that does the encoding/decoding. That way, all of the work is done upfront, and no reflection or other similar run-time mechanisms are needed.

Our request/response classes are quite uniform hence we can derive JSON codecs for all of them automatically. As we are dealing with a hierarchy of classes, we need to define the name of the discriminator field, that will be added to the resulting JSON to distinguish between the various implementations. We’ll use the lower-cased name of the class. That way we’ll get JSONs as discussed in the previous section. (The names our case classes weren’t a coincidence!) All of this is part of the Configuration of the generic derivation process, and is provided as an implicit value:

object Json extends PancakeIngredientCodecs with PancakeStatusCodecs trait PancakeStatusCodecs extends PancakeIngredientCodecs {

private implicit val config: Configuration = Configuration.default

.withDiscriminator("status")

.withSnakeCaseConstructorNames implicit val pancakeStatusCodec: Codec[PancakeStatus] =

deriveConfiguredCodec[PancakeStatus]

} trait PancakeIngredientCodecs {

private implicit val config: Configuration = Configuration.default

.withDiscriminator("ingredient")

.withSnakeCaseConstructorNames implicit val pancakeIngredientCodec: Codec[PancakeIngredient] =

deriveConfiguredCodec[PancakeIngredient]

}

Here, deriveConfiguredCodec is the macro invocation, which will generate the serialisation/deserialisation code at run-time. The status codecs depend on the ingredients code, as expressed using trait inheritance. Finally, we group both codecs in a single Json object, which we can conveniently import.

Describing the endpoints

Having the data representation ready, we’ll proceed by describing the web socket endpoint. This description will only define the structure of the endpoint, without any server-side or client-side logic.

You could look at the description as the endpoint metadata — something that is often expressed in Java using annotations. However here, the description will be a value in the base language, just like any other value representing “things” in your business domain, with which you are working every day.

That’s where sttp tapir comes in. Tapir is a library for describing HTTP endpoints in a type-safe way, using a programmer-friendly API. An endpoint consists of a number of inputs (which are mapped to HTTP requests), outputs (mapped to HTTP successful responses) and error outputs (in case the response should be an HTTP error, such as 4xx or 5xx ).

Each input and output, as well as the whole endpoint description is an immutable value — an instance of a Scala case class, just as our ingredient classes above. Remember, that we are only describing the shape of the endpoint, the communication protocol that it defines — the what. The how is left for later.

We want to expose the endpoint at the /pancakes path, and accept a single query parameter, specifying how many frying pans to use in parallel — ?pans=1 . The more frying pans, the more food we’ll get in a given time interval — provided, of course, that we’ll supply enough ingredients.

Here’s the description of the endpoint:

val pancakesEndpoint: Endpoint[

Int,

String,

Pipe[Task, PancakeIngredient, PancakeStatus],

WebSockets with Fs2Streams[Task]] =

endpoint

.in("pancakes")

.in(query[Int]("pans"))

.errorOut(stringBody)

.out(webSocketBody[

PancakeIngredient, CodecFormat.Json,

PancakeStatus, CodecFormat.Json](Fs2Streams[Task]))

We get a value, pancakesEndpoint , describing our endpoint’s inputs and outputs. The inputs for our endpoint are a constant path ( "pancakes" ), and a query parameter, described by the value returned by query[Int]("pans") . The query method is a convenient way of creating an instance of the EndpointInput.Query data class.

We can refine the description of our query parmaeter by providing description, example values or validation:

query[Int]("pans")

.description("The number of frying pans to use in parallel")

.example(2)

.validate(Validator.min(1))

The endpoint can return HTTP errors (by default with status code 400 — Bad Request ) by returning a stringBody . Note that if the query parameter is missing or fails to parse (is not a positive integer), an HTTP error is returned to the client as well. However, the shape of these parameter decode errors is defined separately, and by default will also be a string with status code 400. This can be of course configured, but is not a part of the endpoint description.

Another important note is that HTTP requests can fail in other ways, as well. First of all, we can have connection-level problems, such as exceptions when establishing the connection. Then, we can get an unhandled exception during request processing, which is typically translated to 500 — Internal Server Error .

Finally, when successful, our endpoint returns a webSocketBody as the output. We need to specify the shape and format (media type) of the input & output frames — in our case this is JSON, but it might also be plain text, xml or binary.

To capture the server-side logic of the behavior of a web socket (what frames it sends and when, and how it reacts to incoming frames), we’re using a Pipe from fs2. Just as query[Int] maps to an integer in the HTTP request, a webSocketBody maps to a pipe, that corresponds to the response to the request (viewed as the entire interaction with the client that happens as part of responding to the request).

A Pipe[Task, A, B] is a transformation of a stream of incoming frames to a stream of outgoing frames — a function: Stream[Task, A] => Stream[Task, B] . Zooming in on the Stream[Task, A] type:

Such a stream emits values of type A .

. While computing the values to emit, effects described by a Monix Task might occur. As a Task can capture any effects, this means that arbitrary side effects might occur when computing the values to emit.

might occur. As a can capture any effects, this means that arbitrary side effects might occur when computing the values to emit. The stream might compute values asynchronously and using multiple concurrent processes. This means, we can have many independent threads of execution computing new values to emit.

We’ll see the streams in action in a second!

Such a pipe is a much more powerful concept than an ordinary function. It can describe arbitrary interaction scenarios, such as:

emitting frames to the client without any input frames received from the client

emitting frames in regular intervals of time

accumulating input frames to produce output frames

responding with multiple output frames in response to a single input frame

The server

Let’s continue by taking on the most complex task — implementing the web socket server. We already have a value that describes the shape of our endpoint, pancakesEndpoint . To expose it to the world, we need to combine it with the server logic: what should happen when a request is received.

We can combine an endpoint description with its server logic by using the endpoint’s .serverLogic method. It expects a function corresponding to the shape of the defined inputs, error outputs and success outputs. The compiler will verify that we are using the correct types:

val pancakesServerEndpoint = pancakesEndpoint

.serverLogic(bakePancakes: Int => Task[Either[

String,

Pipe[Task, PancakeIngredient, PancakeStatus]])

Reading this type, we need to provide a bakePancakes function that takes an integer (the requested number of frying pans to use), and returns a value which is computed by a potentially side-effecting computation (represented by Task ). This value is:

either an error, represented by a value of type String

or success, a value of type Pipe[...] , capturing the server-side logic of the web socket

Notice how similar this is to the type of pancakesEndpoint !

We’ll get to implementing the bakePancakes function in a moment, but let’s first deal with actually exposing the endpoint to the outside world.

The value represented by pancakesServerEndpoint is a pair: a description of the endpoint shape, and the high-level logic to execute. We still need a library which binds to the appropriate port & interface, manages the connection pool, parses the incoming bytes into an HTTP request, and serializes the response. For that purpose, we’ll be using the http4s library.

To expose an endpoint (or multiple endpoints, but here we have just one), we need to translate tapir’s server endpoint into something that is understandable by http4s. This is done by an interpreter: a function, which takes a tapir ServerEndpoint and returns http4s HttpRoutes .

Tapir also has other server interpreters, which integrate with akka-http, Play, Vert.X or Finatra.

Hence, starting the server will consist of two steps: (1) interpreting the server endpoint as described above, using an extension method, (2) binding the server socket and serving requests as defined by the obtained value of type HttpRoutes :

val pancakesServerRoutes: HttpRoutes[Task] =

pancakesServerEndpoint.toRoutes def main(args: Array[String]): Unit =

BlazeServerBuilder[Task](global)

.bindHttp(8080, "localhost")

.withHttpApp(Router("/" -> pancakesServerRoutes).orNotFound)

.resource

.use { _ => Task.never }

.runSyncUnsafe()

Baking logic

We still have to define the central piece of our server application, the Pipe[Task, PancakeIngredient, PancakeStatus] that captures the logic of transforming a stream of incoming ingredients into a stream of statuses.

The logic has to accumulate the ingredients. Once we have enough, we need to pass a portion from which pancakes can be fried to a separate frying process (which runs concurrently to the ingredient-accumulating one: we don’t want frying to delay handling of the arriving web socket frames). Depending on the number of frying pans in use, the frying process takes the ingredients and after some time, turns them into fresh, delicious pancakes.

Let’s start by defining some data structures, which will help us in modelling the accumulation and portioning processes. First, we’ll have a cumulative Ingredients class, with some arithmetic operators, allowing us to add a single ingredient, or subtract/multiple other ingredients:

case class Ingredients(flourGrams: Int, milkLiters: Double, eggsCount: Int) {

def +(i: PancakeIngredient): Ingredients = ...

def -(i: Ingredients): Ingredients = ...

def *(n: Int): Ingredients = ...

}

The second helper class will represent a portion of ingredients, from which we can fry a given amount of pancakes:

case class PortionIngredients(ingredients: Ingredients, pancakeCount: Int)

We always fry at least 10 pancakes from 250 g of flour, 350 ml of milk and two eggs — that’s our basic portion. If there’s more ingredients available, we can multiply that, but we don’t allow fractions. Hence, we need a method which given some ingredients, will optionally return a multiply of portions to fry, and the remaining ingredients:

object PortionIngredients {

val SinglePortion: Ingredients = Ingredients(250, 0.35, 2)

val SinglePortionPancakeCount = 10 def from(ingredients: Ingredients):

(Ingredients, Option[PortionIngredients]) = ...

}

You can find the implementations of these functions in the sources, they are pretty straightforward.

Accumulating

Equipped with these data structures, we can now start implementing our stream processes. First, we’ll implement the accumulating process. We need a way to communicate between the accumulating & frying streams, hence the description of the accumulate process will be parametrised with:

the incoming stream of ingredients

a concurrent queue of PortionIngredients instances

The result will be a stream of messages to send back over the web socket — in our case, these will be confirmations that an ingredient has been received, represented by the IngredientReceived class:

def accumulate(

input: Stream[Task, PancakeIngredient],

fryingQueue: Queue[Task, PortionIngredients]

): Stream[Task, IngredientReceived]

There’s a wealth of combinators available in fs2, but the one we’re going to use here is evalMapAccumulate (the fact that is has accumulate in its name is not a coincidence!):

class Stream[F, O] { // we're using F = Task

def evalMapAccumulate[S, O2](

s: S

)(f: (S, O) => F[(S, O2)]): Stream[F, (S, O2)]

}

This combinator keeps track of some internal state S ; in our case, this state will be an instance of the Ingredients class. Initially, we are starting with all-0 ingredients. Then, we need to provide a function which takes the current state and a stream element — here, O is PancakeIngredient — and produces the updated state plus some output element; hence O2 is IngredientReceived .

This transformation is effectful — as we are returning the next state & output in the F effect. The effect that we’ll be performing is potentially enqueueing a portion of ingredients to the queue. Overall, you can look at this function as a definition of an effectful state machine. Here’s the implementation:

input

.evalMapAccumulate(Ingredients.Empty) {

case (ingredients, ingredient) => val newIngredients = ingredients + ingredient



val (remainingIngredients, portionIngredients) =

PortionIngredients.from(newIngredients)



val enqueueFrying = portionIngredients match {

case None => Task.now()

case Some(pi) => fryingQueue.enqueue1(pi)

}



enqueueFrying.map { _ =>

(remainingIngredients, IngredientReceived(ingredient)) }

}

}

.map(_._2)

Frying

The frying process has to dequeue data from the same queue, calculate how many pancakes should be fried on each frying pan (there might be +-1 differences between frying pans, as the requested number of pancakes doesn’t have to be a multiple of the number of frying pans).

First, let’s write a simple process which simulates the frying of a number of pancakes in sequence — that will correspond to a single frying pan. We’ll go for the simplest possible thing, assuming that we have all the ingredients ready and that frying a pancake takes one second. That’s quite fast, but slowing things down could mean that we skip supper just waiting for a test run of our code to complete:

def fryingPan(count: Int): Stream[Task, PancakeReady.type] =

Stream

.awakeEvery[Task](1.second)

.map(_ => PancakeReady)

.take(count)

We first create an infinite stream of PancakeReady instances, where one is produced every second, and than take the first count elements. The stream will finish once count elements are emitted.

Having that ready, we can now combine a number of frying pans in parallel. First, we obtain a dequeue-stream of PortionIngredients values from the queue:

def fry(fryingPans: Int,

fryingQueue: Queue[Task, PortionIngredients]):

Stream[Task, PancakeReady.type] =

fryingQueue.dequeue.flatMap { portionIngredients =>

...

}

Next, we calculate the distribution of pancakes-per-pan as a sequence of integers:

val singlePanPancakesCount = portionIngredients.pancakeCount /

fryingPans

val extraPancakesCount = portionIngredients.pancakeCount %

fryingPans



val pancakesPerFryingPan = (1 to fryingPans).map(i =>

singlePanPancakesCount + (if (i <= extraPancakesCount) 1 else 0))

Finally, we convert that sequence to a (finite) stream. We map each element to a frying-pan stream as defined above. This gives us a (finite) stream of (finite) streams. What’s left is creating a single stream, which will run all of the nested ones in parallel, and emit elements as they are created. This can be achieved using the .parJoinUnbounded combinator:

Stream(pancakesPerFryingPan: _*)

.map(fryingPan)

.parJoinUnbounded

Accumulating and frying at the same time

We still have to define the top-level process which, simlarly to what we’ve seen above, will combine the results of the accumulate and fry streams in parallel, and emit either the IngredientReceived confirmations, or PancakeReady . These are both subclasses of PancakeStatus , so we should get a Stream[Task, PancakeStatus] as a result.

Remember that the shape of the function that we need to provide to tapir to match the shape of the endpoint is the following:

def bakePancakes(fryingPans: Int): Task[Either[

String,

Pipe[Task, PancakeIngredient, PancakeStatus]]]

We are returning a Task , so we can do some effectful operations. And indeed, creating a concurrent queue is such an operation — it allocates a mutable, shared data structure, and this has to be done in a controlled environment.

The queue that we are going to create will be bounded to 10 elements — which is an arbitrary choice; in fact the important decision that we have make is whether we want to use a bounded, or an unbounded queue.

Using a bounded one, if there’s a lot of incoming ingredients, which overwhelm the frying process (all frying pans are busy, and we allow a buffer of up to 10 portion requests to be waiting), we want to backpressure the accumulating process. This in turn will backpressure receiving from the websocket, which sounds like a reasonable thing to do.

We’re not doing any additional validation or signalling any business errors, so in this implementation we’re not going to leverage the fact that we can signal an error by returning a Left(String) , and we’re going to always return the result as a either’s Right :

Queue.bounded[Task, PortionIngredients](10).map { fryingQueue =>

Right { input: Stream[Task, PancakeIngredient] =>

accumulate(input, fryingQueue)

.merge(fry(fryingPans, fryingQueue))

}

}