diff options
-rw-r--r-- | cask/src/cask/endpoints/FormEndpoint.scala | 35 | ||||
-rw-r--r-- | cask/src/cask/model/Params.scala | 36 | ||||
-rw-r--r-- | cask/src/cask/package.scala | 4 | ||||
-rw-r--r-- | cask/test/src/test/cask/Decorated.scala | 2 | ||||
-rw-r--r-- | cask/test/src/test/cask/ExampleTests.scala | 25 | ||||
-rw-r--r-- | cask/test/src/test/cask/FormJsonPost.scala | 9 | ||||
-rw-r--r-- | cask/test/src/test/cask/MultipartFileUploads.scala | 16 | ||||
-rw-r--r-- | cask/test/src/test/cask/VariableRoutes.scala | 4 | ||||
-rw-r--r-- | readme.md | 320 |
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¶m=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¶m=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 |