Cask


Table of Contents

Cask - a Scala HTTP micro-framework

Main Customization

Discord Chat
Patreon

package app
object MinimalApplication extends cask.MainRoutes{
  @cask.get("/")
  def hello() = {
    "Hello World!"
  }

  @cask.post("/do-thing")
  def doThing(request: cask.Request) = {
    request.text().reverse
  }

  initialize()
}

Cask is a simple Scala web framework inspired by Python's Flask project. It aims to bring simplicity, flexibility and ease-of-use to Scala webservers, avoiding cryptic DSLs or complicated asynchrony.

Getting Started

The easiest way to begin using Cask is by downloading the example project above.

Unzip one of the example projects available on this page (e.g. above) into a folder. This should give you the following files:

build.sc
app/src/MinimalExample.scala
app/test/src/ExampleTests.scala
./mill -w app.runBackground

This will server up the Cask application on http://localhost:8080. You can immediately start interacting with it either via the browser, or programmatically via curl or a HTTP client like Requests-Scala:

val host = "http://localhost:8080"

val success = requests.get(host)

success.text() ==> "Hello World!"
success.statusCode ==> 200

requests.get(host + "/doesnt-exist").statusCode ==> 404

requests.post(host + "/do-thing", data = "hello").text() ==> "olleh"

requests.get(host + "/do-thing").statusCode ==> 404

These HTTP calls are part of the test suite for the example project, which you can run using:

./mill -w app.test

To configure your Cask application to work with IntelliJ, you can use:

./mill mill.scalalib.GenIdea/idea

This will need to be re-run when you re-configure your build.sc file, e.g. when adding additional modules or third-party dependencies.

Cask is just a Scala library, and you can use Cask in any existing Scala project via the following coordinates:

// Mill
ivy"com.lihaoyi::cask:0.9.5"

// SBT
"com.lihaoyi" %% "cask" % "0.9.5"

The ./mill command is just a wrapper around the Mill build tool; the build.sc files you see in all examples are Mill build files, and you can use your own installation of Mill instead of ./mill if you wish. All normal Mill commands and functionality works for ./mill.

The following examples will walk you through how to use Cask to accomplish tasks common to anyone writing a web application. Each example comes with a downloadable example project with code and unit tests, which you can use via the same ./mill -w app.runBackground or ./mill -w app.test workflows we saw above.

Minimal Example

package app
object MinimalApplication extends cask.MainRoutes{
  @cask.get("/")
  def hello() = {
    "Hello World!"
  }

  @cask.post("/do-thing")
  def doThing(request: cask.Request) = {
    request.text().reverse
  }

  initialize()
}

The rough outline of how the minimal example works should be easy to understand:

In most cases, Cask provides convenient helpers to extract exactly the data from the incoming HTTP request that you need, while also de-serializing it into the data type you need and returning meaningful errors if they are missing. Thus, although you can always get all the data necessary through cask.Request, it is often more convenient to use another way, which will go into below.

As your application grows, you will likely want to split up the routes into separate files, themselves separate from any configuration of the Main entrypoint (e.g. overriding the port, host, default error handlers, etc.). You can do this by splitting it up into cask.Routes and cask.Main objects:

package app

case class MinimalRoutes()(implicit cc: castor.Context,
                           log: cask.Logger) extends cask.Routes{
  @cask.get("/")
  def hello() = {
    "Hello World!"
  }

  @cask.post("/do-thing")
  def doThing(request: cask.Request) = {
    request.text().reverse
  }

  initialize()
}
object MinimalRoutesMain extends cask.Main{
  val allRoutes = Seq(MinimalRoutes())
}

You can split up your routes into separate cask.Routes objects as makes sense and pass them all into cask.Main.

Variable Routes

package app
object VariableRoutes extends cask.MainRoutes{
  @cask.get("/user/:userName") // variable path segment, e.g. HOST/user/lihaoyi
  def getUserProfile(userName: String) = {
    s"User $userName"
  }

  @cask.get("/path") // GET allowing arbitrary sub-paths, e.g. HOST/path/foo/bar/baz
  def getSubpath(segments: cask.RemainingPathSegments) = {
    s"Subpath ${segments.value}"
  }

  @cask.post("/path") // POST allowing arbitrary sub-paths, e.g. HOST/path/foo/bar/baz
  def postArticleSubpath(segments: cask.RemainingPathSegments) = {
    s"POST Subpath ${segments.value}"
  }

  initialize()
}

You can bind path segments to endpoint parameters by declaring them as parameters. These are either:

Query Params

package app
object QueryParams extends cask.MainRoutes{

  @cask.get("/article/:articleId") // Mandatory query param, e.g. HOST/article/foo?param=bar
  def getArticle(articleId: Int, param: String) = {
    s"Article $articleId $param"
  }

  @cask.get("/article2/:articleId") // Optional query param
  def getArticleOptional(articleId: Int, param: Option[String] = None) = {
    s"Article $articleId $param"
  }

  @cask.get("/article3/:articleId") // Optional query param with default
  def getArticleDefault(articleId: Int, param: String = "DEFAULT VALUE") = {
    s"Article $articleId $param"
  }

  @cask.get("/article4/:articleId") // 1-or-more param, e.g. HOST/article/foo?param=bar&param=qux
  def getArticleSeq(articleId: Int, param: Seq[String]) = {
    s"Article $articleId $param"
  }

  @cask.get("/article5/:articleId") // 0-or-more query param
  def getArticleOptionalSeq(articleId: Int, param: Seq[String] = Nil) = {
    s"Article $articleId $param"
  }

  @cask.get("/user2/:userName") // allow unknown params, e.g. HOST/article/foo?foo=bar&qux=baz
  def getUserProfileAllowUnknown(userName: String, params: cask.QueryParams) = {
    s"User $userName " + params.value
  }

  @cask.get("/statics/foo")
  def getStatic() = {
    "static route takes precedence"
  }

  @cask.get("/statics/:foo")
  def getDynamics(foo: String) = {
    s"dynamic route $foo"
  }

  @cask.get("/statics/bar")
  def getStatic2() = {
    "another static route"
  }

  initialize()
}

You can bind query parameters to your endpoint method via parameters of the form:

Multi-method Routes

package app
object HttpMethods extends cask.MainRoutes{
  @cask.route("/login", methods = Seq("get", "post"))
  def login(request: cask.Request) = {
    if (request.exchange.getRequestMethod.equalToString("post")) "do_the_login"
    else "show_the_login_form"
  }

  @cask.route("/session", methods = Seq("delete"))
  def session(request: cask.Request) = {
    "delete_the_session"
  }

  @cask.route("/session", methods = Seq("secretmethod"))
  def admin(request: cask.Request) = {
    "security_by_obscurity"
  }

  @cask.route("/api", methods = Seq("options"))
  def cors(request: cask.Request) = {
    "allow_cors"
  }


  initialize()
}

Sometimes, you may want to handle multiple kinds of HTTP requests in the same endpoint function, e.g. with code that can accept both GETs and POSTs and decide what to do in each case. You can use the @cask.route annotation to do so

Receiving Form-encoded or JSON data

package app
object FormJsonPost extends cask.MainRoutes{
  @cask.postJson("/json")
  def jsonEndpoint(value1: ujson.Value, value2: Seq[Int]) = {
    "OK " + value1 + " " + value2
  }

  @cask.postJson("/json-obj")
  def jsonEndpointObj(value1: ujson.Value, value2: Seq[Int]) = {
    ujson.Obj(
      "value1" -> value1,
      "value2" -> value2
    )
  }

  @cask.postForm("/form")
  def formEndpoint(value1: cask.FormValue, value2: Seq[Int]) = {
    "OK " + value1 + " " + value2
  }

  @cask.postForm("/form-obj")
  def formEndpointObj(value1: cask.FormValue, value2: Seq[Int]) = {
    ujson.Obj(
      "value1" -> value1.value,
      "value2" -> value2
    )
  }

  @cask.postForm("/upload")
  def uploadFile(image: cask.FormFile) = {
    image.fileName
  }


  @cask.postJson("/json-extra")
  def jsonEndpointExtra(value1: ujson.Value,
                        value2: Seq[Int],
                        params: cask.QueryParams,
                        segments: cask.RemainingPathSegments) = {
    "OK " + value1 + " " + value2 + " " + params.value + " " + segments.value
  }

  @cask.postJsonCached("/json-obj-cached")
  def jsonEndpointObjCached(value1: ujson.Value, value2: Seq[Int], request: cask.Request) = {
    ujson.Obj(
      "value1" -> value1,
      "value2" -> value2,
      // `postJsonCached` buffers up the body of the request in memory before parsing,
      // giving you access to the request body data if you want to use it yourself
      "body" -> request.text()
    )
  }

  @cask.postForm("/form-extra")
  def formEndpointExtra(value1: cask.FormValue, 
                        value2: Seq[Int],
                        params: cask.QueryParams,
                        segments: cask.RemainingPathSegments) = {
    "OK " + value1 + " " + value2 + " " + params.value + " " + segments.value
  }
  
  initialize()
}

If you need to handle a JSON-encoded POST request, you can use the @cask.postJson decorator. This assumes the posted request body is a JSON dict, and uses its keys to populate the endpoint's parameters, either as raw ujson.Values or deserialized into Seq[Int]s or other things. Deserialization is handled using the uPickle JSON library, though you could write your own version of postJson to work with any other JSON library of your choice.

Similarly, you can mark endpoints as @cask.postForm, in which case the endpoints params will be taken from the form-encoded POST body either raw (as cask.FormValues) or deserialized into simple data structures. Use cask.FormFile if you want the given form value to be a file upload.

Both normal forms and multipart forms are handled the same way.

If the necessary keys are not present in the JSON/form-encoded POST body, or the deserialization into Scala data-types fails, a 400 response is returned automatically with a helpful error message.

Processing Cookies

package app
object Cookies extends cask.MainRoutes{
  @cask.get("/read-cookie")
  def readCookies(username: cask.Cookie) = {
    username.value
  }

  @cask.get("/store-cookie")
  def storeCookies() = {
    cask.Response(
      "Cookies Set!",
      cookies = Seq(cask.Cookie("username", "the_username"))
    )
  }

  @cask.get("/delete-cookie")
  def deleteCookie() = {
    cask.Response(
      "Cookies Deleted!",
      cookies = Seq(cask.Cookie("username", "", expires = java.time.Instant.EPOCH))
    )
  }

  initialize()
}

Cookies are most easily read by declaring a : cask.Cookie parameter; the parameter name is used to fetch the cookie you are interested in. Cookies can be stored by setting the cookie attribute in the response, and deleted simply by setting expires = java.time.Instant.EPOCH (i.e. to have expired a long time ago)

Serving Static Files

package app
object StaticFiles extends cask.MainRoutes{
  @cask.get("/")
  def index() = {
    "Hello!"
  }

  @cask.staticFiles("/static/file")
  def staticFileRoutes() = "resources/cask"

  @cask.staticResources("/static/resource")
  def staticResourceRoutes() = "cask"

  @cask.staticResources("/static/resource2")
  def staticResourceRoutes2() = "."

  initialize()
}

You can ask Cask to serve static files by defining a @cask.staticFiles endpoint. This will match any subpath of the value returned by the endpoint (e.g. above /static/file.txt, /static/folder/file.txt, etc.) and return the file contents from the corresponding file on disk (and 404 otherwise).

Similarly, @cask.staticResources attempts to serve a request based on the JVM resource path, returning the data if a resource is present and a 404 otherwise.

You can also configure the headers you wish to return to static file requests, or use @cask.decorators.compress to compress the responses:

package app
object StaticFiles2 extends cask.MainRoutes{
  @cask.get("/")
  def index() = {
    "Hello!"
  }

  @cask.staticFiles("/static/file", headers = Seq("Cache-Control" -> "max-age=31536000"))
  def staticFileRoutes() = "resources/cask"

  @cask.decorators.compress
  @cask.staticResources("/static/resource")
  def staticResourceRoutes() = "cask"

  @cask.staticResources("/static/resource2")
  def staticResourceRoutes2() = "."

  initialize()
}

Redirects or Aborts

package app
object RedirectAbort extends cask.MainRoutes{
  @cask.get("/")
  def index() = {
    cask.Redirect("/login")
  }

  @cask.get("/login")
  def login() = {
    cask.Abort(401)
  }

  initialize()
}

Cask provides some convenient helpers cask.Redirect and cask.Abort which you can return; these are simple wrappers around cask.Request, and simply set up the relevant headers or status code for you.

HTML Rendering

Cask doesn't come bundled with HTML templating functionality, but it makes it really easy to use community-standard libraries like Scalatags to render your HTML. Simply adding the relevant ivy"com.lihaoyi::scalatags:0.9.1" dependency to your build.sc file is enough to render Scalatags templates:

package app
import scalatags.Text.all._
object Scalatags extends cask.MainRoutes{
  @cask.get("/")
  def hello() = {
    doctype("html")(
      html(
        body(
          h1("Hello World"),
          p("I am cow")
        )
      )
    )
  }

  initialize()
}

If you prefer to use the Twirl templating engine, you can use that too:

package app
object Twirl extends cask.MainRoutes{
  @cask.get("/")
  def hello() = {
    "<!doctype html>" + html.hello("Hello World")
  }

  initialize()
}

With the following app/views/hello.scala.html:

@(titleTxt: String)
<html>
    <body>
        <h1>@titleTxt</h1>
        <p>I am cow</p>
    </body>
</html>

Extending Endpoints with Decorators

package app
object Decorated extends cask.MainRoutes {
  class User {
    override def toString = "[haoyi]"
  }
  class loggedIn extends cask.RawDecorator {
    def wrapFunction(ctx: cask.Request, delegate: Delegate) = {
      delegate(ctx, Map("user" -> new User()))
    }
  }
  class withExtra extends cask.RawDecorator {
    def wrapFunction(ctx: cask.Request, delegate: Delegate) = {
      delegate(ctx, Map("extra" -> 31337))
    }
  }

  class withCustomHeader extends cask.RawDecorator {
    def wrapFunction(request: cask.Request, delegate: Delegate) = {
      request.headers.get("x-custom-header").map(_.head) match {
        case Some(header) => delegate(request, Map("customHeader" -> header))
        case None =>
          cask.router.Result.Success(
            cask.model.Response(
              s"Request is missing required header: 'X-CUSTOM-HEADER'",
              400
            )
          )
      }
    }
  }

  @withExtra()
  @cask.get("/hello/:world")
  def hello(world: String)(extra: Int) = {
    world + extra
  }

  @loggedIn()
  @cask.get("/internal/:world")
  def internal(world: String)(user: User) = {
    world + user
  }

  @withCustomHeader()
  @cask.get("/echo")
  def echoHeader(request: cask.Request)(customHeader: String) = {
    customHeader
  }

  @withExtra()
  @loggedIn()
  @cask.get("/internal-extra/:world")
  def internalExtra(world: String)(user: User)(extra: Int) = {
    world + user + extra
  }

  @withExtra()
  @loggedIn()
  @cask.get("/ignore-extra/:world")
  def ignoreExtra(world: String)(user: User) = {
    world + user
  }

  @loggedIn()
  @cask.get("/hello-default")
  def defaults(world: String = "world")(user: User) = {
    world + user
  }
  initialize()
}

You can write extra decorator annotations that stack on top of the existing @cask.get/@cask.post to provide additional arguments or validation. This is done by implementing the cask.Decorator interface and it's getRawParams function. getRawParams:

Each additional decorator is responsible for one additional parameter list to the right of the existing parameter lists, each of which can contain any number of parameters.

Decorators are useful for things like:

For decorators that you wish to apply to multiple routes at once, you can define them by overriding the cask.Routes#decorators field (to apply to every endpoint in that routes object) or cask.Main#mainDecorators (to apply to every endpoint, period):

package app
object Decorated2 extends cask.MainRoutes{
  class User{
    override def toString = "[haoyi]"
  }
  class loggedIn extends cask.RawDecorator {
    def wrapFunction(ctx: cask.Request, delegate: Delegate) = {
      delegate(ctx, Map("user" -> new User()))
    }
  }
  class withExtra extends cask.RawDecorator {
    def wrapFunction(ctx: cask.Request, delegate: Delegate) = {
      delegate(ctx, Map("extra" -> 31337))
    }
  }

  override def decorators = Seq(new withExtra())

  @cask.get("/hello/:world")
  def hello(world: String)(extra: Int) = {
    world + extra
  }

  @loggedIn()
  @cask.get("/internal-extra/:world")
  def internalExtra(world: String)(user: User)(extra: Int) = {
    world + user + extra
  }

  @loggedIn()
  @cask.get("/ignore-extra/:world")
  def ignoreExtra(world: String)(user: User)(extra: Int)  = {
    world + user
  }

  initialize()
}

This is convenient for cases where you want a set of decorators to apply broadly across your web application, and do not want to repeat them over and over at every single endpoint.

Custom Endpoints

package app

class custom(val path: String, val methods: Seq[String])
  extends cask.HttpEndpoint[Int, Seq[String]]{
  def wrapFunction(ctx: cask.Request, delegate: Delegate) = {
    delegate(ctx, Map()).map{num =>
      cask.Response("Echo " + num, statusCode = num)
    }
  }

  def wrapPathSegment(s: String) = Seq(s)

  type InputParser[T] = cask.endpoints.QueryParamReader[T]
}

object Endpoints extends cask.MainRoutes{


  @custom("/echo/:status", methods = Seq("get"))
  def echoStatus(status: String) = {
    status.toInt
  }

  initialize()
}

When you need more flexibility than decorators allow, you can define your own custom cask.Endpoints to replace the default set that Cask provides. This allows you to

Generally you should not be writing custom cask.Endpoints every day, but if you find yourself trying to standardize on a way of doing things across your web application, it might make sense to write a custom endpoint decorator: to DRY things up , separate business logic (inside the annotated function) from plumbing (in the endpoint function and decorators), and enforcing a standard of how endpoint functions are written.

Gzip & Deflated Responses

package app
object Compress extends cask.MainRoutes{

  @cask.decorators.compress
  @cask.get("/")
  def hello() = {
    "Hello World! Hello World! Hello World!"
  }

  initialize()
}

Cask provides a useful @cask.decorators.compress decorator that gzips or deflates a response body if possible. This is useful if you don't have a proxy like Nginx or similar in front of your server to perform the compression for you.

Like all decorators, @cask.decorators.compress can be defined on a level of a set of cask.Routes:

package app

case class Compress2()(implicit cc: castor.Context,
                       log: cask.Logger) extends cask.Routes{
  override def decorators = Seq(new cask.decorators.compress())

  @cask.get("/")
  def hello() = {
    "Hello World! Hello World! Hello World!"
  }

  initialize()
}

object Compress2Main extends cask.Main{
  val allRoutes = Seq(Compress2())
}

Or globally, in your cask.Main:

package app

case class Compress3()(implicit cc: castor.Context,
                       log: cask.Logger) extends cask.Routes{

  @cask.get("/")
  def hello() = {
    "Hello World! Hello World! Hello World!"
  }

  initialize()
}

object Compress3Main extends cask.Main{
  override def mainDecorators = Seq(new cask.decorators.compress())
  val allRoutes = Seq(Compress3())
}

Websockets

package app

object Websockets extends cask.MainRoutes{
  @cask.websocket("/connect/:userName")
  def showUserProfile(userName: String): cask.WebsocketResult = {
    if (userName != "haoyi") cask.Response("", statusCode = 403)
    else cask.WsHandler { channel =>
      cask.WsActor {
        case cask.Ws.Text("") => channel.send(cask.Ws.Close())
        case cask.Ws.Text(data) =>
          channel.send(cask.Ws.Text(userName + " " + data))
      }
    }
  }

  initialize()
}

Cask's Websocket endpoints are very similar to Cask's HTTP endpoints. Annotated with @cask.websocket instead of @cask.get or @cask.post, the primary difference is that instead of only returning a cask.Response, you now have an option of returning a cask.WsHandler.

The cask.WsHandler allows you to pro-actively start sending websocket messages once a connection has been made, via the channel: WsChannelActor it exposes, and lets you react to messages via the cask.WsActor you create. You can use these two APIs to perform full bi-directional, asynchronous communications, as websockets are intended to be used for. Note that all messages received on a each individual Websocket connection by your cask.WsActor are handled in a single-threaded fashion by default: this means you can work with local mutable state in your @cask.websocket endpoint without worrying about race conditions or multithreading. If you want further parallelism, you can explicitly spin off scala.concurrent.Futures or other cask.BatchActors to perform that parallel processing.

Returning a cask.Response immediately closes the websocket connection, and is useful if you want to e.g. return a 404 or 403 due to the initial request being invalid.

Cask also provides a lower-lever websocket interface, which allows you directly work with the underlying io.undertow.websockets.WebSocketConnectionCallback:

package app

import io.undertow.websockets.WebSocketConnectionCallback
import io.undertow.websockets.core.{AbstractReceiveListener, BufferedTextMessage, WebSocketChannel, WebSockets}
import io.undertow.websockets.spi.WebSocketHttpExchange

object Websockets2 extends cask.MainRoutes{
  @cask.websocket("/connect/:userName")
  def showUserProfile(userName: String): cask.WebsocketResult = {
    if (userName != "haoyi") cask.Response("", statusCode = 403)
    else new WebSocketConnectionCallback() {
      override def onConnect(exchange: WebSocketHttpExchange, channel: WebSocketChannel): Unit = {
        channel.getReceiveSetter.set(
          new AbstractReceiveListener() {
            override def onFullTextMessage(channel: WebSocketChannel, message: BufferedTextMessage) = {
              message.getData match{
                case "" => channel.close()
                case data => WebSockets.sendTextBlocking(userName + " " + data, channel)
              }
            }
          }
        )
        channel.resumeReceives()
      }
    }
  }

  initialize()
}

It leaves it up to you to manage open channels, react to incoming messages, or pro-actively send them out, mostly using the underlying Undertow webserver interface. While Cask does not model streams, backpressure, iteratees, or provide any higher level API, it should not be difficult to take the Cask API and build whatever higher-level abstractions you prefer to use.

If you are separating your cask.Routes from your cask.Main, you need to inject in a cask.Logger to handle errors reported when handling websocket requests:

package app

case class Websockets3()(implicit cc: castor.Context,
                         log: cask.Logger) extends cask.Routes{
  @cask.websocket("/connect/:userName")
  def showUserProfile(userName: String): cask.WebsocketResult = {
    if (userName != "haoyi") cask.Response("", statusCode = 403)
    else cask.WsHandler { channel =>
      cask.WsActor {
        case cask.Ws.Text("") => channel.send(cask.Ws.Close())
        case cask.Ws.Text(data) =>
          channel.send(cask.Ws.Text(userName + " " + data))
      }
    }
  }

  initialize()
}

object Websockets3Main extends cask.Main{
  val allRoutes = Seq(Websockets3())
}

TodoMVC Api Server

package app
object TodoMvcApi extends cask.MainRoutes{
  case class Todo(checked: Boolean, text: String)
  object Todo{
    implicit def todoRW: upickle.default.ReadWriter[Todo] = upickle.default.macroRW[Todo]
  }
  var todos = Seq(
    Todo(true, "Get started with Cask"),
    Todo(false, "Profit!")
  )

  @cask.get("/list/:state")
  def list(state: String) = {
    val filteredTodos = state match{
      case "all" => todos
      case "active" => todos.filter(!_.checked)
      case "completed" => todos.filter(_.checked)
    }
    upickle.default.write(filteredTodos)
  }

  @cask.post("/add")
  def add(request: cask.Request) = {
    todos = Seq(Todo(false, request.text())) ++ todos
  }

  @cask.post("/toggle/:index")
  def toggle(index: Int) = {
    todos = todos.updated(index, todos(index).copy(checked = !todos(index).checked))
  }

  @cask.post("/delete/:index")
  def delete(index: Int) = {
    todos = todos.patch(index, Nil, 1)
  }

  initialize()
}

This is a simple self-contained example of using Cask to write an in-memory API server for the common TodoMVC example app.

This minimal example intentionally does not contain javascript, HTML, styles, etc.. Those can be managed via the normal mechanism for Serving Static Files.

TodoMVC Database Integration

package app
import scalasql.DbApi.Txn
import scalasql.Sc
import scalasql.SqliteDialect._

object TodoMvcDb extends cask.MainRoutes{
  val tmpDb = java.nio.file.Files.createTempDirectory("todo-cask-sqlite")
  val sqliteDataSource = new org.sqlite.SQLiteDataSource()
  sqliteDataSource.setUrl(s"jdbc:sqlite:$tmpDb/file.db")
  lazy val sqliteClient = new scalasql.DbClient.DataSource(
    sqliteDataSource,
    config = new scalasql.Config {}
  )

  class transactional extends cask.RawDecorator{
    def wrapFunction(pctx: cask.Request, delegate: Delegate) = {
      sqliteClient.transaction { txn =>
        val res = delegate(pctx, Map("txn" -> txn))
        if (res.isInstanceOf[cask.router.Result.Error]) txn.rollback()
        res
      }
    }
  }

  case class Todo[T[_]](id: T[Int], checked: T[Boolean], text: T[String])
  object Todo extends scalasql.Table[Todo]{
    implicit def todoRW = upickle.default.macroRW[Todo[Sc]]
  }

  sqliteClient.getAutoCommitClientConnection.updateRaw(
    """CREATE TABLE todo (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  checked BOOLEAN,
  text TEXT
);

INSERT INTO todo (checked, text) VALUES
(1, 'Get started with Cask'),
(0, 'Profit!');
""".stripMargin
  )

  @transactional
  @cask.get("/list/:state")
  def list(state: String)(txn: Txn) = {
    val filteredTodos = state match{
      case "all" => txn.run(Todo.select)
      case "active" => txn.run(Todo.select.filter(!_.checked))
      case "completed" => txn.run(Todo.select.filter(_.checked))
    }
    upickle.default.write(filteredTodos)
  }

  @transactional
  @cask.post("/add")
  def add(request: cask.Request)(txn: Txn) = {
    val body = request.text()
    txn.run(
      Todo
        .insert
        .columns(_.checked := false, _.text := body)
        .returning(_.id)
        .single
    )

    if (body == "FORCE FAILURE") throw new Exception("FORCE FAILURE BODY")
  }

  @transactional
  @cask.post("/toggle/:index")
  def toggle(index: Int)(txn: Txn) = {
    txn.run(Todo.update(_.id === index).set(p => p.checked := !p.checked))
  }

  @transactional
  @cask.post("/delete/:index")
  def delete(index: Int)(txn: Txn) = {
    txn.run(Todo.delete(_.id === index))
  }

  initialize()
}

This example demonstrates how to use Cask to write a TodoMVC API server that persists it's state in a database rather than in memory. We use the ScalaSql database access library to write a @transactional decorator that automatically opens one transaction per call to an endpoint, ensuring that database queries are properly committed on success or rolled-back on error. Note that because the default database connector propagates its transaction context in a thread-local, @transactional does not need to pass the ctx object into each endpoint as an additional parameter list, and so we simply leave it out.

While this example is specific to ScalaSql, you can easily modify the @transactional decorator to make it work with whatever database access library you happen to be using. For libraries which need an implicit transaction, it can be passed into each endpoint function as an additional parameter list as described in Extending Endpoints with Decorators. work with whatever database access library you happen to be using. For libraries which need an implicit transaction, it can be passed into each endpoint function as an additional parameter list as described in Extending Endpoints with Decorators.

TodoMVC Full Stack Web

The following code snippet is the complete code for a full-stack TodoMVC implementation: including HTML generation for the web UI via Scalatags, Javascript for the interactivity, static file serving, and database integration via ScalaSql. While slightly long, this example should give you a tour of all the things you need to know to use Cask.

Note that this is a "boring" server-side-rendered webapp with Ajax interactions, without any complex front-end frameworks or libraries: it's purpose is to demonstrate a simple working web application of using Cask end-to-end, which you can build upon to create your own Cask web application architected however you would like.

package app
import scalasql.DbApi.Txn
import scalasql.Sc
import scalasql.SqliteDialect._
import scalatags.Text.all._
import scalatags.Text.tags2

object TodoServer extends cask.MainRoutes{
  val tmpDb = java.nio.file.Files.createTempDirectory("todo-cask-sqlite")

  val sqliteDataSource = new org.sqlite.SQLiteDataSource()
  sqliteDataSource.setUrl(s"jdbc:sqlite:$tmpDb/file.db")
  lazy val sqliteClient = new scalasql.DbClient.DataSource(
    sqliteDataSource,
    config = new scalasql.Config {}
  )

  class transactional extends cask.RawDecorator{
    def wrapFunction(pctx: cask.Request, delegate: Delegate) = {
      sqliteClient.transaction { txn =>
        val res = delegate(pctx, Map("txn" -> txn))
        if (res.isInstanceOf[cask.router.Result.Error]) txn.rollback()
        res
      }
    }
  }

  case class Todo[T[_]](id: T[Int], checked: T[Boolean], text: T[String])
  object Todo extends scalasql.Table[Todo]

  sqliteClient.getAutoCommitClientConnection.updateRaw(
    """CREATE TABLE todo (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  checked BOOLEAN,
  text TEXT
);

INSERT INTO todo (checked, text) VALUES
(1, 'Get started with Cask'),
(0, 'Profit!');
""".stripMargin
  )

  @transactional
  @cask.post("/list/:state")
  def list(state: String)(txn: Txn) = renderBody(state)(txn).render

  @transactional
  @cask.post("/add/:state")
  def add(state: String, request: cask.Request)(implicit txn: Txn) = {
    val body = request.text()
    txn.run(Todo.insert.columns(_.checked := false, _.text := body))
    renderBody(state).render
  }

  @transactional
  @cask.post("/delete/:state/:index")
  def delete(state: String, index: Int)(implicit txn: Txn) = {
    txn.run(Todo.delete(_.id === index))
    renderBody(state).render
  }

  @transactional
  @cask.post("/toggle/:state/:index")
  def toggle(state: String, index: Int)(implicit txn: Txn) = {
    txn.run(Todo.update(_.id === index).set(p => p.checked := !p.checked))
    renderBody(state).render
  }

  @transactional
  @cask.post("/clear-completed/:state")
  def clearCompleted(state: String)(implicit txn: Txn) = {
    txn.run(Todo.delete(_.checked))
    renderBody(state).render
  }

  @transactional
  @cask.post("/toggle-all/:state")
  def toggleAll(state: String)(implicit txn: Txn) = {
    val next = txn.run(Todo.select.filter(_.checked).size) != 0
    txn.run(Todo.update(_ => true).set(_.checked := !next))
    renderBody(state).render
  }

  def renderBody(state: String)(implicit txn: Txn) = {
    val filteredTodos = state match{
      case "all" => txn.run(Todo.select).sortBy(-_.id)
      case "active" => txn.run(Todo.select.filter(!_.checked)).sortBy(-_.id)
      case "completed" => txn.run(Todo.select.filter(_.checked)).sortBy(-_.id)
    }
    frag(
      header(cls := "header",
        h1("todos"),
        input(cls := "new-todo", placeholder := "What needs to be done?", autofocus := "")
      ),
      tags2.section(cls := "main",
        input(
          id := "toggle-all",
          cls := "toggle-all",
          `type` := "checkbox",
          if (txn.run(Todo.select.filter(_.checked).size !== 0)) checked else ()
        ),
        label(`for` := "toggle-all","Mark all as complete"),
        ul(cls := "todo-list",
          for(todo <- filteredTodos) yield li(
            if (todo.checked) cls := "completed" else (),
            div(cls := "view",
              input(
                cls := "toggle",
                `type` := "checkbox",
                if (todo.checked) checked else (),
                data("todo-index") := todo.id
              ),
              label(todo.text),
              button(cls := "destroy", data("todo-index") := todo.id)
            ),
            input(cls := "edit", value := todo.text)
          )
        )
      ),
      footer(cls := "footer",
        span(cls := "todo-count",
          strong(txn.run(Todo.select.filter(!_.checked).size).toInt),
          " items left"
        ),
        ul(cls := "filters",
          li(cls := "todo-all",
            a(if (state == "all") cls := "selected" else (), "All")
          ),
          li(cls := "todo-active",
            a(if (state == "active") cls := "selected" else (), "Active")
          ),
          li(cls := "todo-completed",
            a(if (state == "completed") cls := "selected" else (), "Completed")
          )
        ),
        button(cls := "clear-completed","Clear completed")
      )
    )
  }

  @transactional
  @cask.get("/")
  def index()(implicit txn: Txn) = {
    doctype("html")(
      html(lang := "en",
        head(
          meta(charset := "utf-8"),
          meta(name := "viewport", content := "width=device-width, initial-scale=1"),
          tags2.title("Template • TodoMVC"),
          link(rel := "stylesheet", href := "/static/index.css")
        ),
        body(
          tags2.section(cls := "todoapp", renderBody("all")),
          footer(cls := "info",
            p("Double-click to edit a todo"),
            p("Created by ",
              a(href := "http://todomvc.com","Li Haoyi")
            ),
            p("Part of ",
              a(href := "http://todomvc.com","TodoMVC")
            )
          ),
          script(src := "/static/app.js")
        )
      )
    )
  }

  @cask.staticResources("/static")
  def static() = "todo"

  initialize()
}

Running Cask with Virtual Threads

package app

import cask.main.Main

import java.lang.management.{ManagementFactory, RuntimeMXBean}
import java.util.concurrent.{ExecutorService, Executors}

// run benchmark with : ./mill benchmark.runBenchmark
object MinimalApplicationWithLoom extends cask.MainRoutes {
  // Print Java version
  private val javaVersion: String = System.getProperty("java.version")
  println("Java Version: " + javaVersion)

  // Print JVM arguments// Print JVM arguments
  private val runtimeMxBean: RuntimeMXBean = ManagementFactory.getRuntimeMXBean
  private val jvmArguments = runtimeMxBean.getInputArguments
  println("JVM Arguments:")

  jvmArguments.forEach((arg: String) => println(arg))

  println(Main.VIRTUAL_THREAD_ENABLED + " :" + System.getProperty(Main.VIRTUAL_THREAD_ENABLED))

  //Use the same underlying executor for both virtual and non-virtual threads
  private val executor = Executors.newFixedThreadPool(4)

  //TO USE LOOM:
  //1. JDK 21 or later is needed.
  //2. add VM option: --add-opens java.base/java.lang=ALL-UNNAMED
  //3. set system property: cask.virtual-threads.enabled=true
  //4. NOTE: `java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor` is using the shared
  //   ForkJoinPool in VirtualThread. If you want to use a separate ForkJoinPool, you can create
  //   a new ForkJoinPool instance and pass it to `createVirtualThreadExecutor` method.

  override protected def handlerExecutor(): Option[ExecutorService] = {
    super.handlerExecutor().orElse(Some(executor))
  }

  /**
   * With curl: curl -X GET http://localhost:8080/
   * you wil see something like:
   * Hello World! from thread:VirtualThread[#63,cask-handler-executor-virtual-thread-10]/runnable@ForkJoinPool-1-worker-1%
   * */
  @cask.get("/")
  def hello() = {
    Thread.sleep(100) // simulate some blocking work
    "Hello World!"
  }

  @cask.post("/do-thing")
  def doThing(request: cask.Request) = {
    request.text().reverse
  }

  initialize()
}

Cask can support using Virtual Threads to handle the request out of the box, you can enable it with the next steps:

  1. Running cask with Java 21 or later
  2. add --add-opens java.base/java.lang=ALL-UNNAMED to your JVM options, which is needed to name the virtual threads.
  3. add -Dcask.virtual-threads.enabled=true to your JVM options, which is needed to enable the virtual threads.
  4. tweak the underlying carrier threads with -Djdk.virtualThreadScheduler.parallelism, jdk.virtualThreadScheduler.maxPoolSize and jdk.unparker.maxPoolSize.

Advanced Features:

  1. You can change the default scheduler of the carrier threads with cask.internal.Util.createVirtualThreadExecutor method, but keep in mind, that's not officially supported by JDK for now.
  2. You can supply your own Executor by override the handlerExecutor() method in your cask.Main object, which will be called only once when the server starts.
  3. You can use jdk.internal.misc.Blocker's begin and end methods to help the ForkJoinPool when needed.

NOTE:

  1. If your code is CPU-bound, you should not use virtual threads, because it will not improve the performance, but will increase the overhead.
  2. A common deadlock can happen when both a virtual thread and a platform thread try to load the same class, but there is no carrier thread available to execute the virtual thread, which will lead to a deadlock, make sure jdk.virtualThreadScheduler.maxPoolSize is large enough to avoid it.
  3. Virtual thread does some kind of rescheduling which may lead to higher latency when the task is actually cpu bound.
  4. OOM is a common issue when you have many virtual threads, you should limit the max in-flight requests to avoid it.
  5. There are some known issues which can leads to a deadlock, you should be careful when using it in production, at least after long time stress test.
  6. JEP 491: Synchronize Virtual Threads without Pinning will be shipped in Java 24.
  7. Some info from early adaptor faire

Some benchmark results:

Test Case Thread Type Requests/sec Avg Latency Max Latency Transfer/sec
staticFilesWithLoom Platform 81,766.27 1.23ms 28.64ms 11.38MB
staticFilesWithLoom Virtual 75,488.32 4.41ms 157.91ms 10.51MB
todoDbWithLoom Platform 81,929.40 1.22ms 11.67ms 13.13MB
todoDbWithLoom Virtual 76,072.80 3.97ms 160.29ms 12.19MB
minimalApplicationWithLoom Platform 38.36 1.05s 1.99s 5.73KB
minimalApplicationWithLoom Virtual 935.74 106.11ms 126.59ms 139.81KB

data source

The performance of non-blocking virtual threads varies, depending on whether blocking is a problem or not.

In general virtual threads are most beneficial in applications which spend a lot of time blocked on IO. How much they benefit your own application will need to be benchmarked and measured, but they are relatively easy to enable so you can run your own small benchmarks before deciding to use them or not.


About the Author: Haoyi is a software engineer, an early contributor to Scala.js, and the author of many open-source Scala tools such as Cask, the Ammonite REPL and FastParse.

If you've enjoy using Cask, or enjoyed using Haoyi's other open source libraries, please chip in (or get your Company to chip in!) via Patreon so he can continue his open-source work


Main Customization