Skip to content

2. Setting apps up

Fede Fernández edited this page Sep 8, 2023 · 25 revisions

Functional Domain Modeling

The X's and O's of TicTacToe

Now that we have learned about sbt multiprojects, docker packaging, and the deployment of a full-stack system, we can start thinking about our domain.

There is almost nothing more important to the success of a programming project than a well-defined domain model. Scala 3 is well-suited for domain modeling. The familiar Scala 2 features that make Scala a great language for domain modeling:

  • case classes
  • pattern matching
  • static typing
  • type inference
  • traits

all still exist in Scala 3.

However, new and improved features, including:

  • opaque types
  • improved enum types
  • union types
  • improved macro systems
  • hlist-like tuples
  • union and intersection types
  • CanThrow
  • Tasty

allow for more compile-time constraint restriction, discrete typing, and batteries-included domain modeling without the use of external libraries, like Refined, Scala Newtype, Shapeless 2 and scalameta.

A domain is the closed model of some universe of discourse. It's all the objects, operations, and relationships within the universe in the "real world". Obviously, we can't simulate all the things in a "real" pet store on a computer system -- that would include all the physical interactions of all the subatomic particles in the pet store and their probabilistic behavior. So, when we describe a domain we wish to encode, we define only the macro-level objects, operations, and relationships of the real domain relevant within the goals of the execution of a computer simulation or program.

Functional domain modeling is the practice of defining domain models as immutable data values and predominately pure functions that operate upon those models. It takes the practice of defining a model with names and operations from a real universe of discourse and applies functional principals to ensure that the data types are immutable and side effecting operations are limited.

For today's workshop, we're modeling a few very small universes of discourse. The first one is the game Tic Tac Toe. In Tic Tac Toe we have a 3x3 board forming a grid of positions players may play. There are two players. There are two piece types: X and O. One player is assigned to the X pieces and another to the Y pieces. Players may not swap pieces. The player assigned to X goes first and places an X in any space on the grid. Only one piece can occupy a grid space at one time. Once placed a piece cannot be moved. After X has placed their piece, the O player places an O on one of the remaining open spaces on the game board. Players continue to alternate until one player has 3 of the same pieces in a horizontal, vertical, or diagonal row of game board spaces, in which case that player wins the game, or there are no more spaces left upon which to play, which means the game is tied. Both conditions result in the end of the game and require the start of a new one.

Before we start the exercise, think about how you would design model domain datatypes in Scala 3. What data types will you need? Will they have fields? If so, what should they be named? Are there any relationships between them?

Exercise 4

⚠️ Before tackling this exercise: Check out this tag 2-1-models-exercise-setup 2-1-models-exercise-setup

Since your task today is to produce a full stack Tic Tac Toe game using Scala and Scala.js, you need to use your only subproject that is built for both Scala and Scala.js to build your shared domain model of Tic Tac Toe. This is the common project, and its code is in ./modules/common/shared/src/main/scala/scaladays/models/.

When first modeling a game's domain, think of the possible states the game can be in at any given time. In the description above, there are several states: turns, wins, and ties. In addition, as this is a simulation, there's a state where the game is "processing" the information sent to it from the players.

There are several options for how to model these states. They could be strings. They could be several independent case classes. They could be one case class with several separate boolean properties, all of which but 1 are true at any given time.

When modeling a domain, we should always try to preserve the commonalities between the objects in a domain, try to use the fewest objects possible to describe it, and keep the types as unique as possible with as few fields as possible. This is because, when testing or debugging or thinking about the program using the domain, you want to think about the fewest possible values an object of that type could be.

Deep-dive: Calculating and limiting the sizes of data models. To make the fewest possible items, we have to know how to calculate the size of the different types of types Scala 3 can express.

Scala 3 has two fundamental types of types:

  1. Product types combine other types together concurrently in a manner similar to a Cartesian product. They're called product types because the total possible values of the type is the product of the total possible values of the individual types. In Scala, product types are typically represented by case classes.

  2. Sum types have a number of possible forms or alternatives. They're called sum types because the total possible values of the type is the sum of the total possible values of the individual types. In Scala 3, sum types are typically represented by sealed trait algebraic data types or, in more recent versions of Scala, enums or union types.

The number of possible values of a sum type is calculated as the sum of the possible values of each of its alternatives.

For example, the Stoplight enum below:

enum Stoplight:
  case Red, Yellow, Green

has:

possible values of Stoplight = possible values of Ref + possible values of Yellow + possible values of Green = 1 + 1 + 1 = 3

The number of possible values for a product type is calculated as the product of the possible values of each of its fields.

For example, a case class of an Int and a Short:

case class Example(s: Short, i: Int)

is

possible values of Example = possible values of Short * possible values of Int = (32,767 (for the positive values) + 1 (for zero) + + 32,768 (for the negative values)) * (2,147,483,647 (for the positive values) + 1 (for zero) + 2,147,483,648 (for the negative values)) = 65536 * 4294967296 = 2.81474976710660 * 10^14

because s is of type Short and i is of type Int.

There are also generic product types. These are classes or case classes that take generic parameters. When we program against a generic interface, the Generic parameters count as a type with 1 possible value. Thus, since product types possible values are the product of the possible values of their fields, we should use the generic interface whenever possible when working with product types because a type like:

case class Foo[A, B, C, D](a: A, b: B, c: C, d:D)

even as a product type has 1 possible value, because A,B,C,and D all have one possible value, and the number of possible values for the product type, Foo[] is

possible values of Foo = possible values of A * possible values of B * possible values of C * possible values of D = 1 * 1 * 1 * 1 = 1

The two types of types can be mixed, for example a sum type made up of product types.

enum Things:
  case This(a: Short) extends Things
  case That(a: Short) extends Things
  

which has:

number of possible values of This + number of possible values of That = 65536 + 65536 = 131,072

Or a product type of sum types:

enum Stoplight:
  enum Stoplight:
  case Red, Yellow, Green
  
case class Foo(s: Short, light: Stoplight)

In this case, Foo has:

number possible values of Short * number possible values of Stoplight = 65536 * 3 = 196,608

possible values.

For completeness, there is a final type, intersection types, which is the combination of all the types joined by the type intersection operator:

trait Foo
trait Bar

type FooBar = Foo & Bar

Which is calculated the same way as product types -- that is the size of Foo * size of Bar.

From the above, it should be obvious that to keep things simple to think about we should prefer to use sum types over generic product types whenever possible.

Open modules/common/shared/src/main/scala/scaladays/models/Game.scala

It should look like this:

package scaladays.models


enum GameState:
  ???

Your task in this exercise, given the description of the game above and the goal of keeping the GameState type as simple as possible while retaining the relationships of the individual states, is to fill out the GameState enum.

There should be 6 possible types for GameState.

⚠️ Before continuing: Check out this tag 2-2-models-exercise-setup-cont 2-2-models-exercise-setup-cont

Game.scala should now be updated. The names may not be exactly the same as what you entered, but there should be exactly the same number of them and their meanings should be similar to yours.

We've now expanded the modeling to include the Piece type, the Position type, and the Movement type. Game.scala should now look like this:

enum Piece:
  case Cross, Circle

final case class Position(x: Int, y: Int)

object Position:
  given Ordering[Position] = (x: Position, y: Position) => if (x.x == y.x) x.y - y.y else x.x - y.x

enum GameState:
  case CrossTurn, CircleTurn, Processing, CrossWin, CircleWin, Tie

final case class Movement(position: Position, piece: Piece, confirmed: Boolean = true)

final case class Game(gameId: GameId, crossPlayer: PlayerId, circlePlayer: PlayerId, state: GameState, movements: List[Movement])

In addition in modules/common/shared/src/main/scala/scaladays/models there is a new file: ids.scala.

Scala 3 added a great feature for modeling called Opaque Types. It allows you to create an alias for a type that, outside of the file where it was created, behaves as if it is an entirely separate type. This is similar to type aliases in Scala 2, but in Scala 2, when two type aliases are assigned to the same type, the two types are equal to each other and interchangeable:

// scala 2

object aliases {
  type Foo = Int
  type Bar = Int

  case class UseFoo(f: Foo)
  case class UseBar(b: Bar)

  def makeUseFoo(b: Bar): UseFoo = UseFoo(b)
  def makeUseBar(f: Foo): UseBar = UseBar(f)
}

With opaque types, you can't accidentally swap Bar and Foo like in the above:

//scala 3

opaque type Foo = Int

object Foo:
  def apply(i: Int): Foo = i
  
opaque type Bar = Int

object Bar:
  def apply(i: Int): Bar = i
  
case class UseFoo(f: Foo)
case class UseBar(b: Bar)

def makeUseFoo(b: Bar): UseFoo = UseFoo(b) // won't compile!
def makeUseBar(f: Foo): UseBar = UseBar(f) // won't compile!

Exercise 5

In the game model above, we have two new types: GameId and PlayerId. To avoid possible pitfalls, you'll use FUUIDs instead of UUIDs in our domain model.

To quote the FUUID site:

Java UUID’s aren’t “exceptionally” safe. Operations throw and are not referentially transparent. We can fix that. -- https://davenverse.github.io/fuuid/

However, you don't want to be able to swap GameId and PlayerId anywhere we use the ids. So your job is to use opaque types to define GameId and PlayerId so that that cannot happen. Include an apply for each type like in the opaque type example above.

ids.scala should look like this:

package scaladays.models

import java.util.UUID

import io.chrisdavenport.fuuid.FUUID

object ids:

  opaque type PlayerId = ???
  opaque type GameId   = ???
  

When you are done, run:

commonJVM / console

In the sbt console, and paste the following at the scala> propmt:

import scaladays.models.ids.*
import _root_.io.chrisdavenport.fuuid.FUUID
import cats.effect.unsafe.implicits.*
import cats.effect.IO

FUUID.randomFUUID[IO].map(PlayerId.apply(_)).unsafeRunSync()
FUUID.randomFUUID[IO].map(GameId.apply(_)).unsafeRunSync()

You should get something similar to the below for output:

val res0: scaladays.models.ids.PlayerId = 7ef53be6-a986-40a3-a20e-a0cdc25a98c2
val res1: scaladays.models.ids.GameId = f535531d-8493-4bc4-aa7e-af7476b0a0e6

Enter :quit to exit the sbt console.

Solution

2-3-models-exercise-solution

diff

⚠️ In order to continue check out this tag: 2-4-models-exercise-solution-cont 2-4-models-exercise-solution-cont
Deep-dive: Explanation of the changes added

Three major changes have been introduced in the 2-4-models-exercise-solution-cont tag, grouped as follow:

New types

Added the types EventId and Nickname similarly we did with PlayerId and GameId

circe codecs

To serialize and deserialize these types, new circe codecs have been added in the companion objects. Don't pay too much attention to this for now, we'll learn more about it in the following section.

extension methods

These will be used later and provide some utility methods for these types.

Server architecture

Server Architecture

Creating the game server

Now that you've established the basic model for your game, we need to start talking about how to execute the model and allow two players to play it. You could do all of the work on the client side. However, that presents limitations. Only two players could play the game at a time. Both players would have to be present at the same computer to play. All the responsibilities of state management and processing as well as the rendering and input handling must be performed on the client, mixing the two concerns.

A more flexible solution is to create a web server to handle the processing of the business logic. This allows the client to focus on the user experience for the players and to offload game processing and state management to the server.

The premier scala http library is http4s.

It provides a typed functional model of http servers, clients, and websocket streaming communcations. It is built on libraries like fs2, cats, and cats-effect. These libraries work together very well, are well-documented, are extremely popular (of the 33.8 M indexed artifacts in maven central, cats is #181, cats-effect is the 288th most popular library, fs2 is #688, http4s-core is #2271), used at industrial scale by companies like Disney Streaming, and are supported by a dedicated, large, welcoming community of scala, http, and functional programming experts.

Exercise 6

⚠️ Before tackling this exercise: Check out this tag 2-5-http-server-exercise-setup 2-5-http-server-exercise-setup

In this exercise, you are going to setup a basic webserver using http4s. When you first setup an http server, the most basic thing you can do is create an http endpoint to check that you can communicate with it over http. This is typically called a "health check" endpoint.

The server code is in ./modules/server/src/main/scala/scaladays.

Deep-dive: Why we use Tagless Final in Scala without formal theory

Tagless Final Encoding

Before we go into the specifics of setting up an http4s server, let's take a little time to discuss a functional programming architecture called tagless final encoding. We're not going to go too deeply into the theorhetical background of tagless final, but we are going to talk about its practical uses.

As discussed in the domain modeling section, modeling a domain is not just about the data objects in the domain, but about the operations on those datatypes. Just as we want to constrain the size possible values as tightly as possible, we want to constrain the size of the possible implementations of the interfaces of those operations.

We calculate the size of a method or function in the same way as we calculate the size of a data type. Because methods and functions have arguments, they are inherently product types. They also have return values, and so to calculate the possible values, we have to take the product of the return type to the power of the possible values of the return type. For a method foo:

def foo(x: Int, s: Short): Boolean

the calculation looks like this:

number possible implementations of foo = number of values of Boolean ^ (number of possible values of Int * number of possible values of Short) = 2 ^ (4294967296 * 65536) = Effectively Infinity

.

Obviously, this is quite a large interface. We would have to generate a lot of inputs to test to exhaustively verify the behavior of foo any chosen foo implementation is the correct one. When the methods or classes are grouped into a module or class, we can add the sizes of the methods in the class together and calculate the size of the whole interface. These are likewise, quite large.

To reduce this complexity, we want to limit the size of the interfaces we create and use. We use the same methods to reduce this complexity as we do when creating data types -- use Sum types whenever possible, use generics whenever possible, and use small, constrained product types as arguments and returns whenever necessary. In scala, we also have the option of constraining the generic types passed to methods, providing small, independent interfaces applied to generic types called Context Bounds:

// here Labelable is a context bound on A, and Label is a Sum type
enum Label:
  case Sold, PendingSale, Undefined

trait Labelable[A]:
  def toLabel(a: A): Label

def foo[A: Labelable](a: A): Label

It's important to note that we don't have to express a context bound in the generic parameters of a method in Scala 3. We can also express it as a using parameter, a parameter to a ContextFunction, or a normal parameter. All of the following mean the same thing.

def fooUsing[A](a: A)(using Labelable[A]): Label
def fooContextFunction[A](a: A): Labelable[A] ?=> Label
def fooNormal[A](a: A, labelable: Labelable[A]): Label
val fooVal: [A] => Labelable[A] ?=> Label = [A] => Labelable[A] ?=> (a: A) => summon[Labelable[A]].toLabel(a)

In FP, we like to delay side effects until the latest possible moment. We do this, in general, by encoding programs as data types and defining an interpreter that converts a datatype into an executable program at runtime. This can be a little tedious without helper interfaces. Imagine programming fooVal above like this:

Apply(
  Select(
    Apply(
	  TypeApply(
	    ClassOf(
		  Summon.class
	   ), 
	   List(
	     TypeTree(
		   TypeApply(
		     ClassOf(
			   Labelable.class
		     ),
			 List(
			   TypeParamRef("A")
		     )
		   )
	     )
	   )
	  ), 
	  List()
	), 
    "toLabel"
  ), 
  List(
    ParamRef("a")
  )
)

That is clearly not very ergonomic. tagless final allows us to use all the complexity limiting tricks we talked about for values to encode programs with small, parameterized methods which produce trees of data values (for side effects) encoding a given, possibly side-effecting domain, like say handling http requests and responses behind the implementations of the interfaces. The data types that are built then expose some sort of run method, which interprets the value of the tree produced.

In this way, programs written with a tagless final encoding limit the cumbersome data type encodings required to work with side-effecting programs while still allowing users to reason about the code as a tree of immutable values. This allows the programmer to refactor tagless final programs safely.

It allows the designers of libraries to control and optimize the interpretation of the trees produced by effectful programs without adding additional complexity to the interfaces used by users of these libraries. Method calls to the interfaces are free to modify the input trees of previous calls, for example.

Meanwhile, users are prevented from violating the calling conventions of the library by coding against the interface rather than the implementation, enforced by the typechecker. The underlying complexity is fully abstracted away from the user.

New methods can be added to a library without violating the source compatibility of code built against earlier versions. And many other advantages.

In general, tagless final programs do this in the same way as we defined foo above, by inverting control to some interface over an unknown effect type, generally encoded as F[_] in interfaces and methods. The effect type is chosen later, when the program is executed in main or in tests. As long as the effect data type chosen matches the constraints of the interfaces passed to the methods within a program, any effect data type will work.

In http4s and cats-based libraries, we generally choose to use cats-effect's IO data type as the effect type.

Don't worry if you don't fully understand the above. Just know that tagless final is a way of managing complexity through inversion of control that produces programs as values, which are safer to refactor during maintenance and limit the number of things a programmer has to think about when reading the program.

Deep-dive: Http4s Server Boilerplate

There are some common things you will always use when setting up a configurable http4s server. In the latest commit, we've added some files to setup a server using an application.conf HOCON file. An example of this and the explanation of the files is below:

Open modules/server/src/main/scala/scaladays/Main.scala. You should see this:

package scaladays

import scaladays.models.ServiceError

import cats.effect.{ExitCode, IO, IOApp}

import org.typelevel.log4cats.Logger
import org.typelevel.log4cats.slf4j.Slf4jLogger

object Main extends IOApp:

  given logger: Logger[IO] = Slf4jLogger.getLogger[IO]

  def run(args: List[String]): IO[ExitCode] =
    Server
      .serve[IO]
      .compile
      .drain
      .handleErrorWith { error =>
        logger.error(error)("Unrecoverable error") >>
          IO.raiseError[ExitCode](ServiceError(error.getMessage))
      }
      .as(ExitCode.Success)

Let's explain what this does:

  • Main object extends IOApp. IOApp is a Cats-Effect trait that serves as a simple and safe entry point for an application which runs on the JVM. The main function of your application will run inside the IO context, providing safety and referential transparency.

  • The given keyword is used to define an implicit value of type Logger[IO], which represents a logger that works within the IO effect type. The Slf4jLogger.getLogger[IO] call creates a new logger backed by SLF4J, a logging facade for Java.

  • The run method is the main method that Cats-Effect's IOApp requires you to implement. It takes a list of command-line arguments and returns an IO of ExitCode. ExitCode is a datatype representing the exit code of a process, with Success and Error being the primary examples.

  • The body of run first calls Server.serve[IO], which is a method that starts an HTTP server and returns a stream of IO actions representing the server's operation.

  • compile is a method from fs2, a functional streams library for Scala. It transforms a stream into an effectful action. The action in this case is the server operation.

  • drain is another method from fs2 which runs the stream purely for its effects, discarding any output.

  • The handleErrorWith function is used to handle any errors that may occur when the server is running. If an error occurs, it logs an error message with the logger, and then raises an error of type ExitCode wrapped in the IO monad, using a ServiceError to wrap the original error message. The >> operator is used to sequence two IO actions, ensuring that the first action (logging the error) is completed before the second action (raising the error) is started.

  • Finally, .as(ExitCode.Success) is used to map the result of the entire computation to ExitCode.Success, meaning that if the server runs successfully and then terminates, the application will exit with a success status.

Open Server.scala. You should see the following:

package scaladays

import scaladays.config.{ConfigurationService}
import scaladays.server.*
import cats.effect.{Async, ExitCode, Resource}
import cats.implicits.*
import org.http4s.server
import org.http4s.server.middleware.CORS
import org.http4s.server.websocket.WebSocketBuilder
import org.typelevel.log4cats.Logger

object Server:

  def serve[F[_]: Async: Logger]: fs2.Stream[F, ExitCode] =
    for
      configService <- fs2.Stream.eval(ConfigurationService.impl)
      routes         = (ws: WebSocketBuilder[F]) => HealthCheck.healthService
      stream        <- fs2.Stream.eval(
                         configService.httpServer
                           .withHttpWebSocketApp(ws =>
                             CORS.policy.withAllowOriginAll.withAllowCredentials(false).apply(routes(ws).orNotFound)
                           )
                           .build
                           .use(_ => Async[F].never[ExitCode])
                       )
    yield stream

This Scala 3 file defines a Server object that sets up an HTTP server using http4s and Cats-Effect. Here is a breakdown of its components:

  • The serve method defines a server that operates inside an effect type F[_]. This type must have an Async instance (to provide asynchronous and concurrent operations) and a Logger instance (for logging). It returns a stream of ExitCode values, which represent the potential exit codes of the server process.

  • Inside the for-comprehension, configService <- fs2.Stream.eval(ConfigurationService.impl) retrieves an instance of a configuration service by evaluating an effect. fs2.Stream.eval creates a stream that emits the result of a single effectful computation.

  • The routes variable is defined as a function that takes a WebSocketBuilder and returns a HealthCheck.healthService. It provides a route for checking the health status of the service.

  • The stream variable is a stream that emits the result of an effectful computation. This computation first retrieves the HTTP server configuration from the configService, configures it with a WebSocket application, builds the server, and then starts it. The server is wrapped in a resource using configService.httpServer.build.use, ensuring that the server will be properly shut down even if an error occurs.

  • The WebSocket application is configured to use the CORS (Cross-Origin Resource Sharing) policy, which allows all origins and disallows credentials. This means that requests from any origin are allowed, but they cannot include credentials like cookies or HTTP authentication.

  • The routes for the WebSocket application are given by the routes function defined earlier. If a request does not match any route, a 404 Not Found response is returned (routes(ws).orNotFound).

  • Async[F].never[ExitCode] creates an effect that never completes, which is used to keep the server running indefinitely. The server will only be shut down when the JVM process is terminated, or if an error occurs in the server.

  • The for-comprehension yields the stream at the end, which is the stream of ExitCode values produced by the server. In this case, it will only ever emit a value if an error occurs in the server, because the server runs indefinitely.

Open modules/server/src/main/scala/scaladays/config/ConfigurationService.scala.

You should see the following:

package scaladays.config

import scaladays.models.{Configuration, HttpConfiguration}

import cats.effect.std.Dispatcher
import cats.effect.{Async, Resource}
import cats.implicits.*

import org.http4s.ember.server.EmberServerBuilder
import org.http4s.{Response, Status}

import com.comcast.ip4s.{Host, Port}
import org.typelevel.log4cats.Logger

trait ConfigurationService[F[_]]:

  def config: Configuration

  def httpServer: EmberServerBuilder[F]


object ConfigurationService:

  def impl[F[_]: Async: Logger]: F[ConfigurationService[F]] =
    def bootServer(httpConfiguration: HttpConfiguration): EmberServerBuilder[F] =
      EmberServerBuilder
        .default[F]
        .withHostOption(Host.fromString(httpConfiguration.host))
        .withPort(Port.fromInt(httpConfiguration.port).get)
        .withMaxHeaderSize(8 * 1024)
        .withIdleTimeout(scala.concurrent.duration.Duration.Inf)
        .withErrorHandler { case e =>
          Logger[F]
            .error(e)("Error in http server")
            .as(
              Response[F](Status.InternalServerError).putHeaders(org.http4s.headers.`Content-Length`.zero)
            )
        }
        .withOnWriteFailure { (optReq, response, failure) =>
          Logger[F].error(failure)(
            s"Error writing http response: \n\t- ${optReq.toString} \n\t- ${response.toString}"
          )
        }

    for
      conf     <- SetupConfiguration.loadConfiguration[F, Configuration]
    yield new ConfigurationService[F]:
      override lazy val config: Configuration                   = conf
      override lazy val httpServer: EmberServerBuilder[F]       = bootServer(
        conf.http.server
      )

This Scala 3 file defines a ConfigurationService trait and its implementation, which encapsulates the configuration data and HTTP server setup for the application.

Here's a breakdown of its components:

  • ConfigurationService trait: This trait has two methods:

    • config: Returns a Configuration object. The actual configuration data is not shown in this snippet, but it likely includes settings such as server host, port, etc.
    • httpServer: Returns a EmberServerBuilder[F] object, which is used to build an HTTP server.
  • ConfigurationService.impl: This method provides an implementation of the ConfigurationService trait. It requires an effect type F[_] that has an Async and a Logger instance. It returns a ConfigurationService[F] wrapped in an F effect.

  • bootServer function: This function takes an HttpConfiguration and returns a server builder. It sets up the server host, port, max header size, idle timeout, and error handlers using the provided configuration. The error handlers log any errors that occur in the server and return a 500 Internal Server Error response. There is also a handler for write failures, which logs the failed request and response.

  • The for-comprehension loads the configuration using SetupConfiguration.loadConfiguration[F, Configuration] and then creates a new ConfigurationService with this configuration. The config and httpServer methods of the service are implemented as lazy vals, meaning that they are only computed once, when they are first accessed.

    Open SetupConfiguration.scala. You should see the following:

    package scaladays.config
    

import scala.reflect.ClassTag

import cats.effect.Async import cats.implicits.*

import com.typesafe.config.ConfigFactory import org.typelevel.log4cats.Logger import pureconfig.{ConfigReader, ConfigSource}

object SetupConfiguration:

def loadConfiguration[F[_]: Async: Logger, C: ClassTag](using cr: ConfigReader[C]): F[C] = for classLoader <- Async[F].delay( ConfigFactory.load(getClass().getClassLoader()) ) config <- Async[F].delay( ConfigSource.fromConfig(classLoader).at("scaladays.workshop").loadOrThrow[C] ) yield config


This Scala 3 file defines a `SetupConfiguration` object that contains
a method for loading application configuration data. The configuration
data is loaded using the PureConfig and TypeSafe Config libraries.

Here's a breakdown of the file:

- `SetupConfiguration` object: This object has one method,
  `loadConfiguration`.

- `loadConfiguration` method: This generic method takes two type
  parameters `F[_]` and `C`. `F[_]` is the effect type, and `C` is the
  configuration type. The method requires the effect type `F[_]` to
  have an `Async` and `Logger` typeclass instance, and `C` to have a
  `ClassTag` and a `ConfigReader`. The method returns the
  configuration of type `C` wrapped in the effect type `F`.

    - `ClassTag` is a type class used by the Scala runtime system to
      retain type information for instances of generic types. It is
      used here to retain the type information about `C`, the
      configuration type.

    - `ConfigReader` is a type class from the PureConfig library. It
      provides a way to convert raw configuration data into a type
      `C`.

- Inside the method, a for-comprehension is used to load the
  configuration:
    - The `com.typesafe.config.Config` object is loaded using the
      class loader of the current class. The `Async[F].delay` function
      is used to suspend this operation in the `F` effect.
    - A `ConfigSource` is obtained by calling
      `ConfigSource.fromConfig(classLoader).at("scaladays.workshop")`. This
      specifies the path in the configuration file where the
      configuration data is located. `loadOrThrow[C]` is used to load
      and decode the configuration data into type `C`. If any error
      occurs during loading or decoding, an exception will be
      thrown. Again, `Async[F].delay` is used to suspend this
      operation in the `F` effect.
    - Finally, the loaded and decoded configuration is yielded.

The configuration file read by the `SetupConfiguration` object is
`modules/server/src/main/resources/application.conf`. Open it.

It should look like this:

```hocon
scaladays.workshop {
  http {
    server {
      host = "0.0.0.0"
      port = 8082
    }

    health {
      host = "0.0.0.0"
      port = 8083
    }
  }
}

The application.conf file contains configuration data for the scaladays.workshop application. The configuration is mainly for the HTTP server and health check services.

Here's a breakdown of the configuration:

  • scaladays.workshop: This is the root path in the configuration and holds all configuration data for the scaladays.workshop application.

  • http: Inside the scaladays.workshop block, there is an http block. This block contains configuration data related to HTTP services.

    • server: This block holds configuration data for the main HTTP server of the application.

      • host: This setting specifies the network interface the server should listen on. The value "0.0.0.0" means that the server should listen on all network interfaces.
      • port: This setting specifies the port number the server should listen on. The value 8082 means that the server should listen on port 8082.
    • health: This block contains configuration data for the health check service.

      • host: Similar to the host in the server block, this setting specifies the network interface the health check service should listen on. The value "0.0.0.0" means that the service should listen on all network interfaces.
      • port: Similar to the port in the server block, this setting specifies the port number the health check service should listen on. The value 8083 means that the service should listen on port 8083.

This configuration data can be loaded into your application using the PureConfig library, as shown in the SetupConfiguration.scala file. The loaded configuration data will be well-typed and can be used to set up your application's HTTP server and health check service.

Open modules/server/src/main/scala/scaladays/server/HealthCheck.scala. It should look lke this:

package scaladays.server

import cats.effect.Async

import org.http4s.*
import org.http4s.dsl.*

object HealthCheck:

  def healthService[F[_]: Async]: HttpRoutes[F] =
    val dsl = new Http4sDsl[F] {}
    import dsl.*
      HttpRoutes.of[F] {
        case GET -> Root / "hello" => Ok("World!")
    }

The HealthCheck.scala file defines a HealthCheck object that includes a method for setting up HTTP routes for health checking services.

Here's a breakdown:

  • HealthCheck object: This object contains one method, healthService.

  • healthService method: This method takes a single type parameter F[_] which requires an Async typeclass instance. The method returns HttpRoutes[F], a data type from the http4s library representing a set of HTTP routes.

Inside the healthService method:

  • An instance of Http4sDsl[F] is created. Http4sDsl is a domain-specific language (DSL) for creating HTTP routes in a declarative way. The instance is assigned to dsl, and all members of dsl are then imported for use.

  • HttpRoutes.of[F] is used to define a set of HTTP routes. One route is defined:

    • The route responds to a GET request to the path "/hello" with a 200 OK response containing the text "World!".

The Async typeclass in Cats Effect signifies a computation that may execute asynchronously. It is used here because http4s is a non-blocking, purely functional library for creating HTTP services, which aligns well with non-blocking, asynchronous effect types like the ones provided by Cats Effect.

You have two tasks in this exercise.

First of all, you need to define a new endpoint at GET /ping that outputs "pong", using the example of the /hello route in the file. When you are done defining the route, delete the example /hello route.

Secondly, you need to start the server, attach it to the app stream and find a way to keep the stream running. The code for this second task lives at modules/server/src/main/scala/scaladays/Server.scala. You should see something like:

package scaladays

import scaladays.config.ConfigurationService
import scaladays.server.*
import cats.effect.{Async, ExitCode, Resource}
import cats.implicits.*
import org.http4s.server
import org.http4s.server.middleware.CORS
import org.http4s.server.websocket.WebSocketBuilder
import org.typelevel.log4cats.Logger

object Server:

  def serve[F[_]: Async: Logger]: fs2.Stream[F, ExitCode] =
    for
      configService <- fs2.Stream.eval(ConfigurationService.impl)
      stream        <- fs2.Stream.eval(
                         // TODO - Use configService.httpServer to start an http app
                         // Find a way to keep the stream running
                         ???
                       )
    yield stream

Complete the ??? by starting a new HTTP app with the healthService and make it run forever.

Hint

Use withHttpApp for adding the routes to the server and Async[F].never in the .use for running it forever.

You can test if your service is correct by running

server/run

in the sbt console for your project, and opening: http://localhost:8082/ping in your browser when the server is fully started. You should see pong on the page.

Congratulations! You've just set up your first real-world http4s server.

Solution

2-6-http-server-exercise-solution

diff

Client architecture

Client Architecture

We will also opt for a solution written entirely in Scala on the client side. But current browsers can't compile Scala code, so as part of the build process, we will let Scala.js interpret our code and translate it into JavaScript.

We will also use a web framework so that, with simple Scala commands, we can express the behavior of our website. We will use Tyrian for this because it makes it very easy to render the views and describe the whole program as a state machine that adopts sequential behavior.

Tyrian

Tyrian is an Elm-inspired, purely functional UI library for Scala 3. Its purpose is to make building interactive websites in Scala 3 fun! Tyrian allows you to describe web pages and complex interactions in a way that is elegant, easy to read and easy to reason about.

The Elm Architecture

Tyrian provides a runtime environment for executing applications that was originally designed according to the Elm architecture.

Elm is the name of a language and an ecosystem, but it’s architecture has become more widely known as the ‘TEA Pattern’ ((T)he (E)lm (A)rchitecture) and has influenced many GUI/UI libraries and implementations beyond the world of functional programming.

The TEA pattern

The TEA Pattern is about:

  • Immutable data.
  • Pure functions.
  • Uni-directional data flow.
  • Strictly ordered events and updates.

This gives you a system that is very easy it reason about, since the data cannot (or is unlikely to) be subject to hard-to-test race conditions or side effects, and everything happens in a predictable order.

The purity of the system, the way that the state is held apart from the processing and rendering functions, also allows for easy testing without the need for complex mocking.

In essence:

notitle

A TyrianApp application essentially needs the following elements to function.

Model

It is a type of data that, according to its properties, must accurately reflect the state of the app. Any change of state must be able to be defined with an evolution of the model.

Msg

This type is usually an Enum and must encompass all the possible events that occur in our app. Events can generally trigger a change to the model and/or other effects that Tyrian calls Cmd.

Init state

The def init: (Model, Cmd[IO, Msg]) method expects the initial state to be defined by the initial values of the Model and a possible start command.

Update method

It is the key to the dynamics of the state machine because it is a function of the type (Model, Msg) => (Model, Cmd), which translated means: Given the current state (Model) and a new event (Msg), returns the new state (Model), and a possible side effect (Cmd).

View

Finally, it is necessary to define what view our app will have for each of the possible states (Model), or in other words, Model => Html[Msg].

Exercise 7

⚠️ Before tackling this exercise: Check out this tag 2-7-client-exercise-setup 2-7-client-exercise-setup

In this exercise, we are going to define the 5 elements that we need to start the Tyrian app.

Model

For now we're just going to define a very simple model that contains the nickname (type Nickname) . Please edit the Model.scala file and add the nickname property to the Model class. Don't forget to define the init value with an empty nickname.

Msg

Edit Messages.scala so that Msg includes a new event called UpdateNickname that contains the nickname property of type Nickname.

Update

The Update method should at least be able to react to the newly created UpdateNickname event. To do this, implement the override def update(model: Model[F]): Msg => (Model[F], Cmd[F, Msg]) method in Update.scala with the case at hand: Yes we get a Msg.UpdateNickname, so the model should evolve by adding the user's nickname, and performing the empty command Cmd.None.

View

Finally, we want the app to update the view when the model contains a new value for the nickname property. To do this, you will have to:

  • In Main, Implement the def view(model: ModelIO): Html[Msg] method so that if the model contains a nickname, then it displays the MainView.mainScreen(nickname) view.

  • In MainView.scala, implement a def mainScreen(nickname: Nickname): Html[Msg] view that contains:

    • A message showing the nickname.
    • An input that emits the UpdateNickname event as the user types.
Some help for the view:

How to generate DOM elements

  • Tyrian incorporates a syntax to express the html elements by using scala objects. More info
  • If you are used to Html: This amazing tool allows you to translate html elements into Tyrian objects.

Bootstrap 5 in da house

  • We added Bootstrap 5 so feel free to use cool components like this text input ;-)
div(cls := "form-floating mb-3")(
  input(
    tpe := "text",
    cls := "form-control",
    id := "div-login",
    placeholder := "Nickname",
    onInput(str => Msg.UpdateNickname(Nickname(str)))
  ),
  label(`for` := "div-login")("Nickname")
)

To verify that everything is in place, run the following command with Docker started.

dockerComposeUp

After the process is finished, you should be able to see your client by visiting http://localhost:23000/. Since the Login butting is disabled, we can fill the text field and see how it's updated.

Screenshot 2023-08-21 at 17 39 21

Solution

2-8-client-exercise-solution

diff