Full Stack Scala Web APP

About me

GOALS

  1. Develop a toy web app
  2. Only Scala! (css, html, js, ajax)
  3. Type-safe RPCs
  4. FE/BE code sharing
  5. Minimal abstractions
  6. Unopinionated
  7. "not a library" (<80 LOC)

Demo

Over-Engineered MORTGAGE calculator

Structure

  1. framework
  2. shared
  3. web
  4. server

Structure

// build.sbt

def module(name: String) = Project(id = name, base = file(name))
  .settings(scalaVersion := "2.13.7")

lazy val framework = module("framework")

lazy val shared = module("shared")
  .dependsOn(framework)

lazy val web = module("web")
  .dependsOn(shared)

lazy val server = module("server")
  .dependsOn(shared)

lazy val root = project.in(file("."))
  .aggregate(server, framework, shared, web)

def cmd(name: String, commands: String*) = Command.command(name){ s => 
  s.copy(remainingCommands = commands.toList.map(cmd => Exec(cmd, None)) ++ s.remainingCommands)
}

commands ++= List(
  cmd("dev", "scalafmtAll", "~;web/fastOptJS;server/reStart;", "server/reStop")
)

Libraries

// project/Dependencies.scala 
object Dependencies {
  val cask = "com.lihaoyi" %% "cask" % "0.8.0"
  val requests = "com.lihaoyi" %% "requests" % "0.7.1"
  
  val scalaJs = Def.setting(Seq(
    // Tests
    "org.scalameta" %%% "munit" % "0.7.29" % Test,

    // Scala JS
    "org.scala-js" %%% "scalajs-dom" % "1.1.0",
    "io.github.cquiroz" %%% "scala-java-time" % "2.2.2",
    "com.lihaoyi" %%% "scalatags" % "0.9.4",

    // Scala CSS
    "com.github.japgolly.scalacss" %%% "core" % "0.8.0-RC1",
    "com.github.japgolly.scalacss" %%% "ext-scalatags" % "0.8.0-RC1",

    // JS Wrappers
    "io.udash" %%% "udash-jquery" % "3.0.4",
    "org.openmole.scaladget" %%% "bootstrapslider" % "1.3.7",

    // API Layer
    "com.lihaoyi" %%% "upickle" % "1.3.8",
    "com.softwaremill.sttp.model" %%% "core" % "1.3.4",

    // Shared Layer
    "org.typelevel" %%% "squants"  % "1.6.0"
  ))
}

Dependencies

//build.sbt

def module(name: String) = Project(id = name, base = file(name))
  .settings(scalaVersion := "2.13.7")

lazy val framework = module("framework")
  .enablePlugins(ScalaJSPlugin)
  .settings(libraryDependencies ++= Seq(Dependencies.cask) ++ 
    Dependencies.scalaJs.value)

lazy val shared = module("shared")
  .enablePlugins(ScalaJSPlugin)
  .dependsOn(framework)

lazy val web = module("web")
  .enablePlugins(ScalaJSPlugin)
  .dependsOn(shared)

lazy val server = module("server")
  .dependsOn(shared)
  .settings(libraryDependencies ++= Seq(
    Dependencies.requests,
    Dependencies.logback,
    Dependencies.scalaLogging,
  ))

lazy val root = project.in(file(".")).aggregate(server, framework, shared, web)

STRUCTURE

ScaLaJS

import org.scalajs.dom.raw._

object Webpage {
  def html = doctype("html")(
    html(
      head(
        meta(charset := "utf-8"),
        meta(attr("name") := "viewport", content := "width=device-width, initial-scale=1, shrink-to-fit=no"),
      ),
      body(
        div(`class` := "container card w-25 mt-5 p-3")(
          h3("Mortgage Calculator"),
          form(id := "calculator")(
            label("APR", `for` := "apr", `class` := "col-form-label"),
            input(value := "apr", id := "apr", `type` := "number", `class` := "form-control"),
            button("Calculate", id := "calc_payments", `type` := "button", `class` := "btn btn-primary m-2")
          ),
        ),
      )
    )
  )
}

HTML

// Webpage.html.render

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
</head>
<body>
<div class="container card w-25 mt-5 p-3">
    <h3>Mortgage Calculator</h3>
    <form id="calculator">
        <label for="apr" class="col-form-label">APR</label>
        <input value="apr" id="apr" type="number" class="form-control"/>
        <button id="calc_payments" type="button" class="btn btn-primary m-2">Calculate</button>
    </form>
</div>
</body>
</html>

DRY

// framework/Page.scala

abstract class Page(name: String) {
  def renderDoc: doctype = doctype("html")(html(renderHead, renderBody, scripts))

  def styles: List[StyleSheet.Standalone] = Nil

  def cssLibs: List[Tag] = List(JsLibs.bootstrap.css)

  def jsLibs: List[Tag] = List(
    JsLibs.jquery,
    JsLibs.bootstrap.js,
  )

  def scripts: List[Modifier] = jsLibs ++ List(
    script(src := "/js/main.js"),
    script(s"$name.init()"),
  )

  def renderHead: Tag = head(
    meta(charset := "utf-8"),
    meta(attr("name") := "viewport", content := kv("width" -> "device-width", "initial-scale" -> "1")),
    cssLibs,
  )

  def renderBody: Tag

  @JSExport def init(): Any = {}
}

USAGE

import scalatags.Text.all._
import org.scalajs.dom.raw._
import scalajs.js.annotation.JSExportTopLevel
import io.udash.wrappers.jquery.{jQ => $, _}

@JSExportTopLevel("hello_world")
object HelloWorld extends framework.Page("hello_world") {
  override def renderBody = body(
    div(`class` := "container")(
      button("Push Me", id := "btn", `type` := "button"),
      div(`id` := "output")
    )
  )

  override def init() = {
    $("#btn").on("click", (elmt, evt) => {
      $("#output").text(Math.random())
    })
  }
}

FE Dependencies

// framework/JsLibs.scala

import scalatags.Text.all._

object JsLibs {
  def css(lib: String, version: String): Tag =
    link(href := s"https://cdn.jsdelivr.net/npm/$lib@$version/dist/css/$lib.min.css", rel := "stylesheet")

  def js(lib: String, version: String): Tag =
    js(lib = lib, version = version, file = lib)

  def js(lib: String, version: String, file: String): Tag =
    script(src := s"https://cdn.jsdelivr.net/npm/$lib@$version/dist/$file.min.js")
    
  // libraries
  val jquery = js("jquery", version = "3.2.1")

  object bootstrap {
    val version = "5.1.3"
    val css     = JsLibs.css("bootstrap", version = version)
    val js      = JsLibs.js("bootstrap", version = version, file = "js/bootstrap.bundle")
  }
}

Mortgage Calculator

import scalajs.js.annotation.JSExportTopLevel
import org.scalajs.dom.raw._
import scalatags.Text.{all => t}
import scalatags.Text.all._

@JSExportTopLevel("mortgage_calculator")
object MortgageCalculator extends framework.Page("mortgage_calculator") {
  override def renderBody = body(
    div(`class` := "container card w-25 mt-5 p-3")(
      h3("Mortgage Calculator"),
      form(t.id := "calculator")(
        input(label = "Loan Amount ($)", id = "loan", default = 1e6.toInt),
        input(label = "APR (%)", id = "apr", default = 5),
        input(label = "Mortgage Period (years)", id = "years", default = 30),
        input(label = "New APR", id = "new_apr", default = 3),
        button("Calculate", id := "calc_payments", `type` := "button", `class` := "btn btn-primary m-2"),
        button("Refinance?", id := "refinance", `type` := "button", `class` := "btn btn-secondary m-2"),
      ),
    ),
    div(id := "output", `class` := "container"),
  )

  def input(label: String, id: String, default: Int): Tag =
    div(`class` := "mb-3")(
      t.label(label, `for` := id, `class` := "col-form-label"),
      t.input(value := default, t.id := id, `type` := "number", `class` := "form-control"),
    )
}

HTTP REQUEST

override def init() = {
  $("#calc_payments").on("click", calc)
}

def calc(element: Element, event: JQueryEvent) = {
  val format = new DecimalFormat("$ #.00");
  import api.Mortgage
  $("#output").html(
    table(`class` := "table table-striped font-monospace")(
      tr(th("#"), th("Balance"), th("Payment"), th("Principal"), th("Interest")),
    ).render,
  )
  for {
    amount <- $("#loan").value().asInstanceOf[String].toIntOption
    apr    <- $("#apr").value().asInstanceOf[String].toFloatOption
    years  <- $("#years").value().asInstanceOf[String].toIntOption
    mortgage = Mortgage(amount = amount, apr = apr, years = years)
    payments       <- Mortgage.API.payments(mortgage)
    (payment, row) <- payments.zipWithIndex
  } $("#output tr:last").after(
    tr(
      td(row + 1),
      td(format.format(payment.balance)),
      td(format.format(payment.payment)),
      td(format.format(payment.principal)),
      td(format.format(payment.interest)),
    ).render,
  )
}

SHARED CODE

// shared/src/main/scala/api/Mortgage.scala

package api

case class Mortgage(principal: Double, apr: Double, years: Int)

case class Payment(principal: Double, interest: Double, balance: Double) {
  val payment = principal + interest
}

object Mortgage {

  trait API {
    def payments(m: Mortgage): Seq[Payment]
    def refinance(mortgage: Mortgage, newRate: Double): Double
  }
}

TYPE SAFE RPC

// shared/src/main/scala/api/Mortgage.scala

package api

case class Mortgage(principal: Double, apr: Double, years: Int)

case class Payment(principal: Double, interest: Double, balance: Double) {
  val payment = principal + interest
}

object Mortgage {
  import framework.RPC
  object API {
    val payments = new RPC[Mortgage, Seq[Payment]]("/api/mortgage/payments")
    val refinance = new RPC[(Mortgage, Double), Double]("/api/mortgage/refinance")
  }
}

RPC

//framework/src/main/scala/framework/RPC.scala

import upickle.default._

import scala.concurrent.{ExecutionContext, Future}

class RPC[I: ReadWriter, O: ReadWriter](path: String) {
  def apply(input: I): Future[O] =
    ???
}

RPC

//framework/src/main/scala/framework/RPC.scala

import org.scalajs.dom.ext.Ajax
import upickle.default._

import scala.concurrent.{ExecutionContext, Future}

class RPC[I: ReadWriter, O: ReadWriter](path: String) {
  def apply(input: I)(implicit ec: ExecutionContext): Future[O] =
    Ajax.post(
      url = path,
      data = write(input),
      headers = Map("Content-Type" -> "application/json"),
    ).map(res => read[O](res.responseText))
}

Flexible RPC

//framework/src/main/scala/framework/RPC.scala

import org.scalajs.dom.HttpMethod
import org.scalajs.dom.ext.Ajax
import upickle.default._

import scala.concurrent.{ExecutionContext, Future}

class RPC[I: ReadWriter, O: ReadWriter](method: HttpMethod, path: String) {
  def apply(input: I)(implicit ec: ExecutionContext): Future[O] = {
    method match {
      case HttpMethod.GET => Ajax.get(
        url = path,
        data = ??? // I -> url params 
      ).map(res => read[O](res.responseText))
      case HttpMethod.POST => Ajax.post(
        url = path,
        data = write(input),
        headers = Map("Content-Type" -> "application/json"),
      )  
    }
  }
}

JSON R/W

//shared/src/main/scala/api/Mortgage.scala

package api

import framework.RPC
import upickle.default._

case class Mortgage(amount: Double, apr: Double, years: Int)
object Mortgage {
  implicit val rw: ReadWriter[Mortgage] = macroRW
  object API {
    val payments  = new RPC[Mortgage, Seq[Payment]]("/api/mortgage/payments")
    val refinance = new RPC[(Mortgage, Double), Double]("/api/mortgage/refinance")
  }
}

case class Payment(principal: Double, interest: Double, balance: Double) {
  val payment = principal + interest
}
object Payment {
  implicit val rw: ReadWriter[Payment] = macroRW
}

SERVICE LAYER

//server/src/main/scala/services/MortgageApiImpl.scala
package services

import api.{Mortgage, Payment}

object MortgageApiImpl {
  def payments(mortgage: Mortgage): Seq[Payment] = {
    val r              = mortgage.apr / 100 / 12
    val n              = 12 * mortgage.years
    val c              = (x: Int) => 1 - Math.pow(1 + r, -x.toDouble)
    val monthlyPayment = mortgage.amount * r / c(n)
    val balance        = (i: Int) => mortgage.amount * c(n - i) / c(n)
    val principal      = (i: Int) => balance(i - 1) - balance(i)
    val interest       = (i: Int) => monthlyPayment - principal(i)
    (1 to n).map(i => Payment(
      principal = principal(i), 
      interest = interest(i), 
      balance = balance(i))
    )
  }

  def refinancePenalty(mortgage: Mortgage, newApr: Double): Double = {
    def totalInterest(m: Mortgage) = payments(m).map(_.interest).sum
    totalInterest(mortgage) - totalInterest(mortgage.copy(apr = newApr))
  }
}

SERVER

//server/src/main/scala/server/Router.scala
package server

import framework.RPC

import services.MortgageApiImpl

object Router extends cask.MainRoutes {
  val isProd = sys.env.get("IS_PROD").flatMap(_.toBooleanOption).exists(identity)

  @cask.get("/")
  def index() = views.MortgageCalculator.renderDoc

  @cask.staticFiles("/js/")
  def scalaJs() = s"web/target/scala-2.13/${if (isProd) "web-opt" else "web-fastopt"}/"

  @cask.post("/api", subpath = true)
  def routeApi(req: cask.Request) =
    RPC.wire(
      api.Mortgage.API.payments  ~~> MortgageApiImpl.payments,
      api.Mortgage.API.refinance ~~> Function.tupled(MortgageApiImpl.refinance),
    )(req)

  initialize()
}

API rOUTING

class RPC[I: ReadWriter, O: ReadWriter](path: String) {
  def apply(input: I)(implicit ec: ExecutionContext): Future[O] =
    Ajax.post(
      url = path,
      data = write(input),
      headers = Map("Content-Type" -> "application/json"),
    ).map(res => read[O](res.responseText))
  
  def ~~>(f: Function[I, O]): RPC.RequestHandler = { req =>
    req.remainingPathSegments.mkString("/") match {
      case `path` => Try(read[I](req.data)) match {
        case Failure(exception) => RPC.error(StatusCode.BadRequest, errors = List(exception.getMessage))
        case Success(input) => Try(f(input)) match {
          case Failure(err) => RPC.error(StatusCode.InternalServerError, errors = List(err.getMessage))
          case Success(result) => cask.Response(data = writeJs(result))
        }
      }
    }
  }
}

object RPC {
  import cask.{Request, Response}
  
  type RequestHandler = PartialFunction[Request, Response[ujson.Value]]

  def error(statusCode: StatusCode, errors: List[String] = Nil) =
    cask.Response(data = ujson.Obj("errors" -> errors), statusCode = statusCode.code)

  def wire(handlers: RequestHandler*): Request => Response[ujson.Value] =
    handlers.reduce(_ orElse _).orElse(_ => error(StatusCode.NotFound))
}

Use Cases

  1. Internal Apps
  2. Admin Pages
  3. DRY - shared logic b/w FE & BE
  4. Fun / personal projects

Shared VaLiDationS

case class Validation[A](rules: Map[String, A => Boolean]) {
  def rule(msg: String)(check: A => Boolean): Validation[A] =
    copy(rules = rules + (msg -> check))

  def apply(a: A): Validation.Result[A] =
    rules.collect({ case (msg, f) if !f(a) => msg }).toList match {
      case Nil        => Right(a)
      case violations => Left(violations)
    }
}

object Validation {
  type Result[A] = Either[List[String], A]

  def empty[A]: Validation[A] = Validation(Map.empty[String, A => Boolean])
  
  def apply[A]: Validation[A] = empty

  def apply[A](a: A)(implicit f: Validation[A]): Result[A] = f(a)

  implicit class Dsl[A: Validation](a: A) {
    def validate: Result[A] = Validation(a)
    def isValid: Boolean = validate.isRight
  }
}

VALIDATION

  case class Address(street: String, city: String, zip: String, country: String)
  object Address {
    implicit val validator: Validation[Address] = Validation[Address]
      .rule("street must be valid")(_.street.length > 5)
      .rule("zip must be of length 5")(_.zip.length == 5)
      .rule("country must be valid")(_.country.length == 2)
  }

  case class Person(name: String, address: Address)
  object Person {
    implicit val validator: Validation[Person] = Validation[Person]
      .rule("name must exist")(_.name.nonEmpty)
      .rule("name must start with capital letter")(_.name(0).isUpper)
      .rule("address is valid")(_.address.isValid)
  }
  

VALIDATION

class RPC[I: ReadWriter, O: ReadWriter](path: String) {
  def inputValidator: Validation[I] = Validation.empty

  def ~~>(f: Function[I, O]): RPC.RequestHandler = { req =>
    req.remainingPathSegments.mkString("/") match {
      case `path` =>
        Try(read[I](req.data)) match {
          case Failure(exception) => RPC.error(StatusCode.BadRequest, errors = List(exception.getMessage))
          case Success(input) =>
            inputValidator(input) match {
              case Left(violations) => RPC.error(StatusCode.BadRequest, errors = violations)
              case _ =>
                Try(f(input)) match {
                  case Failure(exception) => RPC.error(StatusCode.InternalServerError, errors = List(exception.getMessage))
                  case Success(result)    => cask.Response(data = writeJs(result))
                }
            }
        }
    }
  }
}
    val payments = new RPC[Mortgage, Seq[Payment]]("/mortgage/payments") {
      override def inputValidator = Validation[Mortgage]
    }

Scala CSS

import scalatags.Text.TypedTag
import scalacss.DevDefaults._
import scalacss.ScalatagsCss._

abstract class Page(name: String) {
  final lazy val renderDoc: doctype = doctype("html")(
    html(renderHead, renderBody, scripts)
  )

  def styles: List[StyleSheet.Standalone] = {
    import scala.language.postfixOps
    List(
      new StyleSheet.Standalone {
        import dsl._
        List(".slider", ".slider-horizontal").mkString - (
          width(100 %%),
        )
      },
    )
  }

  def cssLibs: List[Modifier] = List(
    JsLibs.bootstrap.css,
    styles.head.render[TypedTag[String]]
  )
}

Scala CSS

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet"/>
    <style type="text/css">
        .slider .slider-horizontal {
            width: 100%;
        }
    </style>
</head>

UTILS

override def init() = {
  $("#calc_payments").on("click", calc)
}

def calc(element: Element, event: JQueryEvent) = {
  val format = new DecimalFormat("$ #.00");
  import api.Mortgage
  $("#output").html(
    table(`class` := "table table-striped font-monospace")(
      tr(th("#"), th("Balance"), th("Payment"), th("Principal"), th("Interest")),
    ).render,
  )
  for {
    amount <- $("#loan").value().asInstanceOf[String].toIntOption
    apr    <- $("#apr").value().asInstanceOf[String].toFloatOption
    years  <- $("#years").value().asInstanceOf[String].toIntOption
    mortgage = Mortgage(amount = amount, apr = apr, years = years)
    payments       <- Mortgage.API.payments(mortgage)
    (payment, row) <- payments.zipWithIndex
  } $("#output tr:last").after(
    tr(
      td(row + 1),
      td(format.format(payment.balance)),
      td(format.format(payment.payment)),
      td(format.format(payment.principal)),
      td(format.format(payment.interest)),
    ).render,
  )
}

UTILS

override def init() = {
  $("#calc_payments").on("click", calc)
}

def calc(element: Element, event: JQueryEvent) = {
  val format = new DecimalFormat("$ #.00");
  import api.Mortgage
  $("#output").html(
    table(`class` := "table table-striped font-monospace")(
      tr(th("#"), th("Balance"), th("Payment"), th("Principal"), th("Interest")),
    ).render,
  )
  for {
    amount <- $("#loan").value().as[Int]
    apr    <- $("#apr").value().as[Double]
    years  <- $("#years").value().as[Int]
    mortgage = Mortgage(amount = amount, apr = apr, years = years)
    payments       <- Mortgage.API.payments(mortgage)
    (payment, row) <- payments.zipWithIndex
  } $("#output tr:last").after(
    tr(
      td(row + 1),
      td(format.format(payment.balance)),
      td(format.format(payment.payment)),
      td(format.format(payment.principal)),
      td(format.format(payment.interest)),
    ).render,
  )
}

JS READ Typeclass

// framework/src/main/scala/framework/JsRead.scala

package framework

import scala.scalajs.js.|
import scala.util.Try

trait JsRead[A] { self =>
  def apply(value: JsRead.Or): Option[A]

  def map[B](f: A => Option[B]): JsRead[B] =
    self(_).flatMap(f)

  def tryMap[B](f: A => B): JsRead[B] =
    map(a => Try(f(a)).toOption)
}

object JsRead {
  type Or = _ | _

  def apply[A](f: Or => Option[A]): JsRead[A] =
    f(_)

  implicit class Dsl(value: Or) {
    def as[A](implicit reader: JsRead[A]): Option[A] =
      reader(value)
  }

  implicit val string: JsRead[String] = JsRead(x => Try(x.asInstanceOf[String]).toOption)
  implicit val int: JsRead[Int]       = string.map(_.toIntOption)
  implicit val double: JsRead[Double] = string.map(_.toDoubleOption)
}

STRUCTURE

Thank YOU

We are hiring

  • Who we are:
  • What we do:
    • Quant Trading
    • Data Science
    • NLP
  • What we love:
    • Functional Programming
    • Algorithms
    • Statistics
    • Data
  •  Tech stack:
    • Scala
    • Spark
    • PostgreSQL
    • Tableau
    • R
    • Python
    • Docker
    • AWS
  • Where are we:
    • NYC
    • SF/Menlo

Full Stack Scala

By Pathikrit Bhowmick