summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLi Haoyi <haoyi.sg@gmail.com>2018-07-28 19:38:43 +0800
committerLi Haoyi <haoyi.sg@gmail.com>2018-07-28 19:38:43 +0800
commit8b14cd206e008b4001f9b257f48870c8d40e8498 (patch)
treec335308af4e1ec2417368421bb97a5c56aaf686e
parent755eb8cd839ef1c34886db12056ac1aa56c0caaa (diff)
downloadcask-8b14cd206e008b4001f9b257f48870c8d40e8498.tar.gz
cask-8b14cd206e008b4001f9b257f48870c8d40e8498.tar.bz2
cask-8b14cd206e008b4001f9b257f48870c8d40e8498.zip
first pass at a readme
-rw-r--r--cask/src/cask/endpoints/FormEndpoint.scala35
-rw-r--r--cask/src/cask/model/Params.scala36
-rw-r--r--cask/src/cask/package.scala4
-rw-r--r--cask/test/src/test/cask/Decorated.scala2
-rw-r--r--cask/test/src/test/cask/ExampleTests.scala25
-rw-r--r--cask/test/src/test/cask/FormJsonPost.scala9
-rw-r--r--cask/test/src/test/cask/MultipartFileUploads.scala16
-rw-r--r--cask/test/src/test/cask/VariableRoutes.scala4
-rw-r--r--readme.md320
9 files changed, 387 insertions, 64 deletions
diff --git a/cask/src/cask/endpoints/FormEndpoint.scala b/cask/src/cask/endpoints/FormEndpoint.scala
index c58c765..715c803 100644
--- a/cask/src/cask/endpoints/FormEndpoint.scala
+++ b/cask/src/cask/endpoints/FormEndpoint.scala
@@ -2,42 +2,51 @@ package cask.endpoints
import cask.internal.{Router, Util}
import cask.main.{Endpoint, Routes}
-import cask.model.{FormValue, ParamContext, Response}
+import cask.model._
import io.undertow.server.handlers.form.FormParserFactory
import collection.JavaConverters._
-sealed trait FormReader[T] extends Router.ArgReader[Seq[FormValue], T, ParamContext]
+sealed trait FormReader[T] extends Router.ArgReader[Seq[FormEntry], T, ParamContext]
object FormReader{
implicit def paramFormReader[T: QueryParamReader] = new FormReader[T]{
def arity = implicitly[QueryParamReader[T]].arity
- def read(ctx: ParamContext, label: String, input: Seq[FormValue]) = {
- implicitly[QueryParamReader[T]].read(ctx, label, if (input == null) null else input.map(_.value))
+ def read(ctx: ParamContext, label: String, input: Seq[FormEntry]) = {
+ implicitly[QueryParamReader[T]].read(ctx, label, if (input == null) null else input.map(_.valueOrFileName))
}
}
+ implicit def formEntryReader = new FormReader[FormEntry]{
+ def arity = 1
+ def read(ctx: ParamContext, label: String, input: Seq[FormEntry]) = input.head
+ }
+ implicit def formEntriesReader = new FormReader[Seq[FormEntry]]{
+ def arity = 1
+ def read(ctx: ParamContext, label: String, input: Seq[FormEntry]) = input
+ }
+
implicit def formValueReader = new FormReader[FormValue]{
def arity = 1
- def read(ctx: ParamContext, label: String, input: Seq[FormValue]) = input.head
+ def read(ctx: ParamContext, label: String, input: Seq[FormEntry]) = input.head.asInstanceOf[FormValue]
}
implicit def formValuesReader = new FormReader[Seq[FormValue]]{
def arity = 1
- def read(ctx: ParamContext, label: String, input: Seq[FormValue]) = input
+ def read(ctx: ParamContext, label: String, input: Seq[FormEntry]) = input.map(_.asInstanceOf[FormValue])
}
- implicit def formValueFileReader = new FormReader[FormValue.File]{
+ implicit def formFileReader = new FormReader[FormFile]{
def arity = 1
- def read(ctx: ParamContext, label: String, input: Seq[FormValue]) = input.head.asFile.get
+ def read(ctx: ParamContext, label: String, input: Seq[FormEntry]) = input.head.asInstanceOf[FormFile]
}
- implicit def formValuesFileReader = new FormReader[Seq[FormValue.File]]{
+ implicit def formFilesReader = new FormReader[Seq[FormFile]]{
def arity = 1
- def read(ctx: ParamContext, label: String, input: Seq[FormValue]) = input.map(_.asFile.get)
+ def read(ctx: ParamContext, label: String, input: Seq[FormEntry]) = input.map(_.asInstanceOf[FormFile])
}
}
class postForm(val path: String, override val subpath: Boolean = false) extends Endpoint[Response]{
val methods = Seq("post")
- type Input = Seq[FormValue]
+ type Input = Seq[FormEntry]
type InputParser[T] = FormReader[T]
def getRawParams(ctx: ParamContext) = {
for{
@@ -51,11 +60,11 @@ class postForm(val path: String, override val subpath: Boolean = false) extends
formData
.iterator()
.asScala
- .map(k => (k, formData.get(k).asScala.map(FormValue.fromUndertow).toSeq))
+ .map(k => (k, formData.get(k).asScala.map(FormEntry.fromUndertow).toSeq))
.toMap
formDataBindings
}
}
- def wrapPathSegment(s: String): Input = Seq(FormValue.Plain(s, new io.undertow.util.HeaderMap))
+ def wrapPathSegment(s: String): Input = Seq(FormValue(s, new io.undertow.util.HeaderMap))
}
diff --git a/cask/src/cask/model/Params.scala b/cask/src/cask/model/Params.scala
index 8f206c8..901cca4 100644
--- a/cask/src/cask/model/Params.scala
+++ b/cask/src/cask/model/Params.scala
@@ -76,24 +76,28 @@ case class Cookie(name: String,
}
-object FormValue{
+sealed trait FormEntry{
+ def valueOrFileName: String
+ def headers: io.undertow.util.HeaderMap
+ def asFile: Option[FormFile] = this match{
+ case p: FormValue => None
+ case p: FormFile => Some(p)
+ }
+}
+object FormEntry{
def fromUndertow(from: io.undertow.server.handlers.form.FormData.FormValue) = {
- if (!from.isFile) Plain(from.getValue, from.getHeaders)
- else File(from.getValue, from.getFileName, from.getPath, from.getHeaders)
+ if (!from.isFile) FormValue(from.getValue, from.getHeaders)
+ else FormFile(from.getFileName, from.getPath, from.getHeaders)
}
- case class Plain(value: String,
- headers: io.undertow.util.HeaderMap) extends FormValue
- case class File(value: String,
- fileName: String,
- filePath: java.nio.file.Path,
- headers: io.undertow.util.HeaderMap) extends FormValue
}
-sealed trait FormValue{
- def value: String
- def headers: io.undertow.util.HeaderMap
- def asFile: Option[FormValue.File] = this match{
- case p: FormValue.Plain => None
- case p: FormValue.File => Some(p)
- }
+case class FormValue(value: String,
+ headers: io.undertow.util.HeaderMap) extends FormEntry{
+ def valueOrFileName = value
+}
+
+case class FormFile(fileName: String,
+ filePath: java.nio.file.Path,
+ headers: io.undertow.util.HeaderMap) extends FormEntry{
+ def valueOrFileName = fileName
}
diff --git a/cask/src/cask/package.scala b/cask/src/cask/package.scala
index 37e61e2..24a0a20 100644
--- a/cask/src/cask/package.scala
+++ b/cask/src/cask/package.scala
@@ -6,8 +6,12 @@ package object cask {
val Abort = model.Abort
type Redirect = model.Redirect
val Redirect = model.Redirect
+ type FormEntry = model.FormEntry
+ val FormEntry = model.FormEntry
type FormValue = model.FormValue
val FormValue = model.FormValue
+ type FormFile = model.FormFile
+ val FormFile = model.FormFile
type Cookie = model.Cookie
val Cookie = model.Cookie
type Subpath = model.Subpath
diff --git a/cask/test/src/test/cask/Decorated.scala b/cask/test/src/test/cask/Decorated.scala
index bd27647..ed377cf 100644
--- a/cask/test/src/test/cask/Decorated.scala
+++ b/cask/test/src/test/cask/Decorated.scala
@@ -1,6 +1,6 @@
package test.cask
-import cask.model.ParamContext
+import cask.model.ParamContext
object Decorated extends cask.MainRoutes{
class User{
diff --git a/cask/test/src/test/cask/ExampleTests.scala b/cask/test/src/test/cask/ExampleTests.scala
index e5be3bf..1a5cf23 100644
--- a/cask/test/src/test/cask/ExampleTests.scala
+++ b/cask/test/src/test/cask/ExampleTests.scala
@@ -38,17 +38,17 @@ object ExampleTests extends TestSuite{
requests.get(host + "/user").statusCode ==> 404
- requests.get(host + "/post/123?query=xyz&query=abc").text() ==>
+ requests.get(host + "/post/123?param=xyz&param=abc").text() ==>
"Post 123 ArrayBuffer(xyz, abc)"
requests.get(host + "/post/123").text() ==>
- """Missing argument: (query: Seq[String])
+ """Missing argument: (param: Seq[String])
|
|Arguments provided did not match expected signature:
|
|showPost
| postId Int
- | query Seq[String]
+ | param Seq[String]
|
|""".stripMargin
@@ -67,15 +67,6 @@ object ExampleTests extends TestSuite{
resp.history.get.statusCode ==> 301
}
- 'MultipartFileUploads - test(MultipartFileUploads){ host =>
- val resp = requests.post(
- host + "/upload",
- data = requests.MultiPart(
- requests.MultiItem("image", "...", "my-best-image.txt")
- )
- )
- resp.text() ==> "my-best-image.txt"
- }
'FormJsonPost - test(FormJsonPost){ host =>
requests.post(host + "/json", data = """{"value1": true, "value2": [3]}""").text() ==>
"OK true Vector(3)"
@@ -84,7 +75,15 @@ object ExampleTests extends TestSuite{
host + "/form",
data = Seq("value1" -> "hello", "value2" -> "1", "value2" -> "2")
).text() ==>
- "OK Plain(hello,null) List(1, 2)"
+ "OK FormValue(hello,null) List(1, 2)"
+
+ val resp = requests.post(
+ host + "/upload",
+ data = requests.MultiPart(
+ requests.MultiItem("image", "...", "my-best-image.txt")
+ )
+ )
+ resp.text() ==> "my-best-image.txt"
}
'Decorated - test(Decorated){ host =>
requests.get(host + "/hello/woo").text() ==> "woo31337"
diff --git a/cask/test/src/test/cask/FormJsonPost.scala b/cask/test/src/test/cask/FormJsonPost.scala
index 0be4480..9db3d24 100644
--- a/cask/test/src/test/cask/FormJsonPost.scala
+++ b/cask/test/src/test/cask/FormJsonPost.scala
@@ -1,7 +1,5 @@
package test.cask
-import cask.FormValue
-
object FormJsonPost extends cask.MainRoutes{
@cask.postJson("/json")
def jsonEndpoint(value1: ujson.Js.Value, value2: Seq[Int]) = {
@@ -9,10 +7,15 @@ object FormJsonPost extends cask.MainRoutes{
}
@cask.postForm("/form")
- def formEndpoint(value1: FormValue, value2: Seq[Int]) = {
+ def formEndpoint(value1: cask.FormValue, value2: Seq[Int]) = {
"OK " + value1 + " " + value2
}
+ @cask.postForm("/upload")
+ def uploadFile(image: cask.FormFile) = {
+ image.fileName
+ }
+
initialize()
}
diff --git a/cask/test/src/test/cask/MultipartFileUploads.scala b/cask/test/src/test/cask/MultipartFileUploads.scala
deleted file mode 100644
index 11b6ec6..0000000
--- a/cask/test/src/test/cask/MultipartFileUploads.scala
+++ /dev/null
@@ -1,16 +0,0 @@
-package test.cask
-
-import io.undertow.server.HttpServerExchange
-import io.undertow.server.handlers.form.FormData
-
-object MultipartFileUploads extends cask.MainRoutes{
- // curl -F "image=@build.sc" localhost:8080/upload
- @cask.post("/upload")
- def uploadFile(formData: FormData) = {
- val file = formData.getFirst("image")
- file.getFileName
- }
-
- initialize()
-}
-
diff --git a/cask/test/src/test/cask/VariableRoutes.scala b/cask/test/src/test/cask/VariableRoutes.scala
index d1816ab..c997d39 100644
--- a/cask/test/src/test/cask/VariableRoutes.scala
+++ b/cask/test/src/test/cask/VariableRoutes.scala
@@ -7,8 +7,8 @@ object VariableRoutes extends cask.MainRoutes{
}
@cask.get("/post/:postId")
- def showPost(postId: Int, query: Seq[String]) = {
- s"Post $postId $query"
+ def showPost(postId: Int, param: Seq[String]) = {
+ s"Post $postId $param"
}
@cask.get("/path", subpath = true)
diff --git a/readme.md b/readme.md
new file mode 100644
index 0000000..9e5060c
--- /dev/null
+++ b/readme.md
@@ -0,0 +1,320 @@
+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.
+
+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`
+or a `Seq[String]` (as above) for repeated params such as
+`?param=hello&param=world`.
+
+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
+import cask.model.ParamContext
+
+object Decorated extends cask.MainRoutes{
+ class User{
+ override def toString = "[haoyi]"
+ }
+ class loggedIn extends cask.Decorator {
+ def getRawParams(ctx: ParamContext) = Right(Map("user" -> new User()))
+ }
+ class withExtra extends cask.Decorator {
+ def getRawParams(ctx: ParamContext) = Right(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
+ }
+
+ initialize()
+}
+
+``` \ No newline at end of file