diff options
author | Li Haoyi <haoyi.sg@gmail.com> | 2018-08-08 15:53:37 +0800 |
---|---|---|
committer | Li Haoyi <haoyi.sg@gmail.com> | 2018-08-08 18:24:18 +0800 |
commit | d85fd093539bdd7d8d432b058c2e2225eaa1ee2b (patch) | |
tree | 1b10e0cdcea08a51255152e259e446a75ec57ead | |
parent | a5320694193fd86b639c53a91fa24fb7f8ea914e (diff) | |
download | cask-d85fd093539bdd7d8d432b058c2e2225eaa1ee2b.tar.gz cask-d85fd093539bdd7d8d432b058c2e2225eaa1ee2b.tar.bz2 cask-d85fd093539bdd7d8d432b058c2e2225eaa1ee2b.zip |
Properly roll back transactions when endpoints fail in TodoMvcDb
-rw-r--r-- | cask/src/cask/endpoints/FormEndpoint.scala | 5 | ||||
-rw-r--r-- | cask/src/cask/endpoints/JsonEndpoint.scala | 5 | ||||
-rw-r--r-- | cask/src/cask/endpoints/StaticEndpoints.scala | 8 | ||||
-rw-r--r-- | cask/src/cask/endpoints/WebEndpoints.scala | 5 | ||||
-rw-r--r-- | cask/src/cask/internal/Router.scala | 4 | ||||
-rw-r--r-- | cask/src/cask/main/Decorators.scala | 67 | ||||
-rw-r--r-- | cask/src/cask/main/Main.scala | 29 | ||||
-rw-r--r-- | cask/src/cask/main/Routes.scala | 8 | ||||
-rw-r--r-- | cask/src/cask/package.scala | 4 | ||||
-rw-r--r-- | cask/test/src/test/cask/Decorated.scala | 9 | ||||
-rw-r--r-- | cask/test/src/test/cask/ExampleTests.scala | 86 | ||||
-rw-r--r-- | cask/test/src/test/cask/FailureTests.scala | 3 | ||||
-rw-r--r-- | cask/test/src/test/cask/MinimalApplication2.scala | 16 | ||||
-rw-r--r-- | cask/test/src/test/cask/TodoMvcDb.scala | 42 | ||||
-rw-r--r-- | readme.md | 149 |
15 files changed, 315 insertions, 125 deletions
diff --git a/cask/src/cask/endpoints/FormEndpoint.scala b/cask/src/cask/endpoints/FormEndpoint.scala index 4e8feb3..48190ce 100644 --- a/cask/src/cask/endpoints/FormEndpoint.scala +++ b/cask/src/cask/endpoints/FormEndpoint.scala @@ -43,12 +43,13 @@ object FormReader{ 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]{ +class postForm(val path: String, override val subpath: Boolean = false) extends Endpoint{ + type Output = Response val methods = Seq("post") type Input = Seq[FormEntry] type InputParser[T] = FormReader[T] - def wrapMethodOutput(ctx: ParamContext, + def wrapFunction(ctx: ParamContext, delegate: Map[String, Input] => Router.Result[Output]): Router.Result[Response] = { try { val formData = FormParserFactory.builder().build().createParser(ctx.exchange).parseBlocking() diff --git a/cask/src/cask/endpoints/JsonEndpoint.scala b/cask/src/cask/endpoints/JsonEndpoint.scala index 3c960d2..fdbbbec 100644 --- a/cask/src/cask/endpoints/JsonEndpoint.scala +++ b/cask/src/cask/endpoints/JsonEndpoint.scala @@ -24,12 +24,13 @@ object JsReader{ } } } -class postJson(val path: String, override val subpath: Boolean = false) extends Endpoint[Response]{ +class postJson(val path: String, override val subpath: Boolean = false) extends Endpoint{ + type Output = Response val methods = Seq("post") type Input = ujson.Js.Value type InputParser[T] = JsReader[T] - def wrapMethodOutput(ctx: ParamContext, + def wrapFunction(ctx: ParamContext, delegate: Map[String, Input] => Router.Result[Output]): Router.Result[Response] = { val obj = for{ str <- diff --git a/cask/src/cask/endpoints/StaticEndpoints.scala b/cask/src/cask/endpoints/StaticEndpoints.scala index 173cdac..048f89a 100644 --- a/cask/src/cask/endpoints/StaticEndpoints.scala +++ b/cask/src/cask/endpoints/StaticEndpoints.scala @@ -4,15 +4,13 @@ import cask.internal.Router import cask.main.Endpoint import cask.model.{Response, ParamContext} -class static(val path: String) extends Endpoint[String] { +class static(val path: String) extends Endpoint { + type Output = String val methods = Seq("get") type Input = Seq[String] type InputParser[T] = QueryParamReader[T] override def subpath = true - def wrapOutput(t: String) = t - - def wrapMethodOutput(ctx: ParamContext, - delegate: Map[String, Input] => Router.Result[String]): Router.Result[Response] = { + def wrapFunction(ctx: ParamContext, delegate: Delegate): Returned = { delegate(Map()) match{ case Router.Result.Success(t) => Router.Result.Success(cask.model.Static(t + "/" + ctx.remaining.mkString("/"))) case e: Router.Result.Error => e diff --git a/cask/src/cask/endpoints/WebEndpoints.scala b/cask/src/cask/endpoints/WebEndpoints.scala index c37a73a..70d16e0 100644 --- a/cask/src/cask/endpoints/WebEndpoints.scala +++ b/cask/src/cask/endpoints/WebEndpoints.scala @@ -7,10 +7,11 @@ import cask.model.{Response, ParamContext} import collection.JavaConverters._ -trait WebEndpoint extends Endpoint[Response]{ +trait WebEndpoint extends Endpoint{ + type Output = Response type Input = Seq[String] type InputParser[T] = QueryParamReader[T] - def wrapMethodOutput(ctx: ParamContext, + def wrapFunction(ctx: ParamContext, delegate: Map[String, Input] => Router.Result[Output]): Router.Result[Response] = { delegate( ctx.exchange.getQueryParameters diff --git a/cask/src/cask/internal/Router.scala b/cask/src/cask/internal/Router.scala index c831240..7ad0c18 100644 --- a/cask/src/cask/internal/Router.scala +++ b/cask/src/cask/internal/Router.scala @@ -197,7 +197,7 @@ class Router[C <: Context](val c: C) { def extractMethod(method: MethodSymbol, curCls: c.universe.Type, - wrapOutput: (c.Tree, c.Tree) => c.Tree, + convertToResultType: c.Tree, ctx: c.Type, argReaders: Seq[c.Tree], annotDeserializeTypes: Seq[c.Tree]): c.universe.Tree = { @@ -323,7 +323,7 @@ class Router[C <: Context](val c: C) { ) => cask.internal.Router.validate(Seq(..${readArgs.flatten.toList})) match{ case cask.internal.Router.Result.Success(Seq(..${argNames.flatten.toList})) => - ${wrapOutput(ctxSymbol, methodCall)} + cask.internal.Router.Result.Success($convertToResultType($methodCall)) case x: cask.internal.Router.Result.Error => x } ) diff --git a/cask/src/cask/main/Decorators.scala b/cask/src/cask/main/Decorators.scala index 462a369..1bd8867 100644 --- a/cask/src/cask/main/Decorators.scala +++ b/cask/src/cask/main/Decorators.scala @@ -4,48 +4,65 @@ import cask.internal.Router import cask.internal.Router.ArgReader import cask.model.{Response, ParamContext} - -trait Endpoint[R] extends BaseDecorator{ - - type Output = R +/** + * Used to annotate a single Cask endpoint function; similar to a [[Decorator]] + * but with additional metadata and capabilities. + */ +trait Endpoint extends BaseDecorator{ + /** + * What is the path that this particular endpoint matches? + */ val path: String + /** + * Which HTTP methods does this endpoint support? POST? GET? PUT? Or some + * combination of those? + */ val methods: Seq[String] + + /** + * Whether or not this endpoint allows matching on sub-paths: does + * `@endpoint("/foo")` capture the path "/foo/bar/baz"? Useful to e.g. have + * an endpoint match URLs with paths in a filesystem (real or virtual) to + * serve files + */ def subpath: Boolean = false - def wrapMethodOutput0(ctx: ParamContext, t: R): cask.internal.Router.Result[Any] = { - cask.internal.Router.Result.Success(t) - } - def wrapMethodOutput(ctx: ParamContext, - delegate: Map[String, Input] => Router.Result[Output]): Router.Result[Response] + def convertToResultType(t: Output): Output = t + /** + * [[Endpoint]]s are unique among decorators in that they alone can bind + * path segments to parameters, e.g. binding `/hello/:world` to `(world: Int)`. + * In order to do so, we need to box up the path segment strings into an + * [[Input]] so they can later be parsed by [[getParamParser]] into an + * instance of the appropriate type. + */ def wrapPathSegment(s: String): Input } -/** - * The core interface of decorator annotations: the decorator provides "raw" - * values to the annotated function via `getRawParams`, which then get - * processed by `getParamParser` into the correct argument types before - * being passed to the function. - * - * For a trivial "provide value" decorator, `getRawParams` would return the - * final param value and `getParamParser` would return a no-op parser. For - * a decorator that takes its input as query-params, JSON, or similar, - * `getRawParams` would provide raw query/JSON/etc. values and - * `getParamParser` would be responsible for processing those into the - * correct parameter types. - */ trait BaseDecorator{ type Input type InputParser[T] <: ArgReader[Input, T, ParamContext] type Output - def wrapMethodOutput(ctx: ParamContext, - delegate: Map[String, Input] => Router.Result[Output]): Router.Result[Response] + type Delegate = Map[String, Input] => Router.Result[Output] + type Returned = Router.Result[Response] + def wrapFunction(ctx: ParamContext, delegate: Delegate): Returned def getParamParser[T](implicit p: InputParser[T]) = p } - +/** + * A decorator allows you to annotate a function to wrap it, via + * `wrapFunction`. You can use this to perform additional validation before or + * after the function runs, provide an additional parameter list of params, + * open/commit/rollback database transactions before/after the function runs, + * or even retrying the wrapped function if it fails. + * + * Calls to the wrapped function are done on the `delegate` parameter passed + * to `wrapFunction`, which takes a `Map` representing any additional argument + * lists (if any). + */ trait Decorator extends BaseDecorator { + type Input = Any type Output = Response type InputParser[T] = NoOpParser[Input, T] diff --git a/cask/src/cask/main/Main.scala b/cask/src/cask/main/Main.scala index 65655fc..5558a08 100644 --- a/cask/src/cask/main/Main.scala +++ b/cask/src/cask/main/Main.scala @@ -58,17 +58,25 @@ abstract class BaseMain{ case Some(((routes, metadata), extBindings, remaining)) => val ctx = ParamContext(exchange, remaining) def rec(remaining: List[Decorator], - bindings: List[Map[String, Any]]): Router.Result[Response] = remaining match{ - case head :: rest => head.wrapMethodOutput(ctx, args => rec(rest, args :: bindings)) - case Nil => - metadata.endpoint.wrapMethodOutput(ctx, epBindings => - metadata.entryPoint - .asInstanceOf[EntryPoint[cask.main.Routes, cask.model.ParamContext]] - .invoke(routes, ctx, (epBindings ++ extBindings.mapValues(metadata.endpoint.wrapPathSegment)) :: bindings.reverse) - .asInstanceOf[Router.Result[Nothing]] - ) + bindings: List[Map[String, Any]]): Router.Result[Response] = try { + remaining match { + case head :: rest => + head.wrapFunction(ctx, args => rec(rest, args :: bindings)) + + case Nil => + metadata.endpoint.wrapFunction(ctx, epBindings => + metadata.entryPoint + .asInstanceOf[EntryPoint[cask.main.Routes, cask.model.ParamContext]] + .invoke(routes, ctx, (epBindings ++ extBindings.mapValues(metadata.endpoint.wrapPathSegment)) :: bindings.reverse) + .asInstanceOf[Router.Result[Nothing]] + ) + + } + // Make sure we wrap any exceptions that bubble up from decorator + // bodies, so outer decorators do not need to worry about their + // delegate throwing on them + }catch{case e: Throwable => Router.Result.Error.Exception(e) } - } rec(metadata.decorators.toList, Nil)match{ case Router.Result.Success(response: Response) => writeResponse(exchange, response) case e: Router.Result.Error => @@ -88,6 +96,7 @@ abstract class BaseMain{ } } + def main(args: Array[String]): Unit = { val server = Undertow.builder .addHttpListener(port, host) diff --git a/cask/src/cask/main/Routes.scala b/cask/src/cask/main/Routes.scala index d26641e..0dea657 100644 --- a/cask/src/cask/main/Routes.scala +++ b/cask/src/cask/main/Routes.scala @@ -8,7 +8,7 @@ import language.experimental.macros object Routes{ case class EndpointMetadata[T](decorators: Seq[Decorator], - endpoint: Endpoint[_], + endpoint: Endpoint, entryPoint: EntryPoint[T, ParamContext]) case class RoutesEndpointsMetadata[T](value: EndpointMetadata[T]*) object RoutesEndpointsMetadata{ @@ -22,12 +22,12 @@ object Routes{ val annotations = m.annotations.filter(_.tree.tpe <:< c.weakTypeOf[BaseDecorator]).reverse if annotations.nonEmpty } yield { - if(!(annotations.head.tree.tpe <:< weakTypeOf[Endpoint[_]])) c.abort( + if(!(annotations.head.tree.tpe <:< weakTypeOf[Endpoint])) c.abort( annotations.head.tree.pos, s"Last annotation applied to a function must be an instance of Endpoint, " + s"not ${annotations.head.tree.tpe}" ) - val allEndpoints = annotations.filter(_.tree.tpe <:< weakTypeOf[Endpoint[_]]) + val allEndpoints = annotations.filter(_.tree.tpe <:< weakTypeOf[Endpoint]) if(allEndpoints.length > 1) c.abort( annotations.head.tree.pos, s"You can only apply one Endpoint annotation to a function, not " + @@ -42,7 +42,7 @@ object Routes{ val route = router.extractMethod( m.asInstanceOf[MethodSymbol], weakTypeOf[T], - (ctx: c.Tree, t: c.Tree) => q"${annotObjectSyms.head}.wrapMethodOutput0($ctx, $t)", + q"${annotObjectSyms.head}.convertToResultType", c.weakTypeOf[ParamContext], annotObjectSyms.map(annotObjectSym => q"$annotObjectSym.getParamParser"), annotObjectSyms.map(annotObjectSym => tq"$annotObjectSym.Input") diff --git a/cask/src/cask/package.scala b/cask/src/cask/package.scala index 24a0a20..19dc675 100644 --- a/cask/src/cask/package.scala +++ b/cask/src/cask/package.scala @@ -18,6 +18,8 @@ package object cask { val Subpath = model.Subpath type Request = model.Request val Request = model.Request + type ParamContext = model.ParamContext + val ParamContext = model.ParamContext // endpoints type get = endpoints.get @@ -34,7 +36,7 @@ package object cask { val Routes = main.Routes type Main = main.Main type Decorator = main.Decorator - type Endpoint[R] = main.Endpoint[R] + type Endpoint = main.Endpoint type BaseDecorator = main.BaseDecorator } diff --git a/cask/test/src/test/cask/Decorated.scala b/cask/test/src/test/cask/Decorated.scala index 9fac78a..d7cb6b8 100644 --- a/cask/test/src/test/cask/Decorated.scala +++ b/cask/test/src/test/cask/Decorated.scala @@ -1,21 +1,16 @@ package test.cask -import cask.internal.Router -import cask.model.{ParamContext, Response} - object Decorated extends cask.MainRoutes{ class User{ override def toString = "[haoyi]" } class loggedIn extends cask.Decorator { - def wrapMethodOutput(ctx: ParamContext, - delegate: Map[String, Input] => Router.Result[Output]): Router.Result[Response] = { + def wrapFunction(ctx: cask.ParamContext, delegate: Delegate): Returned = { delegate(Map("user" -> new User())) } } class withExtra extends cask.Decorator { - def wrapMethodOutput(ctx: ParamContext, - delegate: Map[String, Input] => Router.Result[Output]): Router.Result[Response] = { + def wrapFunction(ctx: cask.ParamContext, delegate: Delegate): Returned = { delegate(Map("extra" -> 31337)) } } diff --git a/cask/test/src/test/cask/ExampleTests.scala b/cask/test/src/test/cask/ExampleTests.scala index 6858051..36e0387 100644 --- a/cask/test/src/test/cask/ExampleTests.scala +++ b/cask/test/src/test/cask/ExampleTests.scala @@ -4,7 +4,7 @@ import io.undertow.server.handlers.BlockingHandler import utest._ object ExampleTests extends TestSuite{ - def test[T](example: cask.main.MainRoutes)(f: String => T): T = { + def test[T](example: cask.main.BaseMain)(f: String => T): T = { val server = Undertow.builder .addHttpListener(8080, "localhost") .setHandler(new BlockingHandler(example.defaultHandler)) @@ -23,25 +23,37 @@ object ExampleTests extends TestSuite{ success.text() ==> "Hello World!" success.statusCode ==> 200 - requests.get(host + "/doesnt-exist").statusCode ==> 404 + requests.get(s"$host/doesnt-exist").statusCode ==> 404 - requests.post(host + "/do-thing", data = "hello").text() ==> "olleh" + requests.post(s"$host/do-thing", data = "hello").text() ==> "olleh" - requests.get(host + "/do-thing").statusCode ==> 404 + requests.get(s"$host/do-thing").statusCode ==> 404 + } + 'MinimalApplication2 - test(MinimalMain){ host => + val success = requests.get(host) + + success.text() ==> "Hello World!" + success.statusCode ==> 200 + + requests.get(s"$host/doesnt-exist").statusCode ==> 404 + + requests.post(s"$host/do-thing", data = "hello").text() ==> "olleh" + + requests.get(s"$host/do-thing").statusCode ==> 404 } 'VariableRoutes - test(VariableRoutes){ host => val noIndexPage = requests.get(host) noIndexPage.statusCode ==> 404 - requests.get(host + "/user/lihaoyi").text() ==> "User lihaoyi" + requests.get(s"$host/user/lihaoyi").text() ==> "User lihaoyi" - requests.get(host + "/user").statusCode ==> 404 + requests.get(s"$host/user").statusCode ==> 404 - requests.get(host + "/post/123?param=xyz¶m=abc").text() ==> + requests.get(s"$host/post/123?param=xyz¶m=abc").text() ==> "Post 123 ArrayBuffer(xyz, abc)" - requests.get(host + "/post/123").text() ==> + requests.get(s"$host/post/123").text() ==> """Missing argument: (param: Seq[String]) | |Arguments provided did not match expected signature: @@ -52,33 +64,33 @@ object ExampleTests extends TestSuite{ | |""".stripMargin - requests.get(host + "/path/one/two/three").text() ==> + requests.get(s"$host/path/one/two/three").text() ==> "Subpath List(one, two, three)" } 'StaticFiles - test(StaticFiles){ host => - requests.get(host + "/static/example.txt").text() ==> + requests.get(s"$host/static/example.txt").text() ==> "the quick brown fox jumps over the lazy dog" } 'RedirectAbort - test(RedirectAbort){ host => - val resp = requests.get(host + "/") + val resp = requests.get(s"$host/") resp.statusCode ==> 401 resp.history.get.statusCode ==> 301 } 'FormJsonPost - test(FormJsonPost){ host => - requests.post(host + "/json", data = """{"value1": true, "value2": [3]}""").text() ==> + requests.post(s"$host/json", data = """{"value1": true, "value2": [3]}""").text() ==> "OK true Vector(3)" requests.post( - host + "/form", + s"$host/form", data = Seq("value1" -> "hello", "value2" -> "1", "value2" -> "2") ).text() ==> "OK FormValue(hello,null) List(1, 2)" val resp = requests.post( - host + "/upload", + s"$host/upload", data = requests.MultiPart( requests.MultiItem("image", "...", "my-best-image.txt") ) @@ -86,61 +98,61 @@ object ExampleTests extends TestSuite{ resp.text() ==> "my-best-image.txt" } 'Decorated - test(Decorated){ host => - requests.get(host + "/hello/woo").text() ==> "woo31337" - requests.get(host + "/internal/boo").text() ==> "boo[haoyi]" - requests.get(host + "/internal-extra/goo").text() ==> "goo[haoyi]31337" + requests.get(s"$host/hello/woo").text() ==> "woo31337" + requests.get(s"$host/internal/boo").text() ==> "boo[haoyi]" + requests.get(s"$host/internal-extra/goo").text() ==> "goo[haoyi]31337" } 'TodoMvcApi - test(TodoMvcApi){ host => - requests.get(host + "/list/all").text() ==> + requests.get(s"$host/list/all").text() ==> """[{"checked":true,"text":"Get started with Cask"},{"checked":false,"text":"Profit!"}]""" - requests.get(host + "/list/active").text() ==> + requests.get(s"$host/list/active").text() ==> """[{"checked":false,"text":"Profit!"}]""" - requests.get(host + "/list/completed").text() ==> + requests.get(s"$host/list/completed").text() ==> """[{"checked":true,"text":"Get started with Cask"}]""" - requests.post(host + "/toggle/1") + requests.post(s"$host/toggle/1") - requests.get(host + "/list/all").text() ==> + requests.get(s"$host/list/all").text() ==> """[{"checked":true,"text":"Get started with Cask"},{"checked":true,"text":"Profit!"}]""" - requests.get(host + "/list/active").text() ==> + requests.get(s"$host/list/active").text() ==> """[]""" - requests.post(host + "/add", data = "new Task") + requests.post(s"$host/add", data = "new Task") - requests.get(host + "/list/active").text() ==> + requests.get(s"$host/list/active").text() ==> """[{"checked":false,"text":"new Task"}]""" - requests.post(host + "/delete/0") + requests.post(s"$host/delete/0") - requests.get(host + "/list/active").text() ==> + requests.get(s"$host/list/active").text() ==> """[]""" } 'TodoMvcDb - test(TodoMvcDb){ host => - requests.get(host + "/list/all").text() ==> + requests.get(s"$host/list/all").text() ==> """[{"id":1,"checked":true,"text":"Get started with Cask"},{"id":2,"checked":false,"text":"Profit!"}]""" - requests.get(host + "/list/active").text() ==> + requests.get(s"$host/list/active").text() ==> """[{"id":2,"checked":false,"text":"Profit!"}]""" - requests.get(host + "/list/completed").text() ==> + requests.get(s"$host/list/completed").text() ==> """[{"id":1,"checked":true,"text":"Get started with Cask"}]""" - requests.post(host + "/toggle/2") + requests.post(s"$host/toggle/2") - requests.get(host + "/list/all").text() ==> + requests.get(s"$host/list/all").text() ==> """[{"id":1,"checked":true,"text":"Get started with Cask"},{"id":2,"checked":true,"text":"Profit!"}]""" - requests.get(host + "/list/active").text() ==> + requests.get(s"$host/list/active").text() ==> """[]""" - requests.post(host + "/add", data = "new Task") + requests.post(s"$host/add", data = "new Task") - requests.get(host + "/list/active").text() ==> + requests.get(s"$host/list/active").text() ==> """[{"id":3,"checked":false,"text":"new Task"}]""" - requests.post(host + "/delete/3") + requests.post(s"$host/delete/3") - requests.get(host + "/list/active").text() ==> + requests.get(s"$host/list/active").text() ==> """[]""" } } diff --git a/cask/test/src/test/cask/FailureTests.scala b/cask/test/src/test/cask/FailureTests.scala index de3b438..9e28c0b 100644 --- a/cask/test/src/test/cask/FailureTests.scala +++ b/cask/test/src/test/cask/FailureTests.scala @@ -6,8 +6,7 @@ import utest._ object FailureTests extends TestSuite { class myDecorator extends cask.Decorator { - def wrapMethodOutput(ctx: ParamContext, - delegate: Map[String, Input] => Router.Result[Output]): Router.Result[Response] = { + def wrapFunction(ctx: ParamContext, delegate: Delegate): Returned = { delegate(Map("extra" -> 31337)) } } diff --git a/cask/test/src/test/cask/MinimalApplication2.scala b/cask/test/src/test/cask/MinimalApplication2.scala new file mode 100644 index 0000000..924b00f --- /dev/null +++ b/cask/test/src/test/cask/MinimalApplication2.scala @@ -0,0 +1,16 @@ +package test.cask + +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)
\ No newline at end of file diff --git a/cask/test/src/test/cask/TodoMvcDb.scala b/cask/test/src/test/cask/TodoMvcDb.scala index c6f8191..b352d1a 100644 --- a/cask/test/src/test/cask/TodoMvcDb.scala +++ b/cask/test/src/test/cask/TodoMvcDb.scala @@ -1,32 +1,39 @@ package test.cask + import cask.internal.Router -import cask.model.{ParamContext, Response} import com.typesafe.config.ConfigFactory -import io.getquill._ +import io.getquill.{SqliteJdbcContext, SnakeCase} object TodoMvcDb extends cask.MainRoutes{ - case class Todo(id: Int, checked: Boolean, text: String) - object Todo{ - implicit def todoRW = upickle.default.macroRW[Todo] - } + 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"}""" ) ) - val tmpDb = java.nio.file.Files.createTempDirectory("todo-cask-sqlite") - - import ctx._ class transactional extends cask.Decorator{ - def wrapMethodOutput(pctx: ParamContext, - delegate: Map[String, Input] => Router.Result[Response]): Router.Result[Response] = { - ctx.transaction(delegate(Map("ctx" -> ctx))) + 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, @@ -42,9 +49,11 @@ object TodoMvcDb extends cask.MainRoutes{ |""".stripMargin ) + import ctx._ + @transactional @cask.get("/list/:state") - def list(state: String)(ctx: SqliteJdbcContext[_]) = { + def list(state: String) = { val filteredTodos = state match{ case "all" => run(query[Todo]) case "active" => run(query[Todo].filter(!_.checked)) @@ -55,22 +64,21 @@ object TodoMvcDb extends cask.MainRoutes{ @transactional @cask.post("/add") - def add(request: cask.Request)(ctx: SqliteJdbcContext[_]) = { + 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)(ctx: SqliteJdbcContext[_]) = { + 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)(ctx: SqliteJdbcContext[_]) = { + def delete(index: Int) = { run(query[Todo].filter(_.id == lift(index)).delete) - } initialize() @@ -119,6 +119,32 @@ 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 --------------- @@ -283,17 +309,19 @@ 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(cask.Decor("user" -> new User())) + def wrapFunction(ctx: cask.ParamContext, delegate: Delegate): Returned = { + delegate(Map("user" -> new User())) + } } class withExtra extends cask.Decorator { - def getRawParams(ctx: ParamContext) = Right(cask.Decor("extra" -> 31337)) + def wrapFunction(ctx: cask.ParamContext, delegate: Delegate): Returned = { + delegate(Map("extra" -> 31337)) + } } @withExtra() @@ -358,9 +386,6 @@ Decorators are useful for things like: transaction that commits when the function succeeds (and rolls-back if it fails), or access to some system resource that needs to be released. -Writing Custom Endpoints ------------------------- - TodoMVC Api Server ------------------ @@ -404,9 +429,115 @@ object TodoMvcApi extends cask.MainRoutes{ } ``` -This is a simple self-contained example of using Cask to write an API server for -the common [TodoMVC example app](http://todomvc.com/). +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).
\ No newline at end of file |