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.
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
cd
into the folder, and run./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.
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:
You define an object that inherits from cask.MainRoutes
Define endpoints using annotated functions, using @cask.get
or @cask.post
with the route they should match
Each function can return the data you want in the response, or a cask.Response
if you want further customization: response code, headers, etc.
Your function can take an optional cask.Request
, which exposes the entire incoming HTTP request if necessary. In the above example, we use it to read the request body into a string and return it reversed.
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
.
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:
:userName
above). This can be a String,
or other primitive types like Int
, Boolean
, Byte
, Short
, Long
, Float
, Double
segments: cask.RemainingPathSegments
, if you want to allow the endpoint to handle arbitrary sub-paths of the given pathpackage 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¶m=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:
param: String
to match ?param=hello
param: Int
for ?param=123
. Other valid types include Boolean
, Byte
, Short
, Long
, Float
, Double
param: Option[T] = None
or param: String = "DEFAULT VALUE"
for cases where the ?param=hello
is optional.param: Seq[T]
for repeated params such as ?param=hello¶m=world
with at least one valueparam: Seq[T] = Nil
for repeated params such as ?param=hello¶m=world
allowing zero valuesparams: cask.QueryParams
if you want your route to be able to handle arbitrary query params without needing to list them out as separate argumentsrequest: cask.Request
which provides lower level access to the things that the HTTP request provides
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
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.Value
s 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.FormValue
s) 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.
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)
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()
}
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.
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>
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
:
Receives a Request
, which basically gives you full access to the underlying undertow HTTP connection so you can pick out whatever data you would like
Returns an Either[Response, cask.Decor[Any]]
. Returning a Left
lets you bail out early with a fixed cask.Response
, avoiding further processing. Returning a Right
provides a map of parameter names and values that will then get passed to the endpoint function in consecutive parameter lists (shown above), as well as an optional cleanup function that is run after the endpoint terminates.
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:
Making an endpoint return a HTTP 403 if the user isn't logged in, but if they are logged in providing the : User
object to the body of the endpoint function
Rate-limiting users by returning early with a HTTP 429 if a user tries to access an endpoint too many times too quickly
Providing request-scoped values to the endpoint function: perhaps a database transaction that commits when the function succeeds (and rolls-back if it fails), or access to some system resource that needs to be released.
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.
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.Endpoint
s to replace the default set that Cask provides. This allows you to
Change the expected return type of the annotated function, and how allows you to that type gets processed: the above example trivially expects an allows you to Int
which becomes the status code, but you could make it e.g. automatically serialize returned objects to JSON responses via your favorite library, or serialize them to bytes via protobufs
Change where the first parameter list's params are taken from: @cask.get
takes them from query params, @cask.postForm
takes them from the form-encoded POST body, and you can write your own endpoint to take the params from where-ever you like: perhaps from the request headers, or a protobuf- encoded request body
Change how parameters are deserialized: e.g. @cask.postJson
de-serializes parameters using the uPickle JSON library, and your own custom endpoint could change that to use another library like Circe or Jackson
DRY up common sets of decorators: if all your endpoint functions use the same decorators, you can extract that functionality into a single cask.Endpoint
to do the job.
Generally you should not be writing custom cask.Endpoint
s 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.
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())
}
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.Future
s or other cask.BatchActor
s 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())
}
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.
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.
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()
}
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