From c0f39d743fbdf07544a6f5b6284d7123e5c36296 Mon Sep 17 00:00:00 2001 From: Li Haoyi Date: Sun, 12 Aug 2018 22:42:47 +0800 Subject: auto publishing of docs w/ example downloads works --- docs/build.sc | 198 +++++ docs/favicon.png | Bin 0 -> 206 bytes docs/logo-white.svg | 1 + docs/pageStyles.sc | 130 +++ docs/pages.sc | 192 +++++ .../1 - Cask: a Scala HTTP micro-framework .md | 927 +++++++++++++++++++++ docs/readme.md | 896 -------------------- 7 files changed, 1448 insertions(+), 896 deletions(-) create mode 100644 docs/build.sc create mode 100644 docs/favicon.png create mode 100644 docs/logo-white.svg create mode 100644 docs/pageStyles.sc create mode 100644 docs/pages.sc create mode 100644 docs/pages/1 - Cask: a Scala HTTP micro-framework .md delete mode 100644 docs/readme.md (limited to 'docs') diff --git a/docs/build.sc b/docs/build.sc new file mode 100644 index 0000000..48fd3e7 --- /dev/null +++ b/docs/build.sc @@ -0,0 +1,198 @@ +// Load dependencies +import $ivy.{`org.pegdown:pegdown:1.6.0`, `com.lihaoyi::scalatags:0.6.5`} +import $file.pageStyles, pageStyles._ +import $file.pages, pages._ +import scalatags.Text.all._ +import $file.^.version +import ammonite.ops._ +import collection.JavaConverters._ +import org.pegdown.{PegDownProcessor, ToHtmlSerializer, LinkRenderer, Extensions} +import org.pegdown.ast.{VerbatimNode, ExpImageNode, HeaderNode, TextNode, SimpleNode, TableNode} + +val (releaseTag, label) = version.publishVersion + +val postsFolder = cwd/'pages + +interp.watch(postsFolder) + +val targetFolder = cwd/'target + + +val (markdownFiles, otherFiles) = ls! postsFolder partition (_.ext == "md") +markdownFiles.foreach(println) +// Walk the posts/ folder and parse out the name, full- and first-paragraph- +// HTML of each post to be used on their respective pages and on the index + +val posts = { + val split = for(path <- markdownFiles) yield { + val Array(number, name) = path.last.split(" - ", 2) + (number, name.stripSuffix(".md"), path) + } + for ((index, name, path) <- split.sortBy(_._1.toInt)) yield { + val processor = new PegDownProcessor( + Extensions.FENCED_CODE_BLOCKS | Extensions.TABLES | Extensions.AUTOLINKS + ) + + val txt = read(path) + .replaceAll( + """\$\$\$([a-zA-Z_0-9]+)""", + s"[example project](https://github.com/lihaoyi/cask/releases/download/$releaseTag/$label.$$1)" + ) + + val ast = processor.parseMarkdown(txt.toArray) + val headers = collection.mutable.Buffer.empty[(String, Int)] + class Serializer extends ToHtmlSerializer(new LinkRenderer){ + override def printImageTag(rendering: LinkRenderer.Rendering) { + printer.print("
") + } + override def visit(node: HeaderNode) = { + val tag = "h" + node.getLevel() + + + val id = + node + .getChildren + .asScala + .collect{case t: TextNode => t.getText} + .mkString + + headers.append(id -> node.getLevel()) + + + val setId = s"id=${'"'+sanitize(id)+'"'}" + printer.print(s"""<$tag $setId class="${Styles.hoverBox.name}">""") + visitChildren(node) + printer.print( + a(href := ("#" + sanitize(id)), Styles.hoverLink)( + i(cls := "fa fa-link", aria.hidden := true) + ).render + ) + printer.print(s"") + } + + override def visit(node: VerbatimNode) = { + printer.println().print( + """
""" +
+            s""""""
+        )
+
+        var text = node.getText()
+        // print HTML breaks for all initial newlines
+        while(text.charAt(0) == '\n') {
+          printer.print("\n")
+          text = text.substring(1)
+        }
+        printer.printEncoded(text)
+        printer.print("
") + } + override def visit(node: TableNode) = { + currentTableNode = node + printer.print("") + visitChildren(node) + printer.print("
") + currentTableNode = null + } + } + + val postlude = Seq[Frag]( + hr, + + p( + b("About the Author:"), + i( + " Haoyi is a software engineer, an early contributor to ", + a(href:="http://www.scala-js.org/")("Scala.js"), + ", and the author of many open-source Scala tools such as Mill, the ", + a(href:="lihaoyi.com/Ammonite", "Ammonite REPL"), " and ", + a(href:="https://github.com/lihaoyi/fastparse", "FastParse"), ". " + ) + ), + p( + i( + "If you've enjoy using Mill, or enjoyed using Haoyi's other open ", + "source libraries, please chip in (or get your Company to chip in!) via ", + a(href:="https://www.patreon.com/lihaoyi", "Patreon"), " so he can ", "continue his open-source work" + ) + ), + hr + ).render + + val rawHtmlContent = new Serializer().toHtml(ast) + postlude + PostInfo(name, headers, rawHtmlContent) + } +} + +def formatRssDate(date: java.time.LocalDate) = { + date + .atTime(0, 0) + .atZone(java.time.ZoneId.of("UTC")) + .format(java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME) +} + +@main +def main(publish: Boolean = false) = { + + rm! targetFolder + + mkdir! targetFolder/'page + for(otherFile <- otherFiles){ + cp(otherFile, targetFolder/'page/(otherFile relativeTo postsFolder)) + } + + cp(pwd/"favicon.png", targetFolder/"favicon.ico") + cp(pwd/"logo-white.svg", targetFolder/"logo-white.svg") + + for(i <- posts.indices){ + val post = posts(i) + + val adjacentLinks = div(display.flex, flexDirection.row, justifyContent.spaceBetween)( + for((j, isNext) <- Seq(i-1 -> false, i+1 -> true)) + yield posts.lift(j) match{ + case None => div() + case Some(dest) => + renderAdjacentLink( + isNext, + dest.name, + (i == 0, j == 0) match { + case (true, true) => s"index.html" + case (true, false) => s"page/${sanitize(dest.name)}.html" + case (false, true) => s"../index.html" + case (false, false) => s"${sanitize(dest.name)}.html" + } + ) + } + ) + + + write( + if (i == 0) targetFolder / "index.html" + else targetFolder/'page/s"${sanitize(post.name)}.html", + postContent( + i == 0, + post, + adjacentLinks, + posts.map(_.name) + ) + ) + } + + if (publish){ + implicit val wd = cwd/'target + %git 'init + %git('add, "-A", ".") + %git('commit, "-am", "first commit") + %git('remote, 'add, 'origin, "git@github.com:lihaoyi/cask.git") + %git('push, "-uf", 'origin, "master:gh-pages") + } +} \ No newline at end of file diff --git a/docs/favicon.png b/docs/favicon.png new file mode 100644 index 0000000..82430a7 Binary files /dev/null and b/docs/favicon.png differ diff --git a/docs/logo-white.svg b/docs/logo-white.svg new file mode 100644 index 0000000..a681aa9 --- /dev/null +++ b/docs/logo-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/pageStyles.sc b/docs/pageStyles.sc new file mode 100644 index 0000000..06651b1 --- /dev/null +++ b/docs/pageStyles.sc @@ -0,0 +1,130 @@ +import $ivy.`com.lihaoyi::scalatags:0.6.5` + +import scalatags.stylesheet._ +import scalatags.Text.all._ + + +val marginWidth = "25%" +object WideStyles extends StyleSheet{ + initStyleSheet() + override def customSheetName = Some("WideStyles") + def header = cls( + position.fixed, + top := 0, + bottom := 0, + width := marginWidth, + justifyContent.center, + display.flex, + flexDirection.column + ) + def tableOfContentsItem = cls( + // We have to use inline-block and verticalAlign.middle and width: 100% + // here, instead of simply using display.block, because display.block items + // with overflow.hidden seem to misbehave and render badly in different ways + // between firefox (renders correctly), chrome (body of list item is offset + // one row from the bullet) and safari (bullet is entirely missing) + display.`inline-block`, + width := "100%", + verticalAlign.middle, + overflow.hidden, + textOverflow.ellipsis + + ) + def tableOfContents = cls( + display.flex, + flexDirection.column, + flexGrow := 1, + flexShrink := 1, + minHeight := 0, + width := "100%" + + ) + def content = cls( + padding := "2em 3em 0", + padding := 48, + marginLeft := marginWidth, + boxSizing.`border-box` + ) + def footer = cls( + position.fixed, + bottom := 0, + height := 50, + width := marginWidth + ) + def marginLeftZero = cls( + marginLeft := 0 + ) +} +object NarrowStyles extends StyleSheet{ + initStyleSheet() + override def customSheetName = Some("NarrowStyles") + def header = cls( + marginBottom := 10 + ) + def content = cls( + padding := 16 + ) + def headerContent = cls( + flexDirection.row, + width := "100%", + display.flex, + alignItems.center + ) + + def flexFont = cls( + fontSize := "4vw" + ) + def disappear = cls( + display.none + ) + def floatLeft = cls( + float.left, + marginLeft := 30 + ) +} +object Styles extends CascadingStyleSheet{ + initStyleSheet() + override def customSheetName = Some("Styles") + def hoverBox = cls( + display.flex, + flexDirection.row, + alignItems.center, + justifyContent.spaceBetween, + &hover( + hoverLink( + opacity := 0.5 + ) + ) + ) + def hoverLink = cls( + opacity := 0.1, + &hover( + opacity := 1.0 + ) + ) + def headerStyle = cls( + backgroundColor := "rgb(61, 79, 93)", + display.flex, + boxSizing.`border-box` + ) + def headerLinkBox = cls( + flex := 1, + display.flex, + flexDirection.column, + ) + def headerLink = cls( + flex := 1, + display.flex, + justifyContent.center, + alignItems.center, + padding := "10px 10px" + ) + def footerStyle = cls( + display.flex, + justifyContent.center, + color := "rgb(158, 167, 174)" + ) + def subtleLink = cls( + textDecoration.none + ) +} \ No newline at end of file diff --git a/docs/pages.sc b/docs/pages.sc new file mode 100644 index 0000000..a6c05c0 --- /dev/null +++ b/docs/pages.sc @@ -0,0 +1,192 @@ +import $ivy.`com.lihaoyi::scalatags:0.6.5` +import scalatags.Text.all._, scalatags.Text.tags2 +import java.time.LocalDate +import $file.pageStyles, pageStyles._ + +case class PostInfo(name: String, + headers: Seq[(String, Int)], + rawHtmlContent: String) + + +def sanitize(s: String): String = { + s.split(" |-", -1).map(_.filter(_.isLetterOrDigit)).mkString("-").toLowerCase +} +def pageChrome(titleText: Option[String], + homePage: Boolean, + contents: Frag, + contentHeaders: Seq[(String, Int)], + pageHeaders: Seq[String]): String = { + val pageTitle = titleText.getOrElse("Mill") + val sheets = Seq( + "https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css", + "https://maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css", + "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.1.0/styles/github-gist.min.css" + ) + + + html( + head( + meta(charset := "utf-8"), + for(sheet <- sheets) + yield link(href := sheet, rel := "stylesheet", `type` := "text/css" ), + tags2.title(pageTitle), + tags2.style(s"@media (min-width: 60em) {${WideStyles.styleSheetText}}"), + tags2.style(s"@media (max-width: 60em) {${NarrowStyles.styleSheetText}}"), + tags2.style(Styles.styleSheetText), + script(src:="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.1.0/highlight.min.js"), + script(src:="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.1.0/languages/scala.min.js"), + script(raw("hljs.initHighlightingOnLoad();")), + // This makes media queries work on iphone (???) + // http://stackoverflow.com/questions/13002731/responsive-design-media-query-not-working-on-iphone + meta(name:="viewport", content:="initial-scale = 1.0,maximum-scale = 1.0") + ), + body(margin := 0, backgroundColor := "#f8f8f8")( + navBar(homePage, contentHeaders, pageHeaders), + div( + WideStyles.content, + NarrowStyles.content, + maxWidth := 900, + titleText.map(h1(_)), + contents + ), + if (contentHeaders.nonEmpty) frag() + else div( + WideStyles.footer, + Styles.footerStyle, + "Last published ", currentTimeText + ) + + ) + ).render +} + +def navBar(homePage: Boolean, contentHeaders: Seq[(String, Int)], pageHeaders: Seq[String]) = { + def navList(navLabel: String, frags: Frag, narrowHide: Boolean) = { + div( + WideStyles.tableOfContents, + if(narrowHide) NarrowStyles.disappear else frag(), + color := "#f8f8f8" + )( + div(paddingLeft := 40, NarrowStyles.disappear)( + b(navLabel) + ), + div(overflowY.auto, flexShrink := 1, minHeight := 0)( + ul( + overflow.hidden, + textAlign.start, + marginTop := 10, + whiteSpace.nowrap, + textOverflow.ellipsis, + marginRight := 10 + )( + frags + ) + ) + ) + } + val pageList = navList( + "Pages", + for((header, i) <- pageHeaders.zipWithIndex) yield li( + WideStyles.marginLeftZero, + NarrowStyles.floatLeft + )( + a( + color := "#f8f8f8", + WideStyles.tableOfContentsItem, + href := { + (homePage, i == 0) match { + case (true, true) => s"index.html" + case (true, false) => s"page/${sanitize(header)}.html" + case (false, true) => s"../index.html" + case (false, false) => s"${sanitize(header)}.html" + } + } + )( + header + ) + ), + narrowHide = false + ) + + val headerBox = div( + NarrowStyles.headerContent, + h1( + textAlign.center, + a( + img( + src := {homePage match{ + case false => s"../logo-white.svg" + case true => "logo-white.svg" + }}, + height := 30, + marginTop := -5 + ), + color := "#f8f8f8", + " Mill", + href := (if (homePage) "" else ".."), + Styles.subtleLink, + NarrowStyles.flexFont, + fontWeight.bold + ), + padding := "30px 30px", + margin := 0 + ), + div( + Styles.headerLinkBox, + pageList + ) + ) + + + val tableOfContents = navList( + "Table of Contents", + for { + (header, indent) <- contentHeaders + offset <- indent match{ + case 2 => Some(0) + case 3 => Some(20) + case _ => None + } + } yield li(marginLeft := offset)( + a( + color := "#f8f8f8", + WideStyles.tableOfContentsItem, + href := s"#${sanitize(header)}" + )( + header + ) + ), + narrowHide = true + ) + + div( + WideStyles.header, + NarrowStyles.header, + Styles.headerStyle, + headerBox, + hr(NarrowStyles.disappear, backgroundColor := "#f8f8f8", width := "80%"), + tableOfContents + ) +} + + +val currentTimeText = LocalDate.now.toString + + +def renderAdjacentLink(next: Boolean, name: String, url: String) = { + a(href := url)( + if(next) frag(name, " ", i(cls:="fa fa-arrow-right" , aria.hidden:=true)) + else frag(i(cls:="fa fa-arrow-left" , aria.hidden:=true), " ", name) + ) +} +def postContent(homePage: Boolean, post: PostInfo, adjacentLinks: Frag, posts: Seq[String]) = pageChrome( + Some(post.name), + homePage, + Seq[Frag]( + div(adjacentLinks, marginBottom := 10), + raw(post.rawHtmlContent), + adjacentLinks + ), + post.headers, + pageHeaders = posts +) \ No newline at end of file diff --git a/docs/pages/1 - Cask: a Scala HTTP micro-framework .md b/docs/pages/1 - Cask: a Scala HTTP micro-framework .md new file mode 100644 index 0000000..22d879e --- /dev/null +++ b/docs/pages/1 - Cask: a Scala HTTP micro-framework .md @@ -0,0 +1,927 @@ +```scala +object MinimalApplication extends cask.MainRoutes{ + @cask.get("/") + def hello() = { + "Hello World!" + } + + @cask.post("/do-thing") + def doThing(request: cask.Request) = { + new String(request.data.readAllBytes()).reverse + } + + initialize() +} +``` + +[Cask](https://github.com/lihaoyi/cask) is a simple Scala web framework inspired +by Python's [Flask](http://flask.pocoo.org/docs/1.0/) 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 +[Mill](http://www.lihaoyi.com/mill/) example project: + +- Install [Mill](http://www.lihaoyi.com/mill/) +- Unzip the $$$minimalApplication into a folder. This should + give you the following files: +```text +build.sc +app/src/MinimalExample.scala +app/test/src/ExampleTests.scala +``` + +- `cd` into the folder, and run + +```bash +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](https://github.com/lihaoyi/requests-scala): + +```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: + +```bash +mill -w app.test +``` + +Cask is just a Scala library, and you can use Cask in any existing Scala project +via the following coordinates: + +```scala +// Mill +ivy"com.lihaoyi::cask:0.1.0" + +// SBT +"com.lihaoyi" %% "cask" % "0.1.0" +``` + +Example Projects +---------------- + +### Minimal Example + +```scala +object MinimalApplication extends cask.MainRoutes{ + @cask.get("/") + def hello() = { + "Hello World!" + } + + @cask.post("/do-thing") + def doThing(request: cask.Request) = { + new String(request.data.readAllBytes()).reverse + } + + initialize() +} +``` + +- $$$minimalApplication + +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 tale 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: + +```scala +object MinimalRoutes extends cask.Routes{ + @cask.get("/") + def hello() = { + "Hello World!" + } + + @cask.post("/do-thing") + def doThing(request: cask.Request) = { + new String(request.data.readAllBytes()).reverse + } + + initialize() +} + +object MinimalMain extends cask.Main(MinimalRoutes) +``` + +- $$$minimalApplication2 +- +You can split up your routes into separate `cask.Routes` objects as makes sense +and pass them all into `cask.Main`. + +### Variable Routes + + +```scala +object VariableRoutes extends cask.MainRoutes{ + @cask.get("/user/:userName") + def showUserProfile(userName: String) = { + s"User $userName" + } + + @cask.get("/post/:postId") + def showPost(postId: Int, param: Seq[String]) = { + s"Post $postId $param" + } + + @cask.get("/path", subpath = true) + def showSubpath(subPath: cask.Subpath) = { + s"Subpath ${subPath.value}" + } + + initialize() +} +``` + +- $$$variableRoutes + +You can bind variables to endpoints by declaring them as parameters: these are +either taken from a path-segment matcher of the same name (e.g. `postId` above), +or from query-parameters of the same name (e.g. `param` above). You can make +`param` take a `: String` to match `?param=hello`, an `: Int` for `?param=123` a +`Seq[T]` (as above) for repeated params such as `?param=hello¶m=world`, or +`: Option[T]` for cases where the `?param=hello` is optional. + +If you need to capture the entire sub-path of the request, you can set the flag +`subpath=true` and ask for a `: cask.Subpath` (the name of the param doesn't +matter). This will make the route match any sub-path of the prefix given to the +`@cask` decorator, and give you the remainder to use in your endpoint logic. + +### Receiving Form-encoded or JSON data + + +```scala +object FormJsonPost extends cask.MainRoutes{ + @cask.postJson("/json") + def jsonEndpoint(value1: ujson.Js.Value, value2: Seq[Int]) = { + "OK " + value1 + " " + value2 + } + + @cask.postForm("/form") + def formEndpoint(value1: cask.FormValue, value2: Seq[Int]) = { + "OK " + value1 + " " + value2 + } + + @cask.postForm("/upload") + def uploadFile(image: cask.FormFile) = { + image.fileName + } + + initialize() +} +``` + +- $$$formJsonPost + +If you need to handle a JSON-encoded POST request, you can use the +`@cast.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.Js.Value`s or deserialized into `Seq[Int]`s or other things. +Deserialization is handled using the +[uPickle](https://github.com/lihaoyi/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. + + +### Processing Cookies + + +```scala +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 + +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 + +```scala +object StaticFiles extends cask.MainRoutes{ + @cask.get("/") + def index() = { + "Hello!" + } + + @cask.static("/static") + def staticRoutes() = "cask/resources/cask" + + initialize() +} +``` + +- $$$staticFiles + +You can ask Cask to serve static files by defining a `@cask.static` 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). + +### Redirects or Aborts + +```scala +object RedirectAbort extends cask.MainRoutes{ + @cask.get("/") + def index() = { + cask.Redirect("/login") + } + + @cask.get("/login") + def login() = { + cask.Abort(401) + } + + initialize() +} +``` + +- $$$redirectAbort + +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. + +### Extending Endpoints with Decorators + + +```scala +object Decorated extends cask.MainRoutes{ + class User{ + override def toString = "[haoyi]" + } + class loggedIn extends cask.Decorator { + def wrapFunction(ctx: cask.ParamContext, delegate: Delegate): Returned = { + delegate(Map("user" -> new User())) + } + } + class withExtra extends cask.Decorator { + def wrapFunction(ctx: cask.ParamContext, delegate: Delegate): Returned = { + delegate(Map("extra" -> 31337)) + } + } + + @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 + } + + @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 + } + + initialize() +} +``` + +- $$$decorateds + +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 `ParamContext`, 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): + +```scala +object Decorated2 extends cask.MainRoutes{ + class User{ + override def toString = "[haoyi]" + } + class loggedIn extends cask.Decorator { + def wrapFunction(ctx: cask.ParamContext, delegate: Delegate): Returned = { + delegate(Map("user" -> new User())) + } + } + class withExtra extends cask.Decorator { + def wrapFunction(ctx: cask.ParamContext, delegate: Delegate): Returned = { + delegate(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) = { + world + user + } + + initialize() +} +``` + +- $$$decorated2 + +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. + +### Gzip & Deflated Responses + + +```scala +object Compress extends cask.MainRoutes{ + + @cask.decorators.compress + @cask.get("/") + def hello() = { + "Hello World! Hello World! Hello World!" + } + + initialize() +} + +``` + +- $$$compress + +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`: + +```scala +object Compress2 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(Compress2) +``` + +- $$$compress2 + +Or globally, in your `cask.Main`: + +```scala +object Compress3 extends cask.Routes{ + + @cask.get("/") + def hello() = { + "Hello World! Hello World! Hello World!" + } + + initialize() +} + +object Compress3Main extends cask.Main(Compress3){ + override def decorators = Seq(new cask.decorators.compress()) +} +``` + +- $$$compress3 + +### TodoMVC Api Server + + +```scala +object TodoMvcApi extends cask.MainRoutes{ + case class Todo(checked: Boolean, text: String) + object Todo{ + implicit def todoRW = 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, new String(request.data.readAllBytes()))) ++ 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() +} +``` + +- $$$todoApi + +This is a simple self-contained example of using Cask to write an in-memory API +server for the common [TodoMVC example app](http://todomvc.com/). + +This minimal example intentionally does not contain javascript, HTML, styles, +etc.. Those can be managed via the normal mechanism for +[Serving Static Files](#serving-static-files). + + +### TodoMVC Database Integration + +```scala +import cask.internal.Router +import com.typesafe.config.ConfigFactory +import io.getquill.{SqliteJdbcContext, SnakeCase} + +object TodoMvcDb extends cask.MainRoutes{ + val tmpDb = java.nio.file.Files.createTempDirectory("todo-cask-sqlite") + + object ctx extends SqliteJdbcContext( + SnakeCase, + ConfigFactory.parseString( + s"""{"driverClassName":"org.sqlite.JDBC","jdbcUrl":"jdbc:sqlite:$tmpDb/file.db"}""" + ) + ) + + class transactional extends cask.Decorator{ + class TransactionFailed(val value: Router.Result.Error) extends Exception + def wrapFunction(pctx: cask.ParamContext, delegate: Delegate): Returned = { + try ctx.transaction( + delegate(Map()) match{ + case Router.Result.Success(t) => Router.Result.Success(t) + case e: Router.Result.Error => throw new TransactionFailed(e) + } + ) + catch{case e: TransactionFailed => e.value} + + } + } + + case class Todo(id: Int, checked: Boolean, text: String) + object Todo{ + implicit def todoRW = upickle.default.macroRW[Todo] + } + + ctx.executeAction( + """CREATE TABLE todo ( + | id INTEGER PRIMARY KEY AUTOINCREMENT, + | checked BOOLEAN, + | text TEXT + |); + |""".stripMargin + ) + ctx.executeAction( + """INSERT INTO todo (checked, text) VALUES + |(1, 'Get started with Cask'), + |(0, 'Profit!'); + |""".stripMargin + ) + + import ctx._ + + @transactional + @cask.get("/list/:state") + def list(state: String) = { + val filteredTodos = state match{ + case "all" => run(query[Todo]) + case "active" => run(query[Todo].filter(!_.checked)) + case "completed" => run(query[Todo].filter(_.checked)) + } + upickle.default.write(filteredTodos) + } + + @transactional + @cask.post("/add") + def add(request: cask.Request) = { + val body = new String(request.data.readAllBytes()) + run(query[Todo].insert(_.checked -> lift(false), _.text -> lift(body)).returning(_.id)) + } + + @transactional + @cask.post("/toggle/:index") + def toggle(index: Int) = { + run(query[Todo].filter(_.id == lift(index)).update(p => p.checked -> !p.checked)) + } + + @transactional + @cask.post("/delete/:index") + def delete(index: Int) = { + run(query[Todo].filter(_.id == lift(index)).delete) + } + + initialize() +} + +``` + +- $$$todoDb + +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 +[Quill](http://getquill.io/) 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 Quill, 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](#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](https://github.com/lihaoyi/scalatags), Javascript for the +interactivity, static file serving, and database integration via +[Quill](https://github.com/getquill/quill). 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. + +```scala +import cask.internal.Router +import com.typesafe.config.ConfigFactory +import io.getquill.{SnakeCase, SqliteJdbcContext} +import scalatags.Text.all._ +import scalatags.Text.tags2 +object Server extends cask.MainRoutes{ + val tmpDb = java.nio.file.Files.createTempDirectory("todo-cask-sqlite") + + object ctx extends SqliteJdbcContext( + SnakeCase, + ConfigFactory.parseString( + s"""{"driverClassName":"org.sqlite.JDBC","jdbcUrl":"jdbc:sqlite:$tmpDb/file.db"}""" + ) + ) + + class transactional extends cask.Decorator{ + class TransactionFailed(val value: Router.Result.Error) extends Exception + def wrapFunction(pctx: cask.ParamContext, delegate: Delegate): Returned = { + try ctx.transaction( + delegate(Map()) match{ + case Router.Result.Success(t) => Router.Result.Success(t) + case e: Router.Result.Error => throw new TransactionFailed(e) + } + ) + catch{case e: TransactionFailed => e.value} + } + } + + case class Todo(id: Int, checked: Boolean, text: String) + + ctx.executeAction( + """CREATE TABLE todo ( + | id INTEGER PRIMARY KEY AUTOINCREMENT, + | checked BOOLEAN, + | text TEXT + |); + |""".stripMargin + ) + ctx.executeAction( + """INSERT INTO todo (checked, text) VALUES + |(1, 'Get started with Cask'), + |(0, 'Profit!'); + |""".stripMargin + ) + + import ctx._ + + @transactional + @cask.post("/list/:state") + def list(state: String) = renderBody(state).render + + @transactional + @cask.post("/add/:state") + def add(state: String, request: cask.Request) = { + val body = new String(request.data.readAllBytes()) + run(query[Todo].insert(_.checked -> lift(false), _.text -> lift(body)).returning(_.id)) + renderBody(state).render + } + + @transactional + @cask.post("/delete/:state/:index") + def delete(state: String, index: Int) = { + run(query[Todo].filter(_.id == lift(index)).delete) + renderBody(state).render + } + + @transactional + @cask.post("/toggle/:state/:index") + def toggle(state: String, index: Int) = { + run(query[Todo].filter(_.id == lift(index)).update(p => p.checked -> !p.checked)) + renderBody(state).render + } + + @transactional + @cask.post("/clear-completed/:state") + def clearCompleted(state: String) = { + run(query[Todo].filter(_.checked).delete) + renderBody(state).render + } + + @transactional + @cask.post("/toggle-all/:state") + def toggleAll(state: String) = { + val next = run(query[Todo].filter(_.checked).size) != 0 + run(query[Todo].update(_.checked -> !lift(next))) + renderBody(state).render + } + + def renderBody(state: String) = { + val filteredTodos = state match{ + case "all" => run(query[Todo]).sortBy(-_.id) + case "active" => run(query[Todo].filter(!_.checked)).sortBy(-_.id) + case "completed" => run(query[Todo].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 (run(query[Todo].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(run(query[Todo].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() = { + cask.Response( + "" + 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.static("/static") + def static() = "example/todo/resources/todo" + + initialize() +} +``` + +- $$$todo + +Main Customization +------------------ + +Apart from the code used to configure and define your routes and endpoints, Cask +also allows global configuration for things that apply to the entire web server. +This can be done by overriding the following methods on `cask.Main` or +`cask.MainRoutes`: + +### def debugMode: Boolean = true + +Makes the Cask report verbose error messages and stack traces if an endpoint +fails; useful for debugging, should be disabled for production. + +### def main + +The cask program entrypoint. By default just spins up a webserver, but you can +override it to do whatever you like before or after the webserver runs. + +### def defaultHandler + +Cask is built on top of the [Undertow](http://undertow.io/) web server. If you +need some low-level functionality not exposed by the Cask API, you can override +`defaultHandler` to make use of Undertow's own +[handler API](http://undertow.io/undertow-docs/undertow-docs-2.0.0/index.html#built-in-handlers) +for customizing your webserver. This allows for things that Cask itself doesn't +internally support: asynchronous requests & response, +[Websockets](http://undertow.io/undertow-docs/undertow-docs-2.0.0/index.html#websockets), +etc. + +### def port: Int = 8080, def host: String = "localhost" + +The host & port to attach your webserver to. + +### def handleNotFound + +The response to serve when the incoming request does not match any of the routes +or endpoints; defaults to a typical 404 + +### def handleEndpointError + +The response to serve when the incoming request matches a route and endpoint, +but then fails for other reasons. Defaults to 400 for mismatched or invalid +endpoint arguments and 500 for exceptions in the endpoint body, and provides +useful stack traces or metadata for debugging if `debugMode = true`. + +### def mainDecorators + +Any `cask.Decorator`s that you want to apply to all routes and all endpoints in +the entire web application \ No newline at end of file diff --git a/docs/readme.md b/docs/readme.md deleted file mode 100644 index 4add926..0000000 --- a/docs/readme.md +++ /dev/null @@ -1,896 +0,0 @@ -Cask: a Scala HTTP micro-framework -================================== - -```scala -object MinimalApplication extends cask.MainRoutes{ - @cask.get("/") - def hello() = { - "Hello World!" - } - - @cask.post("/do-thing") - def doThing(request: cask.Request) = { - new String(request.data.readAllBytes()).reverse - } - - initialize() -} -``` - -Cask is a simple Scala web framework inspired by Python's -[Flask](http://flask.pocoo.org/docs/1.0/) 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 -[Mill](http://www.lihaoyi.com/mill/) example project: - -- Install [Mill](http://www.lihaoyi.com/mill/) -- Unzip [XXX](XXX) into a folder. This should give you the following files: -```text -build.sc -app/src/MinimalExample.scala -app/test/src/ExampleTests.scala -``` - -- `cd` into the folder, and run - -```bash -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](https://github.com/lihaoyi/requests-scala): - -```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: - -```bash -mill -w app.test -``` - -Cask is just a Scala library, and you can use Cask in any existing Scala project -via the following coordinates: - -```scala -// Mill -ivy"com.lihaoyi::cask:0.1.0" - -// SBT -"com.lihaoyi" %% "cask" % "0.1.0" -``` - -Minimal Example ---------------- -```scala -object MinimalApplication extends cask.MainRoutes{ - @cask.get("/") - def hello() = { - "Hello World!" - } - - @cask.post("/do-thing") - def doThing(request: cask.Request) = { - new String(request.data.readAllBytes()).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 tale 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: - -```scala -object MinimalRoutes extends cask.Routes{ - @cask.get("/") - def hello() = { - "Hello World!" - } - - @cask.post("/do-thing") - def doThing(request: cask.Request) = { - new String(request.data.readAllBytes()).reverse - } - - initialize() -} - -object MinimalMain extends cask.Main(MinimalRoutes) -``` - -You can split up your routes into separate `cask.Routes` objects as makes sense -and pass them all into `cask.Main`. - -Variable Routes ---------------- - -```scala -object VariableRoutes extends cask.MainRoutes{ - @cask.get("/user/:userName") - def showUserProfile(userName: String) = { - s"User $userName" - } - - @cask.get("/post/:postId") - def showPost(postId: Int, param: Seq[String]) = { - s"Post $postId $param" - } - - @cask.get("/path", subpath = true) - def showSubpath(subPath: cask.Subpath) = { - s"Subpath ${subPath.value}" - } - - initialize() -} -``` - -You can bind variables to endpoints by declaring them as parameters: these are -either taken from a path-segment matcher of the same name (e.g. `postId` above), -or from query-parameters of the same name (e.g. `param` above). You can make -`param` take a `: String` to match `?param=hello`, an `: Int` for `?param=123` a -`Seq[T]` (as above) for repeated params such as `?param=hello¶m=world`, or -`: Option[T]` for cases where the `?param=hello` is optional. - -If you need to capture the entire sub-path of the request, you can set the flag -`subpath=true` and ask for a `: cask.Subpath` (the name of the param doesn't -matter). This will make the route match any sub-path of the prefix given to the -`@cask` decorator, and give you the remainder to use in your endpoint logic. - -Receiving Form-encoded or JSON data ------------------------------------ - -```scala -object FormJsonPost extends cask.MainRoutes{ - @cask.postJson("/json") - def jsonEndpoint(value1: ujson.Js.Value, value2: Seq[Int]) = { - "OK " + value1 + " " + value2 - } - - @cask.postForm("/form") - def formEndpoint(value1: cask.FormValue, value2: Seq[Int]) = { - "OK " + value1 + " " + value2 - } - - @cask.postForm("/upload") - def uploadFile(image: cask.FormFile) = { - image.fileName - } - - initialize() -} -``` - -If you need to handle a JSON-encoded POST request, you can use the -`@cast.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.Js.Value`s or deserialized into `Seq[Int]`s or other things. -Deserialization is handled using the -[uPickle](https://github.com/lihaoyi/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. - - -Processing Cookies ------------------- - -```scala -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 --------------------- -```scala -object StaticFiles extends cask.MainRoutes{ - @cask.get("/") - def index() = { - "Hello!" - } - - @cask.static("/static") - def staticRoutes() = "cask/resources/cask" - - initialize() -} -``` - -You can ask Cask to serve static files by defining a `@cask.static` 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). - -Redirects or Aborts -------------------- -```scala -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. - -Extending Endpoints with Decorators ------------------------------------ - -```scala -object Decorated extends cask.MainRoutes{ - class User{ - override def toString = "[haoyi]" - } - class loggedIn extends cask.Decorator { - def wrapFunction(ctx: cask.ParamContext, delegate: Delegate): Returned = { - delegate(Map("user" -> new User())) - } - } - class withExtra extends cask.Decorator { - def wrapFunction(ctx: cask.ParamContext, delegate: Delegate): Returned = { - delegate(Map("extra" -> 31337)) - } - } - - @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 - } - - @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 - } - - 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 `ParamContext`, 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): - -```scala -object Decorated2 extends cask.MainRoutes{ - class User{ - override def toString = "[haoyi]" - } - class loggedIn extends cask.Decorator { - def wrapFunction(ctx: cask.ParamContext, delegate: Delegate): Returned = { - delegate(Map("user" -> new User())) - } - } - class withExtra extends cask.Decorator { - def wrapFunction(ctx: cask.ParamContext, delegate: Delegate): Returned = { - delegate(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) = { - 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. - -Gzip & Deflated Responses -------------------------- - -```scala -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`: - -```scala -object Compress2 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(Compress2) -``` - -Or globally, in your `cask.Main`: - -```scala -object Compress3 extends cask.Routes{ - - @cask.get("/") - def hello() = { - "Hello World! Hello World! Hello World!" - } - - initialize() -} - -object Compress3Main extends cask.Main(Compress3){ - override def decorators = Seq(new cask.decorators.compress()) -} -``` - -TodoMVC Api Server ------------------- - -```scala -object TodoMvcApi extends cask.MainRoutes{ - case class Todo(checked: Boolean, text: String) - object Todo{ - implicit def todoRW = 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, new String(request.data.readAllBytes()))) ++ 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](http://todomvc.com/). - -This minimal example intentionally does not contain javascript, HTML, styles, -etc.. Those can be managed via the normal mechanism for -[Serving Static Files](#serving-static-files). - - -TodoMVC Database Integration ----------------------------- -```scala -import cask.internal.Router -import com.typesafe.config.ConfigFactory -import io.getquill.{SqliteJdbcContext, SnakeCase} - -object TodoMvcDb extends cask.MainRoutes{ - val tmpDb = java.nio.file.Files.createTempDirectory("todo-cask-sqlite") - - object ctx extends SqliteJdbcContext( - SnakeCase, - ConfigFactory.parseString( - s"""{"driverClassName":"org.sqlite.JDBC","jdbcUrl":"jdbc:sqlite:$tmpDb/file.db"}""" - ) - ) - - class transactional extends cask.Decorator{ - class TransactionFailed(val value: Router.Result.Error) extends Exception - def wrapFunction(pctx: cask.ParamContext, delegate: Delegate): Returned = { - try ctx.transaction( - delegate(Map()) match{ - case Router.Result.Success(t) => Router.Result.Success(t) - case e: Router.Result.Error => throw new TransactionFailed(e) - } - ) - catch{case e: TransactionFailed => e.value} - - } - } - - case class Todo(id: Int, checked: Boolean, text: String) - object Todo{ - implicit def todoRW = upickle.default.macroRW[Todo] - } - - ctx.executeAction( - """CREATE TABLE todo ( - | id INTEGER PRIMARY KEY AUTOINCREMENT, - | checked BOOLEAN, - | text TEXT - |); - |""".stripMargin - ) - ctx.executeAction( - """INSERT INTO todo (checked, text) VALUES - |(1, 'Get started with Cask'), - |(0, 'Profit!'); - |""".stripMargin - ) - - import ctx._ - - @transactional - @cask.get("/list/:state") - def list(state: String) = { - val filteredTodos = state match{ - case "all" => run(query[Todo]) - case "active" => run(query[Todo].filter(!_.checked)) - case "completed" => run(query[Todo].filter(_.checked)) - } - upickle.default.write(filteredTodos) - } - - @transactional - @cask.post("/add") - def add(request: cask.Request) = { - val body = new String(request.data.readAllBytes()) - run(query[Todo].insert(_.checked -> lift(false), _.text -> lift(body)).returning(_.id)) - } - - @transactional - @cask.post("/toggle/:index") - def toggle(index: Int) = { - run(query[Todo].filter(_.id == lift(index)).update(p => p.checked -> !p.checked)) - } - - @transactional - @cask.post("/delete/:index") - def delete(index: Int) = { - run(query[Todo].filter(_.id == lift(index)).delete) - } - - 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 -[Quill](http://getquill.io/) 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 Quill, 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](#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](https://github.com/lihaoyi/scalatags), Javascript for the -interactivity, static file serving, and database integration via -[Quill](https://github.com/getquill/quill). 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. - -```scala -import cask.internal.Router -import com.typesafe.config.ConfigFactory -import io.getquill.{SnakeCase, SqliteJdbcContext} -import scalatags.Text.all._ -import scalatags.Text.tags2 -object Server extends cask.MainRoutes{ - val tmpDb = java.nio.file.Files.createTempDirectory("todo-cask-sqlite") - - object ctx extends SqliteJdbcContext( - SnakeCase, - ConfigFactory.parseString( - s"""{"driverClassName":"org.sqlite.JDBC","jdbcUrl":"jdbc:sqlite:$tmpDb/file.db"}""" - ) - ) - - class transactional extends cask.Decorator{ - class TransactionFailed(val value: Router.Result.Error) extends Exception - def wrapFunction(pctx: cask.ParamContext, delegate: Delegate): Returned = { - try ctx.transaction( - delegate(Map()) match{ - case Router.Result.Success(t) => Router.Result.Success(t) - case e: Router.Result.Error => throw new TransactionFailed(e) - } - ) - catch{case e: TransactionFailed => e.value} - } - } - - case class Todo(id: Int, checked: Boolean, text: String) - - ctx.executeAction( - """CREATE TABLE todo ( - | id INTEGER PRIMARY KEY AUTOINCREMENT, - | checked BOOLEAN, - | text TEXT - |); - |""".stripMargin - ) - ctx.executeAction( - """INSERT INTO todo (checked, text) VALUES - |(1, 'Get started with Cask'), - |(0, 'Profit!'); - |""".stripMargin - ) - - import ctx._ - - @transactional - @cask.post("/list/:state") - def list(state: String) = renderBody(state).render - - @transactional - @cask.post("/add/:state") - def add(state: String, request: cask.Request) = { - val body = new String(request.data.readAllBytes()) - run(query[Todo].insert(_.checked -> lift(false), _.text -> lift(body)).returning(_.id)) - renderBody(state).render - } - - @transactional - @cask.post("/delete/:state/:index") - def delete(state: String, index: Int) = { - run(query[Todo].filter(_.id == lift(index)).delete) - renderBody(state).render - } - - @transactional - @cask.post("/toggle/:state/:index") - def toggle(state: String, index: Int) = { - run(query[Todo].filter(_.id == lift(index)).update(p => p.checked -> !p.checked)) - renderBody(state).render - } - - @transactional - @cask.post("/clear-completed/:state") - def clearCompleted(state: String) = { - run(query[Todo].filter(_.checked).delete) - renderBody(state).render - } - - @transactional - @cask.post("/toggle-all/:state") - def toggleAll(state: String) = { - val next = run(query[Todo].filter(_.checked).size) != 0 - run(query[Todo].update(_.checked -> !lift(next))) - renderBody(state).render - } - - def renderBody(state: String) = { - val filteredTodos = state match{ - case "all" => run(query[Todo]).sortBy(-_.id) - case "active" => run(query[Todo].filter(!_.checked)).sortBy(-_.id) - case "completed" => run(query[Todo].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 (run(query[Todo].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(run(query[Todo].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() = { - cask.Response( - "" + 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.static("/static") - def static() = "example/todo/resources/todo" - - initialize() -} -``` - -Main Customization ------------------- - -Apart from the code used to configure and define your routes and endpoints, Cask -also allows global configuration for things that apply to the entire web server. -This can be done by overriding the following methods on `cask.Main` or -`cask.MainRoutes`: - -### def debugMode: Boolean = true - -Makes the Cask report verbose error messages and stack traces if an endpoint -fails; useful for debugging, should be disabled for production. - -### def main - -The cask program entrypoint. By default just spins up a webserver, but you can -override it to do whatever you like before or after the webserver runs. - -### def defaultHandler - -Cask is built on top of the [Undertow](http://undertow.io/) web server. If you -need some low-level functionality not exposed by the Cask API, you can override -`defaultHandler` to make use of Undertow's own -[handler API](http://undertow.io/undertow-docs/undertow-docs-2.0.0/index.html#built-in-handlers) -for customizing your webserver. This allows for things that Cask itself doesn't -internally support: asynchronous requests & response, -[Websockets](http://undertow.io/undertow-docs/undertow-docs-2.0.0/index.html#websockets), -etc. - -### def port: Int = 8080, def host: String = "localhost" - -The host & port to attach your webserver to. - -### def handleNotFound - -The response to serve when the incoming request does not match any of the routes -or endpoints; defaults to a typical 404 - -### def handleEndpointError - -The response to serve when the incoming request matches a route and endpoint, -but then fails for other reasons. Defaults to 400 for mismatched or invalid -endpoint arguments and 500 for exceptions in the endpoint body, and provides -useful stack traces or metadata for debugging if `debugMode = true`. - -### def mainDecorators - -Any `cask.Decorator`s that you want to apply to all routes and all endpoints in -the entire web application \ No newline at end of file -- cgit v1.2.3