Full Stack Scala Web APP
About me
-
Pathikrit Bhowmick
-
Scala for 10+ years
GOALS
- Develop a toy web app
- Only Scala! (css, html, js, ajax)
- Type-safe RPCs
- FE/BE code sharing
- Minimal abstractions
- Unopinionated
- "not a library" (<80 LOC)
Demo
Over-Engineered MORTGAGE calculator
Structure
- framework
- shared
- web
- 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
- Internal Apps
- Admin Pages
- DRY - shared logic b/w FE & BE
- 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