瀏覽代碼

Major refactoring and testing

Refactored core logic to be more composable, allowing for more easily adding extensions.
This includes splitting any Play! specific logic into its own plugin.

Added integration testing to Play plugin.  Reviews both a basic case and a more complex,
realisitic case.

Added a script to automatically run tests.
Thomas Flucke 5 年之前
父節點
當前提交
1b8170ed52
共有 84 個文件被更改,包括 3512 次插入224 次删除
  1. 3 0
      .gitignore
  2. 46 22
      README.md
  3. 49 26
      build.sbt
  4. 75 0
      library/src/main/scala/com/tflucke/webroutes/APIRoute.scala
  5. 20 0
      library/src/main/scala/com/tflucke/webroutes/HTTPException.scala
  6. 10 0
      library/src/main/scala/com/tflucke/webroutes/Headers.scala
  7. 8 0
      library/src/main/scala/com/tflucke/webroutes/TimeoutException.scala
  8. 0 20
      library/src/main/scala/name/tflucke/webroutes/APIRoute.scala
  9. 39 0
      play/src/main/scala/com/tflucke/webroutes/RestRPC.scala
  10. 22 0
      play/src/main/scala/com/tflucke/webroutes/endpoints/PlayEndpointFile.scala
  11. 8 0
      play/src/main/scala/com/tflucke/webroutes/formatter/PlayJsonFormatter.scala
  12. 79 0
      play/src/main/scala/com/tflucke/webroutes/parsers/PlayParser.scala
  13. 14 0
      play/src/sbt-test/webroutes/dummy/README.md
  14. 38 0
      play/src/sbt-test/webroutes/dummy/build.sbt
  15. 89 0
      play/src/sbt-test/webroutes/dummy/client/src/main/scala/org/sample/dummy/Main.scala
  16. 23 0
      play/src/sbt-test/webroutes/dummy/client/src/test/scala/org/sample/dummy/PojoControllerTest.scala
  17. 1 0
      play/src/sbt-test/webroutes/dummy/project/build.properties
  18. 9 0
      play/src/sbt-test/webroutes/dummy/project/plugins.sbt
  19. 73 0
      play/src/sbt-test/webroutes/dummy/server/app/org/sample/dummy/controllers/PojoController.scala
  20. 26 0
      play/src/sbt-test/webroutes/dummy/server/app/org/sample/dummy/controllers/ViewController.scala
  21. 25 0
      play/src/sbt-test/webroutes/dummy/server/app/views/index.scala.html
  22. 24 0
      play/src/sbt-test/webroutes/dummy/server/app/views/main.scala.html
  23. 41 0
      play/src/sbt-test/webroutes/dummy/server/conf/application.conf
  24. 43 0
      play/src/sbt-test/webroutes/dummy/server/conf/logback.xml
  25. 1 0
      play/src/sbt-test/webroutes/dummy/server/conf/messages
  26. 44 0
      play/src/sbt-test/webroutes/dummy/server/conf/routes
  27. 二進制
      play/src/sbt-test/webroutes/dummy/server/public/images/favicon.png
  28. 0 0
      play/src/sbt-test/webroutes/dummy/server/public/stylesheets/main.css
  29. 26 0
      play/src/sbt-test/webroutes/dummy/server/test/ApplicationSpec.scala
  30. 58 0
      play/src/sbt-test/webroutes/dummy/server/test/HTTPStatus.scala
  31. 124 0
      play/src/sbt-test/webroutes/dummy/server/test/IntegrationSpec.scala
  32. 18 0
      play/src/sbt-test/webroutes/dummy/shared/src/main/scala/org/sample/dummy/shared/models/Pojo.scala
  33. 3 0
      play/src/sbt-test/webroutes/dummy/test
  34. 5 0
      play/src/sbt-test/webroutes/user/README.md
  35. 55 0
      play/src/sbt-test/webroutes/user/build.sbt
  36. 39 0
      play/src/sbt-test/webroutes/user/client/src/main/scala/org/sample/user/Cookie.scala
  37. 206 0
      play/src/sbt-test/webroutes/user/client/src/main/scala/org/sample/user/Main.scala
  38. 45 0
      play/src/sbt-test/webroutes/user/client/src/main/scala/org/sample/user/Storage.scala
  39. 1 0
      play/src/sbt-test/webroutes/user/project/build.properties
  40. 9 0
      play/src/sbt-test/webroutes/user/project/plugins.sbt
  41. 159 0
      play/src/sbt-test/webroutes/user/server/app/org/sample/user/controllers/UserController.scala
  42. 26 0
      play/src/sbt-test/webroutes/user/server/app/org/sample/user/controllers/ViewController.scala
  43. 242 0
      play/src/sbt-test/webroutes/user/server/app/org/sample/user/services/OAuth2Service.scala
  44. 30 0
      play/src/sbt-test/webroutes/user/server/app/org/sample/user/util/TryWith.scala
  45. 28 0
      play/src/sbt-test/webroutes/user/server/app/org/sample/user/util/WithConnection.scala
  46. 71 0
      play/src/sbt-test/webroutes/user/server/app/views/index.scala.html
  47. 24 0
      play/src/sbt-test/webroutes/user/server/app/views/main.scala.html
  48. 45 0
      play/src/sbt-test/webroutes/user/server/conf/application.conf
  49. 44 0
      play/src/sbt-test/webroutes/user/server/conf/evolutions/default/1.sql
  50. 43 0
      play/src/sbt-test/webroutes/user/server/conf/logback.xml
  51. 1 0
      play/src/sbt-test/webroutes/user/server/conf/messages
  52. 32 0
      play/src/sbt-test/webroutes/user/server/conf/routes
  53. 7 0
      play/src/sbt-test/webroutes/user/server/conf/testing.conf
  54. 二進制
      play/src/sbt-test/webroutes/user/server/public/images/favicon.png
  55. 0 0
      play/src/sbt-test/webroutes/user/server/public/stylesheets/main.css
  56. 17 0
      play/src/sbt-test/webroutes/user/server/public/views/login.html
  57. 27 0
      play/src/sbt-test/webroutes/user/server/public/views/register.html
  58. 536 0
      play/src/sbt-test/webroutes/user/server/test/ApplicationSpec.scala
  59. 370 0
      play/src/sbt-test/webroutes/user/server/test/IntegrationSpec.scala
  60. 5 0
      play/src/sbt-test/webroutes/user/shared/src/main/scala/org/sample/user/shared/SharedMessages.scala
  61. 90 0
      play/src/sbt-test/webroutes/user/shared/src/main/scala/org/sample/user/shared/models/GrantRequest.scala
  62. 27 0
      play/src/sbt-test/webroutes/user/shared/src/main/scala/org/sample/user/shared/models/User.scala
  63. 3 0
      play/src/sbt-test/webroutes/user/test
  64. 0 0
      play/src/test/resources/empty-route
  65. 0 0
      play/src/test/resources/no-shared-route
  66. 0 0
      play/src/test/resources/user-route
  67. 14 12
      play/src/test/scala/com/tflucke/webroutes/unit/parsers/PlayParserTest.scala
  68. 59 0
      plugin/src/main/scala/com/tflucke/webroutes/RPCGenerator.scala
  69. 39 0
      plugin/src/main/scala/com/tflucke/webroutes/RestRPC.scala
  70. 60 0
      plugin/src/main/scala/com/tflucke/webroutes/RouteDef.scala
  71. 24 0
      plugin/src/main/scala/com/tflucke/webroutes/endpoints/EndpointFile.scala
  72. 17 0
      plugin/src/main/scala/com/tflucke/webroutes/endpoints/ImplicitEndpointFile.scala
  73. 14 0
      plugin/src/main/scala/com/tflucke/webroutes/formatter/Formatter.scala
  74. 8 0
      plugin/src/main/scala/com/tflucke/webroutes/formatter/FormatterTypes.scala
  75. 21 0
      plugin/src/main/scala/com/tflucke/webroutes/formatter/NoFormatter.scala
  76. 6 0
      plugin/src/main/scala/com/tflucke/webroutes/formatter/PlainTextFormatter.scala
  77. 15 0
      plugin/src/main/scala/com/tflucke/webroutes/parsers/Parser.scala
  78. 0 23
      plugin/src/main/scala/name/tflucke/webroutes/RouteDef.scala
  79. 0 47
      plugin/src/main/scala/name/tflucke/webroutes/RouteGenerator.scala
  80. 0 27
      plugin/src/main/scala/name/tflucke/webroutes/WebRoutes.scala
  81. 0 8
      plugin/src/main/scala/name/tflucke/webroutes/parsers/Parser.scala
  82. 0 38
      plugin/src/main/scala/name/tflucke/webroutes/parsers/PlayParser.scala
  83. 1 1
      project/build.properties
  84. 8 0
      test.sh

+ 3 - 0
.gitignore

@@ -1 +1,4 @@
 target/
+.bloop/
+.metals/
+project/metals.sbt

+ 46 - 22
README.md

@@ -28,12 +28,12 @@ server which the preforms the function and sends the result back to the client.
 
 ## What is REST RPC?
 
-REST RPC is a plugin which creates an RPC interface over a RESTFUL one.
+REST RPC is a plugin which creates an RPC interface over a RESTful one.
 
 For example, say a program has an API `PUT /user/:id/address` which maps to the
 function `addUserAddress(id: Long, addr: Address): User`.  The client program
 using REST RPC could then make this function call:
-`addUserAddress(123, myNewAddr)()`.  The RPC layer would then transform that
+`addUserAddress(123)(myNewAddr)`.  The RPC layer would then transform that
 function call into this HTTP request:
 ```HTTP
 PUT /user/123/address HTTP/1.1
@@ -47,7 +47,7 @@ Accept: application/json
    "state": "CA"
 }
 ```
-And then transform the server's response into a strongly typed response.  All
+And then transform the server's response into a strongly typed value.  All
 without writing any HTTP manipulation.
 
 ## Why use REST RPC
@@ -63,7 +63,7 @@ Several reasons:
 
 First, add the plugin to SBT by inserting this line into `project/plugins.sbt`:
 ```scala
-addSbtPlugin("name.tflucke"       % "sbt-rest-rpc"            % "0.1.0")
+addSbtPlugin("com.tflucke"       % "sbt-rest-rpc"            % "0.1.0")
 ```
 
 Second, add the plugin to your project in `build.sbt`:
@@ -77,17 +77,17 @@ client.enablePlugins(RestRpc)
 
 Finally, add the API file and parser as an input in `build.sbt`:
 ```scala
-settings(
-    Compile / generateJsRoutes / fileInputs += (server / baseDirectory).value.toGlob / "conf" / "routes"
-)
-
-/* Or if you are using subprojects */
-
 client.settings(
-    Compile / generateJsRoutes / fileInputs += (server / baseDirectory).value.toGlob / "conf" / "routes"
+    Compile / apiDefinitions += PlayEndpointFile(server)
 )
 ```
 
+## Examples
+
+Sample projects can be found in the `plugin/src/sbt-test/` directory.
+
+## FAQ
+
 ### What if I don't use the play! framework for my server?
 
 You can still use this plugin!  You have two options which will work:
@@ -108,15 +108,13 @@ functions for each of the APIs for each of the servers so long as the servers do
 not have two functions with the same signature.  (We recommend putting each
 server in a different package to guarantee that each function will be unique.)
 
-## FAQ
-
-> Why does the client have to call the function twice?
+### Why does the client have to call the function twice?
 
 Because we wanted the client to be able to access underlying HTTP properties if
 it became useful.  For example, instead of calling the API, a program may want
 to acquire the URL like this:
 ```scala
-println(addUserAddress(123, myNewAddr).url)
+println(addUserAddress(123).url)
 ```
 Outputs:
 ```
@@ -126,25 +124,51 @@ Outputs:
 Additionally, the second call allows the user to specify HTTP arguments
 separately from RPC arguments.  For example:
 ```scala
-addUserAddress(123, myNewAddr)(timeout = 5)
+addUserAddress(123)(myNewAddr, timeout = 5)
 ```
 This clear separation between the RPC and HTTP helps both readability and reduce
-collisions (After all, a user might want an RPC parameter named `timeout`).
+namespace collisions (After all, a user might want an RPC parameter named
+`timeout`).
 
-> Will this plugin support other frameworks besides the play! framework?
+It also allows the client to call the same API multiple times without
+re-computing the URL.  This is useful for APIs which have windowed results.
+
+Finally, it allows the client interface to have a separate parameter for the API
+body.  This is useful because it eliminates the possibility of collisions and
+allows the client code to call the same interface with multiple bodies.
+
+### Will this plugin support other frameworks besides the play! framework?
 
 While this plugin in intended to be flexible, there are no immediate plans to
 support other frameworks at this time.
 
 But we will still happily accept support from the community in this matter.
 
+## Using this repository
+
+### Publishing
+
+TODO
+
+### Running Tests
+
+`sbt test` will run the unit tests for this project.
+
+`sbt scripted` will run the integration tests for this project.
+Be warned that this will open browser windows.
+We recommend using `./test.sh` instead.
+
+`./test.sh` will run all the tests.  If Xvfb is available, it will run the tests
+in a virtual X11 session to prevent unnecessary disruption of workflow and allow
+the script to run on a headless server.
+
 ## TODO:
 
-* Request body support
-* Write tests
 * JVM scala support
 * Java support
 * Clean support for other frameworks besides play!
-* Support for JSON serializers besides json-play (uPickle would be a good first one)
-* Aliasing RPC interfaces
+* Upickle serializer
+* Aliasing RPC function names
+* Overloading RPC function arguments
 * Source file parsers
+* Support NodeJs (Using http.request instead of XMLhttprequest)

+ 49 - 26
build.sbt

@@ -1,48 +1,71 @@
 lazy val root = project.in(file("."))
   .aggregate(
     plugin,
-    library
+    library,
+    playPlugin
+  ).settings(
+    crossScalaVersions := Nil,
+    publish / skip := true,
   )
 
+def commonSettings = Seq(
+  organization := "com.tflucke",
+  //maintainer := "Thomas Flucke <admin@tflucke.name>",
+  scalacOptions ++= Seq(
+    "-deprecation",
+    "-unchecked"
+  ),
+  crossScalaVersions := Seq("2.12.11", "2.12.10")
+)
+
 /****** The Actual Plugin ******/
 
 lazy val plugin = (project in file("plugin"))
   .enablePlugins(SbtPlugin)
+  .settings(commonSettings)
   .settings(
+    addSbtPlugin("org.portable-scala" % "sbt-platform-deps" % "1.0.0"),
     name := "sbt-rest-rpc",
-    organization := "name.tflucke",
-    //maintainer := "Thomas Flucke <admin@tflucke.name>",
-    version := "0.1.0",
-    scalacOptions ++= Seq(
-      "-deprecation",
-      "-unchecked",
-    ),
-    libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.8" % "test",
-    logBuffered in Test := false
-  )
+    version := "0.1.0-SNAPSHOT",
+    publishLocal := (publishLocal dependsOn (library / publishLocal)).value
+  ).dependsOn(library)
 
 /****** The Plugin Library ******/
 
 lazy val library = (project in file("library"))
   .enablePlugins(ScalaJSPlugin)
+  .settings(commonSettings)
   .settings(
     name := "rest-rpc",
-    organization := "name.tflucke",
-    //maintainer := "Thomas Flucke <admin@tflucke.name>",
-    version := "0.1.0",
-    scalacOptions ++= Seq(
-      "-deprecation",
-      "-unchecked"
-    ),
-    libraryDependencies ++= Seq(
+    version := "0.1.0-SNAPSHOT",
+    libraryDependencies ++= (if (scalaJSVersion.startsWith("0.6.")) Seq(
       // Wrapper library for JS dom to scala
       // Docs: https://scala-js.github.io/scala-js-dom/
       // Scaladocs: https://www.javadoc.io/doc/org.scala-js/scalajs-dom_sjs1.0.0-M7_2.12/0.9.6/org/scalajs/dom/index.html
-      (if (scalaJSVersion.startsWith("0.6.")) "org.scala-js" %%% "scalajs-dom" % "0.9.8"
-      else                                    "org.scala-js" %%% "scalajs-dom" % "1.0.0"),
-      // Json Parsing
-      // Wiki:
-      (if (scalaJSVersion.startsWith("0.6.")) "com.typesafe.play" %%% "play-json" % "2.8.1"
-      else                                    "com.typesafe.play" %%% "play-json" % "2.9.0"),
-    )
+      "org.scala-js" %%% "scalajs-dom" % "0.9.8",
+      "com.typesafe.play" %%% "play-json" % "2.8.1"
+    ) else Seq(
+      "org.scala-js" %%% "scalajs-dom" % "1.0.0",
+      "com.typesafe.play" %%% "play-json" % "2.9.0"
+    ))      
   )
+
+/******* The Play Plugin *******/
+
+lazy val playPlugin = (project in file("play"))
+  .enablePlugins(SbtPlugin)
+  .settings(commonSettings)
+  .settings(
+    addSbtPlugin("com.tflucke" % "sbt-rest-rpc" % "0.1.0-SNAPSHOT"),
+    name := "sbt-rest-rpc-play",
+    version := "0.1.0-SNAPSHOT",
+    libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.8" % Test,
+    logBuffered in Test := false,
+    javaOptions in Runtime += "-Dlibrary.version="+(library / version).value,
+    scriptedLaunchOpts := { scriptedLaunchOpts.value ++ Seq(
+      "-Xmx1024M",
+      "-Dplugin.version="+version.value,
+    ) },
+    scriptedBufferLog := false,
+    publishLocal := (publishLocal dependsOn (plugin / publishLocal)).value
+  ).dependsOn(plugin)

+ 75 - 0
library/src/main/scala/com/tflucke/webroutes/APIRoute.scala

@@ -0,0 +1,75 @@
+package com.tflucke.webroutes
+
+import play.api.libs.json.{Json,JsValue}
+import org.scalajs.dom.ext.Ajax.InputData
+import scala.concurrent.{Future,ExecutionContext}
+import scala.util.{Try,Success,Failure}
+import org.scalajs.dom.XMLHttpRequest
+import org.scalajs.dom.ext.Ajax
+import org.scalajs.dom.ext.AjaxException
+
+abstract class APIRoute[T](val method: String, val url: String, format: String) {
+  protected def convert(xhr: XMLHttpRequest): T
+  protected def acceptHeader: String
+
+  def apply(
+    timeout: Int = 0,
+    withCredentials: Boolean = false
+  )(implicit
+    ec: ExecutionContext,
+    headers: Headers = Headers.empty
+  ) : Future[T] = Ajax(
+    method,
+    url,
+    null,
+    timeout,
+    (headers + ("Accept" -> acceptHeader)).map,
+    withCredentials,
+    format
+  ).transform(APIRoute.wrapFailureException(convert))
+}
+
+private object APIRoute {
+  def wrapFailureException[T](convert: (XMLHttpRequest) => T)
+  (x: Try[XMLHttpRequest]) = x match {
+      case Success(res) => Try(convert(res))
+      case Failure(ex: AjaxException) if ex.isTimeout && ex.xhr.status == 0 =>
+        Failure(TimeoutException(ex))
+      case Failure(ex: AjaxException) => Failure(HTTPException(ex.xhr.status, ex))
+      case Failure(ex) => Failure(ex)
+    }
+}
+
+abstract class APIRouteBody[B, R](
+  val method: String,
+  val url: String,
+  format: String
+) {
+  protected def convertBody(body: B): InputData
+  protected def convertResult(xhr: XMLHttpRequest): R
+  protected def contentTypeHeader: String
+  protected def acceptHeader: String
+
+  def apply(
+    body: B,
+    timeout: Int = 0,
+    withCredentials: Boolean = false
+  )(implicit
+    ec: ExecutionContext,
+    headers: Headers = Headers.empty
+  ) : Future[R] = try {
+    Ajax(
+      method,
+      url,
+      convertBody(body),
+      timeout,
+      (headers
+        + ("Accept" -> acceptHeader)
+        + ("Content-Type" -> contentTypeHeader)).map,
+      withCredentials,
+      format
+    ).transform(APIRoute.wrapFailureException(convertResult))
+  } catch {
+    case ex: Exception => Future.failed(ex)
+  }
+}

+ 20 - 0
library/src/main/scala/com/tflucke/webroutes/HTTPException.scala

@@ -0,0 +1,20 @@
+package com.tflucke.webroutes
+
+import org.scalajs.dom.ext.AjaxException
+import scala.util.Try
+
+case class HTTPException(val statusCode: Int, ex: AjaxException)
+    extends Exception {
+
+  type ParserFn[T] = (String) => T
+
+  initCause(ex)
+
+  override val getMessage = ex.xhr.statusText
+
+  val responseText = ex.xhr.responseText
+  def responseType = ex.xhr.responseType
+  val statusText = ex.xhr.statusText
+  def responseXML() = Option(ex.xhr.responseXML)
+  def responseObject[T](implicit fn: ParserFn[T]) = fn(ex.xhr.responseText)
+}

+ 10 - 0
library/src/main/scala/com/tflucke/webroutes/Headers.scala

@@ -0,0 +1,10 @@
+package com.tflucke.webroutes
+
+case class Headers(val map: Map[String, String]) {
+  def +(header: (String, String)) = Headers(this.map + header)
+  def -(header: String) = Headers(this.map - header)
+}
+
+object Headers {
+  def empty = Headers(Map.empty[String, String])
+}

+ 8 - 0
library/src/main/scala/com/tflucke/webroutes/TimeoutException.scala

@@ -0,0 +1,8 @@
+package com.tflucke.webroutes
+
+import org.scalajs.dom.ext.AjaxException
+
+case class TimeoutException(val ex: AjaxException) extends Exception {
+  override val getMessage =
+    s"Connection timed out after ${ex.xhr.timeout} milliseconds."
+}

+ 0 - 20
library/src/main/scala/name/tflucke/webroutes/APIRoute.scala

@@ -1,20 +0,0 @@
-package name.tflucke.webroutes
-
-import play.api.libs.json.{Json,JsValue}
-import org.scalajs.dom.ext.Ajax.InputData
-import scala.concurrent.Future
-import scala.concurrent.ExecutionContext.Implicits.global
-import scala.util.Try
-import org.scalajs.dom.XMLHttpRequest
-import org.scalajs.dom.ext.Ajax
-
-abstract class APIRoute[T](val method: String, val url: String, format: String) {
-
-  protected def convert(json: JsValue): T
-
-  def apply(timeout: Int = 0, headers: Map[String, String] = Map.empty, withCredentials: Boolean = false) : Future[T] = {
-    Ajax(method, url, null, timeout, headers, withCredentials, format).transform(
-      (x: Try[XMLHttpRequest]) => Try(convert(Json.parse(x.get.responseText)))
-    )
-  }
-}

+ 39 - 0
play/src/main/scala/com/tflucke/webroutes/RestRPC.scala

@@ -0,0 +1,39 @@
+package com.tflucke.webroutes
+
+import sbt._
+import Keys.{sourceGenerators,resourceManaged,libraryDependencies}
+import com.tflucke.webroutes.parsers.Parser
+import com.tflucke.webroutes.endpoints.EndpointFile
+import org.portablescala.sbtplatformdeps.PlatformDepsPlugin.autoImport._
+
+/** The main entry point for the plugin.
+  * 
+  * Defines project tasks and default settings.
+  * 
+  * @author Thomas Flucke
+  */
+object RestRPC extends AutoPlugin {
+
+  object autoImport {
+    val generateRpc = taskKey[Seq[File]]("Generate scala client RPC APIs")
+    val apiDefinitions = taskKey[Seq[EndpointFile]]("List of files containing API definitions and their parsers")
+
+    lazy val baseRestRPCSettings: Seq[Def.Setting[_]] = Seq(
+      libraryDependencies += "org.tflucke" %%% "rest-rpc" % "0.1.0"
+    )
+
+    lazy val compileRestRPCSettings: Seq[Def.Setting[_]] = Seq(
+      apiDefinitions := Seq.empty,
+      sourceGenerators += generateRpc,
+      generateRpc := {
+        println("Generating Scala RPC objects...")
+        RPCGenerator(apiDefinitions.value, (Compile / resourceManaged).value)
+      }
+    )
+  }
+
+  import autoImport._
+
+  override lazy val projectSettings = baseRestRPCSettings ++
+    inConfig(Compile)(compileRestRPCSettings)
+}

+ 22 - 0
play/src/main/scala/com/tflucke/webroutes/endpoints/PlayEndpointFile.scala

@@ -0,0 +1,22 @@
+package com.tflucke.webroutes.endpoints
+
+import sbt.{File,Project}
+import com.tflucke.webroutes.parsers.PlayParser
+import com.tflucke.webroutes.formatter._
+
+object PlayEndpointFile {
+  def apply(
+    server: Project,
+    textFmt: TextFormatter = PlainTextFormatter,
+    jsonFmt: JsonFormatter = PlayJsonFormatter,
+    binFmt:  BinaryFormatter = NoBinaryFormatter,
+    formFmt: FormFormatter = NoFormFormatter
+  ) = new EndpointFile(
+    new File(server.base, "conf/routes"),
+    PlayParser,
+    textFmt,
+    jsonFmt,
+    binFmt,
+    formFmt
+  )
+}

+ 8 - 0
play/src/main/scala/com/tflucke/webroutes/formatter/PlayJsonFormatter.scala

@@ -0,0 +1,8 @@
+package com.tflucke.webroutes.formatter
+
+object PlayJsonFormatter extends JsonFormatter {
+  def genDeserializer(body: String, typ: String): String =
+    s"Json.parse($body).as[$typ]"
+  def genSerializer(body: String, typ: String): String =
+    s"Json.stringify(Json.toJson($body))"
+}

+ 79 - 0
play/src/main/scala/com/tflucke/webroutes/parsers/PlayParser.scala

@@ -0,0 +1,79 @@
+package com.tflucke.webroutes.parsers
+
+import sbt.File
+import scala.io.Source
+import com.tflucke.webroutes.RouteDef
+
+/** A parse which will extract information from a Play! Framework routes file.
+  * 
+  * To mark that a specific endpoint should be wrapped in an RPC API, add a RPC
+  * declaration comment (<code># Shared Route</code>) just before it.
+  * 
+  * The routes file does not contain all of the useful information for generating
+  * an RPC API.  To extend this, additional information may be supplied in
+  * attribute comments between the declaration comment and the endpoint:
+  * 
+  * <code>
+  * # attribute: value
+  * </code>
+  * 
+  * Currently recognized attributes include:
+  * <ul>
+  * <li>mime: The accepted response mime type</li>
+  * <li>type: The expected scala return type from the endpoint</li>
+  * <li>content: The data format to send the body of the HTTP call</li>
+  * <li>body: The scala type of the data sent in the body of the HTTP call</li>
+  * </ul>
+  * 
+  * Unrecognized attributes are ignored.
+  * 
+  * If either mime or type are not specified, PlayParser will attempt to find a
+  * reasonable default.  For best results, specify both.
+  * 
+  * If neither are specified, PlayParser will assume the response is a plaintext
+  * String
+  * 
+  * If the type is a String, PlayParser will assume the mime type text/plain.
+  * Otherwise, PlayParser will assume mime type is application/json.
+  * 
+  * If mime is text/plain, PlayParser will assume the scala type is String.
+  * 
+  * @author Thomas Flucke
+  */
+object PlayParser extends Parser {
+  val routeStartPattern = raw"\s*#\s*Shared\s+Route\s*"
+  val propPattern = raw"\s*#\s*\w+\s*:\s*[^\s]+\s*"
+  val routePattern = raw"\s*(GET|PUT|POST|DELETE)\s+(/[^\s]*)+\s+@?([\.\w]+)\.([\w]+)\.([\w]+)\s*(\(\s*(\w+\s*:\s*\w+\s*)?(,\s*\w+\s*:\s*\w+)?\s*\))?"
+  val sharedRoutePattern = s"${routeStartPattern}\n((${propPattern}\n)*)${routePattern}".r
+
+  def parseFile(input: File): Seq[RouteDef] =
+    (for (route <- sharedRoutePattern.findAllMatchIn(Source.fromFile(input).mkString)) yield {
+      val propPattern = "\\s*#\\s*(\\w+)\\s*:\\s*([^\\s]+)\\s*\n".r
+      val props = propPattern.findAllMatchIn(route.group(1)).map(m => (m.group(1), m.group(2))).toList
+      def getProp(name: String): Option[String] = props.find(_._1.equalsIgnoreCase(name)).map(_._2)
+      val method = route.group(3)
+      val path = route.group(4)
+      val pack = route.group(5)
+      val obj = route.group(6)
+      val function = route.group(7)
+      val args = (Option(route.group(9)).getOrElse("") + Option(route.group(10)).getOrElse("")).split(",").filter(!_.isEmpty)
+      val (retMime, retType) = getReturnType(getProp("mime"), getProp("type"))
+      RouteDef(method, path, pack, obj, function, args, getProp("body"),
+        getProp("content") getOrElse "json",
+        retType,
+        retMime)
+    }).toList
+
+  def getReturnType(mime: Option[String], typ: Option[String]) =
+    (mime, typ) match {
+      case (Some(retMime), Some(retType)) => (retMime, retType)
+      case (Some("text/json"), None) => ("application/json", "Any")
+      case (Some("application/json"), None) => ("application/json", "Any")
+      case (Some("text/plain"), None) => ("text/plain", "String")
+      case (Some(_), None) => ???
+      case (None, Some("String")) => ("text/plain", "String")
+      case (None, Some("java.lang.String")) => ("text/plain", "String")
+      case (None, Some(retType)) => ("application/json", retType)
+      case (None, None) => ("text/plain", "String")
+    }
+}

+ 14 - 0
play/src/sbt-test/webroutes/dummy/README.md

@@ -0,0 +1,14 @@
+# Dummy Test
+
+Basic project that tests fundamental functionality.
+
+This project tests:
+
+* Calling a RPC function with each HTTP method:
+  * GET
+  * POST
+  * PUT
+  * DELETE
+* Calling an RPC function which returns an object
+* Calling an RPC function which returns a list of objects
+* Calling an RPC function with HTTP request content

+ 38 - 0
play/src/sbt-test/webroutes/dummy/build.sbt

@@ -0,0 +1,38 @@
+lazy val server: Project = (project in file("server"))
+  .settings(
+    version := "0.1.0",
+    scalaJSProjects := Seq(client),
+    pipelineStages in Assets := Seq(scalaJSPipeline),
+    compile in Compile := ((compile in Compile) dependsOn scalaJSPipeline).value,
+    libraryDependencies += guice,
+    libraryDependencies += "com.vmunier" %% "scalajs-scripts" % "1.1.4",
+    libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "5.0.0" % "test"
+  )
+  .enablePlugins(PlayScala)
+  .dependsOn(sharedJvm)
+
+import com.tflucke.webroutes.endpoints.PlayEndpointFile
+
+lazy val client = (project in file("client"))
+  .settings(
+    version := "0.1.0",
+    scalaJSUseMainModuleInitializer := true,
+    Compile / apiDefinitions += PlayEndpointFile(server)
+  )
+  .enablePlugins(ScalaJSPlugin, RestRPC, ScalaJSWeb)
+  .dependsOn(sharedJs)
+
+lazy val shared = crossProject(JSPlatform, JVMPlatform)
+  .crossType(CrossType.Pure)
+  .in(file("shared"))
+  .settings(
+    libraryDependencies += "com.typesafe.play" %% "play-json" % "2.8.1"
+  )
+  .jsConfigure(_ enablePlugins ScalaJSWeb)
+lazy val sharedJvm = shared.jvm
+lazy val sharedJs = shared.js
+
+// loads the server project at sbt startup
+onLoad in Global := (onLoad in Global).value.andThen(
+  state => "project server" :: state
+)

+ 89 - 0
play/src/sbt-test/webroutes/dummy/client/src/main/scala/org/sample/dummy/Main.scala

@@ -0,0 +1,89 @@
+package org.sample.dummy
+
+import org.scalajs.dom.document
+import org.scalajs.dom.raw.HTMLInputElement
+import scala.scalajs.js.annotation.JSExportTopLevel
+import scala.util.{Try,Success,Failure}
+import scala.concurrent.ExecutionContext.Implicits.global
+import scala.concurrent.duration._
+import scala.concurrent.Future
+import org.sample.dummy.shared.models._
+import org.sample.dummy.controllers.PojoController
+import com.tflucke.webroutes.Headers
+
+object Main {
+
+  /* Get the CSRF token embedded in the HTML page.  This token will implicitly
+   * be passed to Rest RPC.
+   */
+  implicit def headers = Headers.empty +
+    (document.querySelector("input[name=\"csrfToken\"]") match {
+      case null => ("" -> "")
+      case token => ("Csrf-Token" -> token.asInstanceOf[HTMLInputElement].value)
+    })
+
+  val httpSuccessCodes = (200 to 208) ++ Seq(226, 304)
+  val httpRedirectCodes = (300 to 303) ++ Seq(305) ++ Seq(307, 308)
+  val httpErrorCodes = (400 to 417) ++ (421 to 426) ++ (500 to 508) ++
+    Seq(428, 429, 431, 451, 510, 511)
+
+  val tests = Seq(
+    test("query-test-1",  queryTest),
+    test("get-test-1",    getTest),
+    test("add-test-1",    addTest),
+    test("update-test-1", updateTest),
+    test("delete-test-1", deleteTest),
+    test("parse-error-test-1", parseTest),
+    test("timeout-test-1", timeoutTest)
+    //test("abort-test-1", abortTest)
+  ) ++ httpSuccessCodes.map( status =>
+    test(s"success-test-$status", () => PojoController.errorCode(status)())
+  ) ++ httpRedirectCodes.map( status =>
+    test(s"redirect-test-$status", () => PojoController.errorCode(status)())
+  ) ++ httpErrorCodes.map( status =>
+    test(s"error-test-$status", () => PojoController.errorCode(status)())
+  )
+
+  def main(args: Array[String]): Unit = { }
+
+  @JSExportTopLevel("unitTestsReady")
+  def unitTestsReady() = tests.forall(_.isCompleted)
+    
+  def test(id: String, tstFn: () => Future[String]) = tstFn() andThen {
+    case Success(succ) => {
+      val queryDiv = document.createElement("div")
+      document.querySelector("body").appendChild(queryDiv)
+      queryDiv.id = id
+      queryDiv.innerHTML = succ
+    }
+    case Failure(error) => {
+      val queryDiv = document.createElement("div")
+      document.querySelector("body").appendChild(queryDiv)
+      queryDiv.id = id
+      queryDiv.innerHTML = error.getMessage
+    }
+  }
+
+  def queryTest(): Future[String] =
+    PojoController.query()() map (_.mkString("<br />"))
+
+  def getTest(): Future[String] = PojoController.get(1)() map(_ match {
+    case Pojo(id, str, sub) => s"$id, $str, $sub"
+  })
+
+  def updateTest(): Future[String] = PojoController.update(1)(
+    Pojo(3, "Hello", SubPojo(0.582f))
+  ) map (_.toString)
+
+  def addTest(): Future[String] = PojoController.add()(
+    Pojo(72, "foo", SubPojo(12.0))
+  ) map (_.toString)
+
+  def deleteTest(): Future[String] = PojoController.delete(1)() map (_.toString)
+
+  def timeoutTest(): Future[String] =
+    PojoController.reallySlowApi(3600000)(timeout = 10)
+
+  def parseTest(): Future[String] =
+    PojoController.wrongReturnType()() map (_.toString)
+}

+ 23 - 0
play/src/sbt-test/webroutes/dummy/client/src/test/scala/org/sample/dummy/PojoControllerTest.scala

@@ -0,0 +1,23 @@
+package org.sample.dummy
+
+import scala.util.{Success,Failure}
+import org.sample.dummy.shared.models.Pojo
+import org.sample.dummy.controllers.PojoController
+
+import org.scalatest.flatspec.AsyncFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+class PojoControllerTest extends AsyncFlatSpec with Matchers {
+
+  implicit override def executionContext = scala.scalajs.concurrent.JSExecutionContext.Implicits.queue
+
+  // "PojoController.query" should "return a non-empty sequence" in {
+  //   PojoController.query()() onComplete(_ match {case Failure(org.scalajs.dom.ext.AjaxException(xhr)) => {
+  //     println("status: "+xhr.status)
+  //     println("response: "+xhr.response)
+  //     println("response type: "+xhr.responseType)
+  //     println("state: "+xhr.readyState)
+  //   }})
+  //   PojoController.query()() map(_ should matchPattern {case Success(Seq(a, b)) => })
+  // }
+}

+ 1 - 0
play/src/sbt-test/webroutes/dummy/project/build.properties

@@ -0,0 +1 @@
+sbt.version=1.3.8

+ 9 - 0
play/src/sbt-test/webroutes/dummy/project/plugins.sbt

@@ -0,0 +1,9 @@
+addSbtPlugin("org.scala-js"       % "sbt-scalajs"               % "1.1.0")
+addSbtPlugin("com.typesafe.play"  % "sbt-plugin"                % "2.8.1")
+addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject"  % "1.0.0")
+addSbtPlugin("com.vmunier"        % "sbt-web-scalajs"           % "1.0.11")
+
+sys.props.get("plugin.version") match {
+  case Some(x) => addSbtPlugin("com.tflucke" % "sbt-rest-rpc-play" % x)
+  case _ => addSbtPlugin("com.tflucke"       % "sbt-rest-rpc-play" % "0.1.0-SNAPSHOT")
+}

+ 73 - 0
play/src/sbt-test/webroutes/dummy/server/app/org/sample/dummy/controllers/PojoController.scala

@@ -0,0 +1,73 @@
+package org.sample.dummy.controllers
+
+import javax.inject._
+import play.api._
+import play.api.mvc._
+import play.api.libs.json._
+import play.api.libs.functional.syntax._
+import org.sample.dummy.shared.models._
+import org.sample.dummy.shared.models.Pojo._
+
+import scala.concurrent.Future
+import java.io.ObjectInputFilter.Status
+
+@Singleton
+class PojoController @Inject()(
+  val controllerComponents: ControllerComponents,
+) extends BaseController {
+
+  implicit val ec: scala.concurrent.ExecutionContext =
+    scala.concurrent.ExecutionContext.global
+
+  def get(id: Long) = Action.async { implicit request: Request[AnyContent] =>
+    Future { Ok(Json.toJson(Pojo(id, "bla", SubPojo(0.1f)))) }
+  }
+
+  def query = Action.async { implicit request: Request[AnyContent] =>
+    Future { Ok(Json.toJson(Seq(
+      Pojo(1, "foo", SubPojo(0.5f)),
+      Pojo(2, "bar", SubPojo(1.1f))
+    ))) }
+  }
+
+  def update(id: Long) =
+    Action(parse.json).async { implicit request: Request[JsValue] =>
+      Future {
+        val body = request.body.as[Pojo]
+        body match {
+          case Pojo(_, str, sub) => Ok(Json.toJson(Pojo(id, str, sub)))
+        }
+      }
+    }
+
+  def add = Action(parse.json).async { implicit request: Request[JsValue] =>
+    Future {
+      val body = request.body.as[Pojo]
+      body match {
+        case Pojo(_, str, sub) => Ok(Json.toJson(Pojo(93, str, sub)))
+      }
+    }
+  }
+
+  def delete(id: Long) = Action.async { implicit request: Request[AnyContent] =>
+    Future { Ok(Json.toJson(Pojo(id, "bla", SubPojo(0.1f)))) }
+  }
+
+  def reallySlowApi(miliseconds: Long) =
+    Action.async { implicit request: Request[AnyContent] =>
+      Future {
+        Thread.sleep(miliseconds)
+        Ok("You waited too long!")
+      }
+    }
+
+  def wrongReturnType() =
+    Action.async { implicit request: Request[AnyContent] =>
+      Future { Ok("Hello World!") }
+    }
+
+  def errorCode(status: Int) = 
+    Action.async { implicit request: Request[AnyContent] =>
+      Future { Status(status) }
+    }
+}

+ 26 - 0
play/src/sbt-test/webroutes/dummy/server/app/org/sample/dummy/controllers/ViewController.scala

@@ -0,0 +1,26 @@
+package org.sample.dummy.controllers
+
+import javax.inject._
+import play.api._
+import play.api.mvc._
+
+/**
+ * This controller creates an `Action` to handle HTTP requests to the
+ * application's home page.
+ */
+@Singleton
+class ViewController @Inject()(
+  val controllerComponents: ControllerComponents
+) extends BaseController {
+
+  /**
+   * Create an Action to render an HTML page.
+   *
+   * The configuration in the `routes` file means that this method
+   * will be called when the application receives a `GET` request with
+   * a path of `/`.
+   */
+  def index() = Action { implicit request: Request[AnyContent] =>
+    Ok(views.html.index())
+  }
+}

+ 25 - 0
play/src/sbt-test/webroutes/dummy/server/app/views/index.scala.html

@@ -0,0 +1,25 @@
+@()(implicit request: RequestHeader)
+<!DOCTYPE html>
+<html>
+<head>
+  <title>Dummy Class</title>
+  <link rel="shortcut icon" type="image/png"
+	    href="@routes.Assets.versioned("images/favicon.png")">
+</head>
+<body>
+  <!-- Creates a CSRF token, required by Play for non-GET requests.  Token is
+       embedded in a hidden input tag. -->
+  @helper.CSRF.formField
+  <!-- Load the script.  Must come after CSRF token is defined because the script
+       will read it upon startup. -->
+  @scalajs.html.scripts(
+    "client",
+    routes.Assets.versioned(_).toString,
+    name => getClass.getResource(s"/public/$name") != null
+  )
+  <script>
+    // Selenium can access vars, but not lets
+    var unitTestReady = unitTestsReady;
+  </script>
+</body>
+</html>

+ 24 - 0
play/src/sbt-test/webroutes/dummy/server/app/views/main.scala.html

@@ -0,0 +1,24 @@
+@*
+ * This template is called from the `index` template. This template
+ * handles the rendering of the page header and body tags. It takes
+ * two arguments, a `String` for the title of the page and an `Html`
+ * object to insert into the body of the page.
+ *@
+@this()
+@(title: String)(content: Html)(implicit req: play.api.mvc.RequestHeader)
+
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        @* Here's where we render the page title `String`. *@
+        <title>@title</title>
+        <link rel="stylesheet" media="screen" href="@routes.Assets.versioned("stylesheets/main.css")" />
+        <link rel="shortcut icon" type="image/png" href="@routes.Assets.versioned("images/favicon.png")" />
+    </head>
+    <body>
+        @* And here's where we render the `Html` object containing
+         * the page content. *@
+        @content
+      <script src="@routes.Assets.versioned("javascripts/main.js")" type="text/javascript"></script>
+    </body>
+</html>

+ 41 - 0
play/src/sbt-test/webroutes/dummy/server/conf/application.conf

@@ -0,0 +1,41 @@
+# https://www.playframework.com/documentation/latest/Configuration
+
+# Secret key
+# ~~~~~
+# The secret key is used to secure cryptographics functions.
+# If you deploy your application to several instances be sure to use the same key!
+#play.crypto.secret="CHANGEME!"
+
+# The application languages
+# ~~~~~
+play.i18n.langs=["en"]
+
+# Database configuration
+# ~~~~~ 
+# You can declare as many datasources as you want.
+# By convention, the default datasource is named `default`
+#
+
+db.default.url="jdbc:h2:mem:play;MODE=MYSQL"
+
+# Evolutions
+# ~~~~~
+# You can disable evolutions if needed
+play.evolutions.enabled = false
+
+# Filters
+# ~~~~~
+play.filters.enabled=[
+    "play.filters.cors.CORSFilter",
+    "play.filters.headers.SecurityHeadersFilter",
+    "play.filters.hosts.AllowedHostsFilter",
+    "play.filters.csrf.CSRFFilter"
+]
+play.filters.cors {
+  pathPrefixes = ["/"]
+  allowedOrigins = ["http://localhost:9000", "*"]
+}
+
+# Modules
+# ~~~~~
+#play.modules.enabled += "play.modules.swagger.SwaggerModule"

+ 43 - 0
play/src/sbt-test/webroutes/dummy/server/conf/logback.xml

@@ -0,0 +1,43 @@
+<!-- https://www.playframework.com/documentation/latest/SettingsLogger -->
+<!-- Docs: http://logback.qos.ch/ -->
+<configuration>
+
+  <conversionRule conversionWord="coloredLevel" converterClass="play.api.libs.logback.ColoredLevel" />
+
+  <appender name="FILE" class="ch.qos.logback.core.FileAppender">
+    <file>${application.home:-.}/logs/application.log</file>
+    <encoder>
+      <charset>UTF-8</charset>
+      <pattern>
+        %d{yyyy-MM-dd HH:mm:ss} %highlight(%-5level) %cyan(%logger{36}) %magenta(%X{akkaSource}) %msg%n
+      </pattern>
+    </encoder>
+  </appender>
+
+  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+    <withJansi>true</withJansi>
+    <encoder>
+      <charset>UTF-8</charset>
+      <pattern>
+        %d{yyyy-MM-dd HH:mm:ss} %highlight(%-5level) %cyan(%logger{36}) %magenta(%X{akkaSource}) %msg%n
+      </pattern>
+    </encoder>
+  </appender>
+
+  <appender name="ASYNCFILE" class="ch.qos.logback.classic.AsyncAppender">
+    <appender-ref ref="FILE" />
+  </appender>
+
+  <appender name="ASYNCSTDOUT" class="ch.qos.logback.classic.AsyncAppender">
+    <appender-ref ref="STDOUT" />
+  </appender>
+
+  <logger name="play" level="INFO" />
+  <logger name="application" level="DEBUG" />
+
+  <root level="WARN">
+    <!--<appender-ref ref="ASYNCFILE" />-->
+    <appender-ref ref="ASYNCSTDOUT" />
+  </root>
+
+</configuration>

+ 1 - 0
play/src/sbt-test/webroutes/dummy/server/conf/messages

@@ -0,0 +1 @@
+# https://www.playframework.com/documentation/latest/ScalaI18N

+ 44 - 0
play/src/sbt-test/webroutes/dummy/server/conf/routes

@@ -0,0 +1,44 @@
+# Routes
+# This file defines all application routes (Higher priority routes first)
+# https://www.playframework.com/documentation/latest/ScalaRouting
+# ~~~~
+
+GET     /           org.sample.dummy.controllers.ViewController.index
+
+# Shared Route
+# type: org.sample.dummy.shared.models.Pojo
+GET     /dummy/:id/ org.sample.dummy.controllers.PojoController.get(id: Long)
+
+# Shared Route
+# type: Seq[org.sample.dummy.shared.models.Pojo]
+GET     /dummy/     org.sample.dummy.controllers.PojoController.query
+
+# Shared Route
+# body: org.sample.dummy.shared.models.Pojo
+# type: org.sample.dummy.shared.models.Pojo
+PUT     /dummy/     org.sample.dummy.controllers.PojoController.add
+
+# Shared Route
+# body: org.sample.dummy.shared.models.Pojo
+# type: org.sample.dummy.shared.models.Pojo
+POST    /dummy/:id/ org.sample.dummy.controllers.PojoController.update(id: Long)
+
+# Shared Route
+# type: org.sample.dummy.shared.models.Pojo
+DELETE  /dummy/:id/ org.sample.dummy.controllers.PojoController.delete(id: Long)
+
+# Shared Route
+# type: String
+GET    /error/timeout/:time/ org.sample.dummy.controllers.PojoController.reallySlowApi(time: Long)
+
+# Shared Route
+# type: org.sample.dummy.shared.models.Pojo
+GET    /error/parse/ org.sample.dummy.controllers.PojoController.wrongReturnType
+
+# Shared Route
+# type: String
+GET    /error/:status/ org.sample.dummy.controllers.PojoController.errorCode(status :Int)
+
+
+# Map static resources from the /public folder to the /assets URL path
+GET     /assets/*file                       controllers.Assets.versioned(path="/public", file: Asset)

二進制
play/src/sbt-test/webroutes/dummy/server/public/images/favicon.png


+ 0 - 0
plugin/src/test/resources/empty-route → play/src/sbt-test/webroutes/dummy/server/public/stylesheets/main.css


+ 26 - 0
play/src/sbt-test/webroutes/dummy/server/test/ApplicationSpec.scala

@@ -0,0 +1,26 @@
+// import org.junit.runner._
+// import org.specs2.runner._
+// import play.api.test._
+
+// /**
+//  * Add your spec here.
+//  * You can mock out a whole application including requests, plugins etc.
+//  * For more information, consult the wiki.
+//  */
+// @RunWith(classOf[JUnitRunner])
+// class ApplicationSpec() extends PlaySpecification {
+
+//   "Application" should {
+
+//     "send 404 on a bad request" in new WithApplication {
+//       route(app, FakeRequest(GET, "/boum")) must beSome.which (status(_) == NOT_FOUND)
+//     }
+
+//     "render the index page" in new WithApplication {
+//       val home = route(app, FakeRequest(GET, "/")).get
+
+//       status(home) must equalTo(OK)
+//       contentType(home) must beSome.which(_ == "text/html")
+//     }
+//   }
+// }

+ 58 - 0
play/src/sbt-test/webroutes/dummy/server/test/HTTPStatus.scala

@@ -0,0 +1,58 @@
+trait HTTPStatus {
+  val statusReasons = Map(
+    200 -> "OK",
+    201 -> "Created",
+    202 -> "Accepted",
+    203 -> "Non-Authoritative Information",
+    204 -> "No Content",
+    205 -> "Reset Content",
+    206 -> "Partial Conten",
+    300 -> "Multiple Choices",
+    301 -> "Moved Permanently",
+    302 -> "Found",
+    303 -> "See Other",
+    304 -> "Not Modified",
+    305 -> "Use Proxy",
+    307 -> "Temporary Redirect",
+    308 -> "Permanent Redirect",
+    400 -> "Bad Request",
+    401 -> "Unauthorized",
+    402 -> "Payment Required",
+    403 -> "Forbidden",
+    404 -> "Not Found",
+    405 -> "Method Not Allowed",
+    406 -> "Not Acceptable",
+    407 -> "Proxy Authentication Required",
+    408 -> "Request Timeout",
+    409 -> "Conflict",
+    410 -> "Gone",
+    411 -> "Length Required",
+    412 -> "Precondition Failed",
+    413 -> "Payload Too Large",
+    414 -> "URI Too Long",
+    415 -> "Unsupported Media Type",
+    416 -> "Range Not Satisfiable",
+    417 -> "Expectation Failed",
+    421 -> "Misdirected Request",
+    422 -> "Unprocessable Entity",
+    423 -> "Locked",
+    424 -> "Failed Dependency",
+    425 -> "Too Early",
+    426 -> "Upgrade Required",
+    428 -> "Precondition Required",
+    429 -> "Too Many Requests",
+    431 -> "Request Header Fields Too Large",
+    451 -> "Unavailable For Legal Reasons",
+    500 -> "Internal Server Error",
+    501 -> "Not Implemented",
+    502 -> "Bad Gateway",
+    503 -> "Service Unavailable",
+    504 -> "Gateway Timeout",
+    505 -> "HTTP Version Not Supported",
+    506 -> "Variant Also Negotiates",
+    507 -> "Insufficient Storage",
+    508 -> "Loop Detected",
+    510 -> "Not Extended",
+    511 -> "Network Authentication Required"
+  )
+}

+ 124 - 0
play/src/sbt-test/webroutes/dummy/server/test/IntegrationSpec.scala

@@ -0,0 +1,124 @@
+import play.api.test.Helpers._
+import org.scalatest.Assertion
+import org.scalatestplus.play._
+import org.scalatestplus.play.guice.GuiceOneServerPerSuite
+import scala.concurrent.{Future,blocking}
+import scala.concurrent.duration._
+import java.util.concurrent.TimeoutException
+
+class IntegrationSpec extends PlaySpec
+    with GuiceOneServerPerSuite
+    with AllBrowsersPerSuite
+    with HTTPStatus {
+
+  override lazy val browsers = Vector(
+    FirefoxInfo(firefoxProfile),
+    //ChromeInfo
+  )
+
+  val address = s"http://localhost:$port"
+  def pojoRegex(
+    id: String ="\\d+",
+    str: String = "[^,]*",
+    sub: String = "\\d+\\.\\d+"
+  ): String = s"Pojo\\($id,$str,SubPojo\\($sub\\)\\)"
+
+  def waitForReady[T](
+    timeout: Duration = 1 minute,
+    period: Duration = 100 milliseconds
+  )(fn: => T): Future[T] =
+    if (executeScript("return unitTestReady()").asInstanceOf[Boolean])
+      Future.successful(fn)
+    else if (timeout < 0.second)
+      Future.failed(new TimeoutException())
+    else {
+      blocking {
+        Thread.sleep(period toMillis)
+      }
+      waitForReady(timeout - period, period)(fn)
+    }
+
+  def sharedTests(browser: BrowserInfo) = {
+
+    "The index page" must {
+      "load with " + browser.name in {
+        go to address
+      }
+
+      "display query results " + browser.name in {
+        waitForReady() {
+          val text = id("query-test-1").webElement.getText
+          text must fullyMatch regex (s"${pojoRegex()}(\n${pojoRegex()})*")
+        }
+      }
+
+      "display get results " + browser.name in {
+        waitForReady() {
+          val text = id("get-test-1").webElement.getText
+          text must fullyMatch regex ("1, bla, SubPojo\\(0.1\\d*\\)")
+        }
+      }
+
+      "display add results " + browser.name in {
+        waitForReady() {
+          val text = id("add-test-1").webElement.getText
+          text must fullyMatch regex (pojoRegex("93", "foo", "12(.\\d*)?"))
+        }
+      }
+
+      "display update results " + browser.name in {
+        waitForReady() {
+          val text = id("update-test-1").webElement.getText
+          text must fullyMatch regex (pojoRegex("1", "Hello", "0.5\\d*"))
+        }
+      }
+
+      "display delete results " + browser.name in {
+        waitForReady() {
+          val text = id("delete-test-1").webElement.getText
+          text must fullyMatch regex (pojoRegex("1", "bla", "0.1\\d*"))
+        }
+      }
+
+      "display timeout error " + browser.name in {
+        waitForReady() {
+          val text = id("timeout-test-1").webElement.getText
+          text mustEqual "Connection timed out after 10 milliseconds."
+        }
+      }
+
+      val httpSuccessCodes = (200 to 208) ++ Seq(226, 304)
+      val httpRedirectCodes = (300 to 303) ++ Seq(305) ++ Seq(307, 308)
+      val httpErrorCodes = (400 to 417) ++ (421 to 426) ++ (500 to 508) ++
+        Seq(428, 429, 431, 451, 510, 511)
+
+      for (status <- httpSuccessCodes)
+        testSuccess(status)
+      for (status <- httpRedirectCodes)
+        testRedirection(status)
+      for (status <- httpErrorCodes)
+        testError(status)
+
+      def testSuccess(i: Int) = 
+        s"display success for status $i " + browser.name in {
+          waitForReady() {
+            id(s"success-test-$i").webElement.getText mustEqual ""
+          }
+        }
+
+      def testRedirection(i: Int) = 
+        s"display redirection reason for status $i " + browser.name in {
+          waitForReady() {
+            id(s"redirect-test-$i").webElement.getText mustEqual statusReasons(i)
+          }
+        }
+
+      def testError(i: Int) = 
+        s"display error reason for status $i " + browser.name in {
+          waitForReady() {
+            id(s"error-test-$i").webElement.getText mustEqual statusReasons(i)
+          }
+        }
+    }
+  }
+}

+ 18 - 0
play/src/sbt-test/webroutes/dummy/shared/src/main/scala/org/sample/dummy/shared/models/Pojo.scala

@@ -0,0 +1,18 @@
+package org.sample.dummy.shared.models
+
+case class Pojo (
+  val id: Long,
+  val name: String,
+  val sub: SubPojo
+)
+
+case class SubPojo (
+  val dbl: Double
+)
+
+object Pojo {
+  import play.api.libs.json.{Format,Json}
+
+  implicit val subPojoFormat: Format[SubPojo] = Json.format[SubPojo]
+  implicit val pojoFormat: Format[Pojo] = Json.format[Pojo]
+}

+ 3 - 0
play/src/sbt-test/webroutes/dummy/test

@@ -0,0 +1,3 @@
+# Test that the project compiles
+> compile
+> test

+ 5 - 0
play/src/sbt-test/webroutes/user/README.md

@@ -0,0 +1,5 @@
+# User Example
+
+Tests a basic web page with a login and new account buttons.
+
+Uses an in-memory H2 SQL database backend.

+ 55 - 0
play/src/sbt-test/webroutes/user/build.sbt

@@ -0,0 +1,55 @@
+def commonSettings = Seq(
+  scalaVersion := "2.12.11",
+  version := "0.1.0",
+)
+
+lazy val server: Project = (project in file("server"))
+  .settings(commonSettings)
+  .settings(
+    scalaJSProjects := Seq(client),
+    pipelineStages in Assets := Seq(scalaJSPipeline),
+    compile in Compile := ((compile in Compile) dependsOn scalaJSPipeline).value,
+    javaOptions in Test += "-Dconfig.file=conf/testing.conf",
+    libraryDependencies ++= Seq(
+      evolutions,
+      guice,
+      jdbc,
+      specs2 % Test,
+      "com.vmunier" %% "scalajs-scripts" % "1.1.4",
+      "com.h2database" % "h2" % "1.4.191",
+      "com.nulab-inc" %% "scala-oauth2-core" % "1.5.0",
+      "com.nulab-inc" %% "play2-oauth2-provider" % "1.5.0",
+      "com.github.t3hnar" %% "scala-bcrypt" % "4.1",
+      "org.scalatestplus.play" %% "scalatestplus-play" % "5.0.0" % Test
+    )
+  )
+  .enablePlugins(PlayScala)
+  .dependsOn(sharedJvm)
+
+import com.tflucke.webroutes.endpoints.PlayEndpointFile
+
+lazy val client = (project in file("client"))
+  .settings(commonSettings)
+  .settings(
+    scalaJSUseMainModuleInitializer := true,
+    Compile / apiDefinitions += PlayEndpointFile(server),
+    libraryDependencies += "org.querki"      %%% "jquery-facade"  % "2.0"
+  )
+  .enablePlugins(ScalaJSPlugin, RestRPC, ScalaJSWeb)
+  .dependsOn(sharedJs)
+
+lazy val shared = crossProject(JSPlatform, JVMPlatform)
+  .crossType(CrossType.Pure)
+  .in(file("shared"))
+  .settings(commonSettings)
+  .settings(
+    libraryDependencies += "com.typesafe.play" %% "play-json" % "2.8.1",
+  )
+  .jsConfigure(_ enablePlugins ScalaJSWeb)
+lazy val sharedJvm = shared.jvm
+lazy val sharedJs = shared.js
+
+// loads the server project at sbt startup
+onLoad in Global := (onLoad in Global).value.andThen(
+  state => "project server" :: state
+)

+ 39 - 0
play/src/sbt-test/webroutes/user/client/src/main/scala/org/sample/user/Cookie.scala

@@ -0,0 +1,39 @@
+package org.sample.user
+
+import org.scalajs.dom.document
+import scala.concurrent.duration.Duration
+
+trait Cookies {
+  def set(key: String, value: String, options: CookieOptions = CookieOptions()): Cookies
+  def get(key: String): Option[String]
+  def apply(name: String) = get(name)
+}
+
+case class CookieOptions(
+  path: Option[String] = None,
+  domain: Option[String] = None,
+  expires: Option[Duration] = None,
+  secure: Option[Boolean] = None
+) {
+  def opStr(name: String, op: Option[Any]) = op.map(x => s"$name=$x;").getOrElse("")
+
+  expires.get
+
+  override def toString = opStr("path", path) + opStr("domain", domain) +
+    expires.map(x => s"expires=${x.toMillis};").getOrElse("") + opStr("secure", secure)
+}
+
+object Cookies extends Cookies {
+  def get(name: String) = document.cookie.split(";")
+    .find(_.trim.startsWith(s"$name="))
+    .map(x => x.substring(x.indexOf("=") + 1))
+
+  def set(
+    name: String,
+    value: String,
+    options: CookieOptions = CookieOptions()
+  ) = {
+    document.cookie = s"$name=$value;$options"
+    this
+  }
+}

+ 206 - 0
play/src/sbt-test/webroutes/user/client/src/main/scala/org/sample/user/Main.scala

@@ -0,0 +1,206 @@
+package org.sample.user
+
+import com.tflucke.webroutes.Headers
+import org.scalajs.dom.document
+import org.scalajs.dom.raw.{Element,HTMLInputElement,MouseEvent,HTMLDivElement}
+import scala.scalajs.js
+import scala.scalajs.js.annotation.JSExportTopLevel
+import scala.scalajs.js.timers
+import scala.util.{Try,Success,Failure}
+import scala.concurrent.ExecutionContext.Implicits.global
+import scala.concurrent.Future
+import org.sample.user.shared.models._
+import org.sample.user.controllers.UserController
+import org.querki.jquery.{JQueryEventObject,JQuery,JQueryXHR,JQueryStatic => $}
+import com.tflucke.webroutes.{HTTPException,TimeoutException}
+import play.api.libs.json.Json
+
+object Main {
+  /* Get the CSRF token embedded in the HTML page.  This token will implicitly
+   * be passed to Rest RPC.
+   */
+  implicit def headers = Headers(Seq(csrfHeader, authHeader).flatten.toMap)
+
+  @JSExportTopLevel(name="asyncCount")
+  var asyncCount = 0
+
+  def track[T](future: Future[T]): Future[T] = {
+    asyncCount += 1
+    future andThen {
+      case _=> asyncCount -= 1
+    }
+  }
+
+  val csrfSelector = "input[name=\"csrfToken\"]"
+  def csrfHeader = Option(document.querySelector(csrfSelector)).map({ x =>
+    ("Csrf-Token" -> x.asInstanceOf[HTMLInputElement].value)
+  })
+
+  def authHeader = SessionStorage.get("access-token").map({ token =>
+    ("Authorization" -> token)
+  })
+
+  def main(args: Array[String]): Unit = {
+    refreshToken
+    $("#btn-login").click(promptLogin _)
+    $("#btn-signup").click(promptSignup _)
+    $("#btn-logout").click(logout _)
+  }
+
+  def promptLogin() = {
+    $("body").append(overlayWindow("/assets/views/login.html", login))
+  }
+
+  def promptSignup() = {
+    $("body").append(overlayWindow("/assets/views/register.html", signup))
+  }
+
+  def refreshToken: Unit = {
+    SessionStorage.remove("access-token")
+    SessionStorage.get("username").map({user =>
+      SessionStorage.get("refresh-token").map({refresh =>
+        track(UserController.accessToken()(RefreshRequest(user, refresh))
+          .map(loginComplete(user)).recover({
+            case _ => SessionStorage.remove("username").remove("refresh-token")
+          }))
+      })
+    }).flatten
+  }
+
+  def loginComplete(user: String)(auth: UserAuthorization) = {
+    SessionStorage.set("access-token",
+      "%s %s".format(auth.tokenType, auth.accessToken)
+    )
+    SessionStorage.set("username", user)
+    SessionStorage.set("refresh-token", auth.refreshToken)
+    timers.setTimeout(auth.expiresIn)(refreshToken)
+    $("#login-btns").hide
+    $("#logout-btns").show
+    track(UserController.getName()() andThen {
+      case Success(name) => $("#content").text(s"Hello, $name!")
+      case Failure(error) => System.err.println(error)
+    })
+  }
+
+  def signup(div: JQuery): Future[Any] = {
+    import java.util.InputMismatchException
+
+    val email = div.find("#email").value.toString
+    val password = div.find("#password").value.toString
+    track((if (!password.equals(div.find("#password2").value.toString))
+      Future.failed(new InputMismatchException("Passwords do not match."))
+    else
+      UserController.registerUser()(UserRegistration(
+        div.find("#fname").value.toString,
+        div.find("#lname").value.toString,
+        email,
+        password
+      )) map(loginComplete(email))) andThen({
+        case Failure(e: InputMismatchException) => showError(div, e.getMessage)
+        case Failure(e: HTTPException) => showError(div, e.responseText)
+        case Failure(e: TimeoutException) => showError(div, e.getMessage)
+      }))
+  }
+
+  @js.native
+  @js.annotation.JSGlobal("btoa")
+  def base64Encode(str: String): String = js.native
+
+  def showError(div: JQuery, msg: String) = div.find(".alert-danger").html(
+    s"<strong>Error:</strong> $msg"
+  ).show
+
+  def parseString[T](implicit reader: play.api.libs.json.Reads[T]) = {
+    str: String => Json.parse(str).as[T]
+  }
+
+  def login(div: JQuery): Future[Any] = {
+    val email = div.find("#email").value.toString
+    implicit var headers = Main.headers +
+      ("Authorization" -> ("Basic " + base64Encode(
+        "%s:%s".format(email, div.find("#password").value)
+      )))
+    track(UserController.accessToken()(PasswordRequest()).andThen({
+      case Success(auth) => loginComplete(email)(auth)
+      case Failure(error: HTTPException) => Try(
+        error.responseObject(parseString[GrantError])
+      ) match {
+        case Success(gError) => showError(div, gError.errorDescription)
+        case Failure(_) => showError(div, error.getMessage)
+      }
+      case Failure(error: TimeoutException) => showError(div, error.getMessage)
+    }))
+  }
+
+  def logout: Future[Unit] = {
+    track(SessionStorage.get("username").map({user =>
+      SessionStorage.get("refresh-token").map({ refresh: String =>
+        UserController.revokeAccessToken()(RefreshRequest(user, refresh))
+      })
+    }).flatten.getOrElse(Future.failed(
+      new IllegalStateException("No login information.")
+    )).map({ _ =>
+      SessionStorage.remove("username").remove("refresh-token")
+      document.location.reload(false)
+    }))
+  }
+
+  def shadeWindow(cancelFn: Option[(() => Unit)]) = {
+    val shadeDiv = $("<div>").css(js.Dictionary[js.Any](
+      "z-index" -> 99,
+      "background-color" -> "rgba(0, 0, 0, 0.5)",
+      "position" -> "fixed",
+      "top" -> 0,
+      "bottom" -> 0,
+      "left" -> 0,
+      "right" -> 0
+    ))
+    shadeDiv.click((event: JQueryEventObject) => {
+      if (event.target == shadeDiv(0))
+      {
+        cancelFn match {
+          case Some(fn) => fn()
+          case None =>
+        }
+        $(event.target).detach
+      }
+    })
+  }
+
+  def overlayWindow[T](
+    contentUrl: String,
+    submitFn: (JQuery) => Future[T],
+    cancelFn: Option[() => Unit] = None
+  ) = {
+    val promptDiv = $("<div>").css(js.Dictionary[js.Any](
+      "margin" -> "auto",
+      "padding" -> "1em",
+      "background-color" -> "#ffffff",
+      "position" -> "relative",
+      "border-radius" -> "1em",
+      "top" -> "50%",
+      "transform" -> "translateY(-50%)"
+    )).addClass("w-50")
+    val shadeDiv = shadeWindow(cancelFn).append(promptDiv)
+    promptDiv.load(contentUrl, "",
+      (elm: Element, resp: String, status: String, xhr: JQueryXHR) => {
+        $(elm).find("*[data-cb='cancel']").click((event: JQueryEventObject) => {
+          cancelFn match {
+            case Some(fn) => fn()
+            case None =>
+          }
+          shadeDiv.detach
+        })
+        val div =  $(elm).find("*[data-cb='success']").click(
+          (event: JQueryEventObject) => {
+            submitFn(promptDiv) onComplete {
+              case Success(_) => shadeDiv.detach
+              case Failure(err) => System.err.println(err)
+            }
+          }
+        )
+      }
+    )
+    shadeDiv.append(promptDiv)
+  }
+}

+ 45 - 0
play/src/sbt-test/webroutes/user/client/src/main/scala/org/sample/user/Storage.scala

@@ -0,0 +1,45 @@
+package org.sample.user
+
+import org.scalajs.dom.document
+import scala.scalajs.js
+import scala.scalajs.js.annotation.{JSName,JSGlobal}
+import scala.concurrent.duration.Duration
+
+@js.native
+protected trait JsStorage extends js.Object {
+  def setItem(key: String, value: String): Unit = js.native
+  def getItem(key: String): String = js.native
+  def removeItem(key: String): Unit = js.native
+  def clear(): Unit = js.native
+}
+
+trait Storage {
+  protected val native: JsStorage
+
+  def set(key: String, value: String): Storage = {
+    native.setItem(key, value)
+    this
+  }
+  def get(key: String): Option[String] = Option(native.getItem(key))
+  def remove(key: String): Storage = {
+    native.removeItem(key)
+    this
+  }
+  def clear(): Storage = {
+    native.clear
+    this
+  }
+}
+
+object SessionStorage extends Storage {
+  @js.native
+  @JSGlobal("window.sessionStorage")
+  protected object native extends JsStorage {}
+}
+
+
+object LocalStorage extends Storage {
+  @js.native
+  @JSGlobal("window.localStorage")
+  protected object native extends JsStorage {}
+}

+ 1 - 0
play/src/sbt-test/webroutes/user/project/build.properties

@@ -0,0 +1 @@
+sbt.version=1.3.10

+ 9 - 0
play/src/sbt-test/webroutes/user/project/plugins.sbt

@@ -0,0 +1,9 @@
+addSbtPlugin("org.scala-js"       % "sbt-scalajs"               % "1.1.0")
+addSbtPlugin("com.typesafe.play"  % "sbt-plugin"                % "2.8.2")
+addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject"  % "1.0.0")
+addSbtPlugin("com.vmunier"        % "sbt-web-scalajs"           % "1.0.11")
+
+sys.props.get("plugin.version") match {
+  case Some(x) => addSbtPlugin("com.tflucke" % "sbt-rest-rpc-play" % x)
+  case _ => addSbtPlugin("com.tflucke"       % "sbt-rest-rpc-play" % "0.1.0-SNAPSHOT")
+}

+ 159 - 0
play/src/sbt-test/webroutes/user/server/app/org/sample/user/controllers/UserController.scala

@@ -0,0 +1,159 @@
+package org.sample.user.controllers
+
+import java.sql.{Connection,Statement}
+import java.util.Base64
+import javax.inject.{Inject,Singleton}
+import play.api._
+import play.api.db.Database
+import play.api.mvc._
+import play.api.libs.json._
+import org.sample.user.services.OAuth2Service
+import org.sample.user.shared.models._
+import org.sample.user.util.{TryWith,WithConnection}
+import com.github.t3hnar.bcrypt._
+import scalaoauth2.provider._
+import scala.concurrent.duration._
+import scala.concurrent.{Await,Future,blocking}
+import scala.util.{Try,Success,Failure}
+
+@Singleton
+class UserController @Inject()(
+  val controllerComponents: ControllerComponents,
+  oauth: OAuth2Service,
+  db: Database
+) extends BaseController with OAuth2Provider with OAuth2ProviderActionBuilders {
+
+  implicit val ec = scala.concurrent.ExecutionContext.global
+
+  override val tokenEndpoint = new TokenEndpoint {
+    override val handlers = Map(
+      //OAuthGrantType.AUTHORIZATION_CODE -> new AuthorizationCode(),
+      OAuthGrantType.REFRESH_TOKEN -> new RefreshToken(),
+      //OAuthGrantType.CLIENT_CREDENTIALS -> new ClientCredentials(),
+      //OAuthGrantType.IMPLICIT -> new Implicit(),
+      OAuthGrantType.PASSWORD -> new Password()
+    )
+  }
+
+  def encodeBasicAuth(email: String, pass: String) =
+    s"Basic " + Base64.getEncoder().encodeToString(s"$email:$pass".getBytes())
+
+  def decodeBasicAuth(auth: String): Option[(String, String)] = {
+    val format = raw"Basic ([\d\w+/=]*)".r
+    auth match {
+      case format(cred) => {
+        val split = new String(Base64.getDecoder().decode(cred)).split(":", 2)
+        Some((split(0), split(1)))
+      }
+      case _ => None
+    }
+  }
+
+  def accessToken = Action.async { implicit request: Request[Any] =>
+    issueAccessToken(oauth)
+  }
+
+  def revokeAccessToken() = AuthorizedAction[User](oauth).async(parse.json)
+  { implicit request: AuthInfoRequest[JsValue, User] =>
+    val email = (request.body \ "client_id").as[String]
+    val refresh = (request.body \ "refresh_token").as[String]
+    oauth.revokeAccessToken(request.authInfo, email, refresh).transform {
+      case Success(_) => Success(
+        Ok("").withHeaders("Cache-Control" -> "no-store", "Pragma" -> "no-cache")
+      )
+      case Failure(e: OAuthError) => Success(
+        new Status(e.statusCode)(responseOAuthErrorJson(e))
+          .withHeaders(responseOAuthErrorHeader(e))
+      )
+      case Failure(e) => Failure(e)
+    }
+  }
+
+  private val emailRegex = """(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])"""
+  private def isEmail(email: String): Boolean = email.matches(emailRegex)
+
+  private def checkEmail(conn: Connection, email: String): Future[Boolean] = {
+    val emailquery = "SELECT id FROM users WHERE email=? LIMIT 1"
+    Future {
+      TryWith(conn.prepareStatement(emailquery)) { st =>
+        st.setString(1, email)
+        st.executeQuery.next
+      }
+    }.transform(_.flatten)
+  }
+
+  def validateRegisterRequest(body: UserRegistration) = 
+    if (!isEmail(body.email))
+      Some("Invalid email.")
+    else if (body.password.length < 8)
+      Some("Password too short (Minimum 8 characters).")
+    else
+      None
+
+  // TODO: Unit test API
+  def registerUser() = Action.async(parse.json)
+  { implicit request: Request[JsValue] =>
+    val body = request.body.as[UserRegistration]
+    validateRegisterRequest(body) match {
+      case Some(reason) => Future.successful(BadRequest(reason))
+      case None => WithConnection.async(db) { conn =>
+        val insert = """|INSERT INTO users (email,password,fname,lname)
+                        |VALUES (?,?,?,?);""".stripMargin
+        Future {
+          TryWith (conn.prepareStatement(insert)) { st =>
+            st.setString(1, body.email)
+            st.setString(2, body.password.bcrypt)
+            st.setString(3, body.fname)
+            st.setString(4, body.lname)
+            blocking {
+              st.executeUpdate
+            }
+          }
+        }.transform { _.flatten match {
+          case Failure(_) =>
+            Success(Conflict("This email address is already registered."))
+          case Success(1) => Success(Ok(""))
+          // This case should never happen
+          case Success(_) => Success(InternalServerError(
+            "Unknown error occurred registering account."
+          ))
+        } }
+      }.map {
+        case Result(ResponseHeader(OK, _, _), _, _, _, _) =>
+          issueAccessToken(oauth)(Request[Map[String, Seq[String]]](
+            request.withHeaders(Headers(
+              "Authorization" -> encodeBasicAuth(body.email, body.password)
+            )),
+            Map("grant_type" -> Seq(OAuthGrantType.PASSWORD))
+          ), ec)
+        case other => Future.successful(other)
+      }.flatten
+    }
+  }
+
+  def getName() = AuthorizedAction[User](oauth)
+  { implicit request: AuthInfoRequest[AnyContent, User] =>
+    Ok("%s %s".format(request.authInfo.user.fname, request.authInfo.user.lname))
+  }
+
+  import java.sql.ResultSet
+  def resultSetToSeq[T](resSet: ResultSet)(fn: ResultSet => T): Seq[T] =
+    if (resSet.next) fn(resSet) +: resultSetToSeq[T](resSet)(fn)
+    else Seq.empty[T]
+
+  // TODO: Unit test API
+  def getUsers() = Action.async
+  { implicit request: Request[AnyContent] =>
+    WithConnection.async(db) { conn =>
+      Future {
+        TryWith (conn.prepareStatement("SELECT email FROM users;")) { st =>
+          blocking {
+            TryWith (st.executeQuery) { res =>
+              resultSetToSeq(res) { _.getString(1) }
+            }
+          }
+        }
+      } transform { _.flatten.flatten } map {x: Seq[String] => Ok(Json.toJson(x))}
+    }
+  }
+}

+ 26 - 0
play/src/sbt-test/webroutes/user/server/app/org/sample/user/controllers/ViewController.scala

@@ -0,0 +1,26 @@
+package org.sample.user.controllers
+
+import javax.inject._
+import play.api._
+import play.api.mvc._
+
+/**
+ * This controller creates an `Action` to handle HTTP requests to the
+ * application's home page.
+ */
+@Singleton
+class ViewController @Inject()(
+  val controllerComponents: ControllerComponents
+) extends BaseController {
+
+  /**
+   * Create an Action to render an HTML page.
+   *
+   * The configuration in the `routes` file means that this method
+   * will be called when the application receives a `GET` request with
+   * a path of `/`.
+   */
+  def index() = Action { implicit request: Request[AnyContent] =>
+    Ok(views.html.index())
+  }
+}

+ 242 - 0
play/src/sbt-test/webroutes/user/server/app/org/sample/user/services/OAuth2Service.scala

@@ -0,0 +1,242 @@
+package org.sample.user.services
+
+import com.github.t3hnar.bcrypt._
+import org.sample.user.shared.models.{User,RefreshRequest}
+import org.sample.user.util.{TryWith,WithConnection}
+import java.security.SecureRandom
+import java.sql.{Connection,ResultSet,SQLException,Timestamp,PreparedStatement}
+import java.util.{Base64,Calendar,Date}
+import javax.inject.{Inject,Singleton}
+import play.api.db.Database
+import scala.concurrent.duration._
+import scala.concurrent.{ExecutionContext,Future,blocking,Await}
+import scala.language.postfixOps
+import scala.util.{Success,Failure,Try}
+import scalaoauth2.provider._
+
+@Singleton
+class OAuth2Service @Inject()(db: Database) extends DataHandler[User] {
+  implicit val ec: ExecutionContext = ExecutionContext.global
+
+  private def toExpiration(creation: Date, n: Long) =
+    new Timestamp(creation.getTime + (n seconds).toMillis)
+
+  private def toSeconds(creation: Date, expiration: Timestamp) = (
+    (expiration.getTime() - creation.getTime()) millis
+  ).toSeconds
+
+  override def createAccessToken(auth: AuthInfo[User]) =
+    saveToken(auth.user, genToken())
+
+  private def genToken(expire: Duration = 1 hour) = new AccessToken(
+    generateSecureBytes(),
+    Some(generateSecureBytes()),
+    None,
+    Some(expire.toSeconds),
+    Calendar.getInstance.getTime
+  )
+
+  private def generateSecureBytes(n: Int = 32): String = {
+    val token = new Array[Byte](n);
+    new SecureRandom().nextBytes(token)
+    Base64.getEncoder.encodeToString(token)
+  }
+
+  private def saveToken(user: User, token: AccessToken): Future[AccessToken] =
+    WithConnection.async(db) { conn =>
+      val sql = """|INSERT INTO tokens
+                   |(user, access, refresh, created, expire, refresh_expire)
+                   |VALUES (?, ?, ?, ?, ?, ?);""".stripMargin
+      Future {
+        TryWith (conn.prepareStatement(sql)) { st =>
+          st.setLong(1, user.id)
+          st.setString(2, token.token)
+          token.refreshToken match {
+            case Some(t) => st.setString(3, t)
+            case None => st.setNull(3, java.sql.Types.CHAR)
+          }
+          st.setTimestamp(4, new Timestamp(token.createdAt.getTime))
+          token.expiresIn match {
+            case Some(t) => {
+              st.setTimestamp(5, toExpiration(token.createdAt, t))
+              st.setTimestamp(6, toExpiration(token.createdAt, 10*t))
+            }
+            case None => throw new IllegalArgumentException(
+              "Token without an expiration is new supported."
+            )
+          }
+          blocking {
+            if (st.executeUpdate != 1)
+              throw new SQLException("Update failed for unknown reasons.")
+          }
+          token
+        }
+      } transform { _.flatten}
+    }
+
+  private implicit def userFromResSet(res: ResultSet) = User(
+    res.getLong(1),
+    res.getString(2),
+    res.getString(3),
+    res.getString(4)
+  )
+
+  private implicit def tokenFromResSet(res: ResultSet) = AccessToken(
+    res.getString(1),
+    Some(res.getString(2)),
+    None,
+    Some(toSeconds(res.getTimestamp(4), res.getTimestamp(3))),
+    res.getTimestamp(4)
+  )
+
+  private def prepareSql[T](
+    sql: String,
+    keys: Any*
+  )(fn: (PreparedStatement => T)) = WithConnection.async(db) { conn =>
+    Future {
+      TryWith (conn.prepareStatement(sql)) { st =>
+        for ((key, i) <- keys.zipWithIndex)
+          key match {
+            case str: String => st.setString(i+1, str)
+            case long: Long => st.setLong(i+1, long)
+            case _ => throw new IllegalArgumentException(
+              "Unknown key type [%s].".format(key.getClass().getName())
+            )
+          }
+        blocking { fn(st) }
+      }
+    }
+  }.transform(_.flatten)
+
+  private def queryTo[T](sql: String, keys: Any*)(implicit fn: (ResultSet) => T) =
+    prepareSql[Try[Option[T]]](sql, keys:_*) { st =>
+      TryWith (st.executeQuery) { res =>
+        if (!res.next) None
+        else Some(fn(res))
+      }
+    } transform { _.flatten }
+
+  override def validateClient(
+    cred: Option[ClientCredential],
+    requ: AuthorizationRequest
+  ): Future[Boolean] = {
+    val cc = cred.getOrElse(throw new UnauthorizedClient("username required"))
+    requ.grantType match {
+      case OAuthGrantType.PASSWORD => validateUsernamePassword(
+        cc.clientId,
+        cc.clientSecret.getOrElse(throw new AccessDenied("password required"))
+      )
+      case OAuthGrantType.REFRESH_TOKEN => validateUsernameRefresh(
+        cc.clientId,
+        RefreshTokenRequest(requ).refreshToken
+      )
+      case OAuthGrantType.AUTHORIZATION_CODE => throw new UnsupportedGrantType()
+      case OAuthGrantType.CLIENT_CREDENTIALS => throw new UnsupportedGrantType()
+      case OAuthGrantType.IMPLICIT => throw new UnsupportedGrantType()
+    }
+  }
+
+  private def validateUsernamePassword(username: String, pass: String) = {
+    val sql = "SELECT password FROM users WHERE email=? LIMIT 1;"
+    implicit def fn(res: ResultSet) = {
+      pass.isBcryptedSafe(res.getString(1)).getOrElse(false)
+    }
+    queryTo[Boolean](sql, username) transform { _.map { _.getOrElse(false) } }
+  }
+
+  private def validateUsernameRefresh(username: String, refresh: String) = {
+    val sql = """|SELECT * FROM users u
+                 |JOIN active_refresh_tokens r ON id = user
+                 |WHERE email=? AND refresh=? LIMIT 1;""".stripMargin
+    queryTo[Any](sql, username, refresh) map { !_.isEmpty }
+  }
+
+  override def findUser(
+    cred: Option[ClientCredential],
+    requ: AuthorizationRequest
+  ): Future[Option[User]] = {
+    val sql = """|SELECT id, fname, lname, email
+                 |FROM users WHERE email=? LIMIT 1;""".stripMargin
+    val cc = cred.getOrElse(
+      throw new UnsupportedGrantType("client_id required")
+    )
+    queryTo[User](sql, cc.clientId)
+  }
+
+  /* 2020-07-25: Never re-issue the same authorization token. Always generate a
+   * new one.
+   */
+  override def getStoredAccessToken(auth: AuthInfo[User]) = {
+    Future.successful(None)
+  }
+  // {
+  //   val sql = """|SELECT access, refresh, expire, created
+  //                |FROM active_tokens WHERE user=? LIMIT 1;""".stripMargin
+  //   queryTo[AccessToken](sql, auth.id)
+  // }
+  
+  override def findAccessToken(token: String): Future[Option[AccessToken]] = {
+    val sql = """|SELECT access, refresh, expire, created
+                 |FROM tokens WHERE access = ? LIMIT 1;""".stripMargin
+    queryTo[AccessToken](sql, token)
+  }
+
+  private def userToAuth(usr: User) = AuthInfo(usr, Some(usr.email), None, None)
+
+  override def findAuthInfoByAccessToken(token: AccessToken) = {
+    val sql = """|SELECT id, fname, lname, email, password
+                 |FROM users JOIN active_tokens ON users.id = user
+                 |WHERE access=? LIMIT 1;""".stripMargin
+    queryTo[User](sql, token.token) map ( _ map userToAuth )
+  }
+
+  override def findAuthInfoByRefreshToken(
+    token: String
+  ): Future[Option[AuthInfo[User]]] = {
+    val sql = """|SELECT id, fname, lname, email, password
+                 |FROM users JOIN active_refresh_tokens ON users.id = user
+                 |WHERE refresh=? LIMIT 1;""".stripMargin
+    queryTo[User](sql, token) map { _ map userToAuth }
+  }
+
+  override def refreshAccessToken(
+    auth: AuthInfo[User],
+    refreshToken: String
+  ): Future[AccessToken] = {
+    val sql = """|UPDATE tokens SET revoked = true
+                 |WHERE refresh = ? AND user = ?;""".stripMargin
+    prepareSql[Int](sql, refreshToken, auth.user.id) {
+      _.executeUpdate
+    }.map({ _ => createAccessToken(auth) }).flatten
+  }
+
+  def revokeAccessToken(
+    auth: AuthInfo[User],
+    user: String,
+    refresh: String
+  ): Future[Unit] = {
+    val sql = """|UPDATE tokens SET revoked = true
+                 |WHERE user = ? AND refresh = ?;""".stripMargin
+    if (user != auth.user.email)
+      Future.failed(
+        new InvalidClient("Invalid client or client is not authorized")
+      )
+    else 
+      prepareSql[Try[Unit]](sql, auth.user.id, refresh) { st: PreparedStatement =>
+        Try {
+          if (st.executeUpdate == 0)
+            throw new InvalidToken("The access token is invalid")
+        }
+      }.transform { _.flatten }
+  }
+
+  override def deleteAuthCode(token: String): Future[Unit] =
+    throw new UnsupportedGrantType(
+      "Code grant authorizations are not supported."
+    )
+
+  override def findAuthInfoByCode(code: String): Future[Option[AuthInfo[User]]] = 
+    throw new UnsupportedGrantType(
+      "Code grant authorizations are not supported."
+    )
+}

+ 30 - 0
play/src/sbt-test/webroutes/user/server/app/org/sample/user/util/TryWith.scala

@@ -0,0 +1,30 @@
+package org.sample.user.util
+
+import scala.util.control.NonFatal
+import scala.util.{Failure, Try}
+
+/* Adopted with minor changes from:
+ * https://codereview.stackexchange.com/questions/79267/scala-trywith-that-closes-resources-automatically
+ * Written by Stack Overflow User Morgan
+ */
+object TryWith {
+  def apply[C <: AutoCloseable, R](resource: => C)(f: C => R): Try[R] =
+    Try(resource).flatMap(resourceInstance => {
+      try {
+        val returnValue = f(resourceInstance)
+        Try(resourceInstance.close()).map(_ => returnValue)
+      }
+      catch {
+        case NonFatal(exceptionInFunction) =>
+          try {
+            resourceInstance.close()
+            Failure(exceptionInFunction)
+          }
+          catch {
+            case NonFatal(exceptionInClose) =>
+              exceptionInFunction.addSuppressed(exceptionInClose)
+              Failure(exceptionInFunction)
+          }
+      }
+    })
+}

+ 28 - 0
play/src/sbt-test/webroutes/user/server/app/org/sample/user/util/WithConnection.scala

@@ -0,0 +1,28 @@
+package org.sample.user.util
+
+import scala.util.control.NonFatal
+import scala.util.{Failure, Try}
+import java.sql.Connection
+import play.api.db.Database
+import scala.concurrent.{ExecutionContext,Future}
+
+object WithConnection {
+  def async[R](db: Database)
+  (fn: Connection => Future[R])
+  (implicit ec: ExecutionContext): Future[R] = {
+    val conn = db.getConnection()
+    val res = fn(conn)
+    res onComplete { _ => conn.close }
+    res
+  }
+
+  def apply[R](db: Database)(fn: Connection => R): R = {
+    val conn = db.getConnection()
+    try {
+      fn(conn)
+    }
+    finally {
+      conn.close
+    }
+  }
+}

+ 71 - 0
play/src/sbt-test/webroutes/user/server/app/views/index.scala.html

@@ -0,0 +1,71 @@
+@()(implicit request: RequestHeader)
+<!DOCTYPE html>
+<html>
+<head>
+  <title>User Sample Webpage</title>
+  <link rel="shortcut icon" type="image/png"
+	    href="@routes.Assets.versioned("images/favicon.png")">
+  <!-- Bootstrap -->
+  <link rel="stylesheet" crossorigin="anonymous"
+        href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css"
+        integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" />
+  <script src="https://code.jquery.com/jquery-3.3.1.min.js"
+          crossorigin="anonymous"></script>
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js"
+          integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49"
+          crossorigin="anonymous"></script>
+  <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js"
+          integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy"
+          crossorigin="anonymous"></script>
+</head>
+<body>
+  <header class="page-header">
+    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
+      <a class="navbar-brand" href="#">Sample Web Page</a>
+      <button class="navbar-toggler" type="button" data-toggle="collapse"
+              data-target="#navbarNav" aria-controls="navbarNav"
+              aria-expanded="false" aria-label="Toggle navigation">
+        <span class="navbar-toggler-icon"></span>
+      </button>
+      <div class="collapse navbar-collapse" id="navbarNav">
+        <a href="#" class="navbar-brand"></a>
+        <ul class="navbar-nav mr-auto mt-2 mt-lg-0">
+          <li class="nav-item active">
+            <a class="nav-link" href="#">Home</a>
+          </li>
+        </ul>
+        <ul id="login-btns" class="navbar-nav">
+          <li class="nav-item">
+            <a class="nav-link" href="#" id="btn-login">Login</a>
+          </li>
+          <li class="nav-item">
+            <a class="nav-link" href="#" id="btn-signup">Signup</a>
+          </li>
+        </ul>
+        <ul id="logout-btns" class="navbar-nav" style="display: none;">
+          <li class="nav-item">
+            <a class="nav-link" href="#" id="btn-logout">Logout</a>
+          </li>
+        </ul>
+      </div>
+    </nav>
+  </header>
+  <div class="container text-center">
+    <h1 id="content"></h1>
+  </div>
+  <!-- Creates a CSRF token, required by Play for non-GET requests.  Token is
+       embedded in a hidden input tag. -->
+  @helper.CSRF.formField
+  <!-- Load the script.  Must come after CSRF token is defined because the script
+       will read it upon startup. -->
+  @scalajs.html.scripts(
+    "client",
+    routes.Assets.versioned(_).toString,
+    name => getClass.getResource(s"/public/$name") != null
+  )
+  <script>
+    // Selenium can access vars, but not lets
+    var selAsyncCount = asyncCount;
+  </script>
+</body>
+</html>

+ 24 - 0
play/src/sbt-test/webroutes/user/server/app/views/main.scala.html

@@ -0,0 +1,24 @@
+@*
+ * This template is called from the `index` template. This template
+ * handles the rendering of the page header and body tags. It takes
+ * two arguments, a `String` for the title of the page and an `Html`
+ * object to insert into the body of the page.
+ *@
+@this()
+@(title: String)(content: Html)(implicit req: play.api.mvc.RequestHeader)
+
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        @* Here's where we render the page title `String`. *@
+        <title>@title</title>
+        <link rel="stylesheet" media="screen" href="@routes.Assets.versioned("stylesheets/main.css")" />
+        <link rel="shortcut icon" type="image/png" href="@routes.Assets.versioned("images/favicon.png")" />
+    </head>
+    <body>
+        @* And here's where we render the `Html` object containing
+         * the page content. *@
+        @content
+      <script src="@routes.Assets.versioned("javascripts/main.js")" type="text/javascript"></script>
+    </body>
+</html>

+ 45 - 0
play/src/sbt-test/webroutes/user/server/conf/application.conf

@@ -0,0 +1,45 @@
+# https://www.playframework.com/documentation/latest/Configuration
+
+# Secret key
+# ~~~~~
+# The secret key is used to secure cryptographics functions.
+# If you deploy your application to several instances be sure to use the same key!
+#play.crypto.secret="CHANGEME!"
+
+# The application languages
+# ~~~~~
+play.i18n.langs=["en"]
+
+# Database configuration
+# ~~~~~ 
+# You can declare as many datasources as you want.
+# By convention, the default datasource is named `default`
+#
+db.default.driver=org.h2.Driver
+db.default.url="jdbc:h2:mem:users"
+
+# Evolutions
+# ~~~~~
+# You can disable evolutions if needed
+play.evolutions.enabled = true
+
+# Filters
+# ~~~~~
+play.filters.enabled=[
+    "play.filters.cors.CORSFilter",
+    "play.filters.headers.SecurityHeadersFilter",
+#    "play.filters.hosts.AllowedHostsFilter",
+    "play.filters.csrf.CSRFFilter"
+]
+play.filters.cors {
+  pathPrefixes = ["/"]
+  #allowedOrigins = ["http://localhost:9000"]
+  allowedOrigins = null
+}
+play.filters.csrf {
+  bypassCorsTrustedOrigins = true
+}
+
+# Modules
+# ~~~~~
+#play.modules.enabled += "play.modules.swagger.SwaggerModule"

+ 44 - 0
play/src/sbt-test/webroutes/user/server/conf/evolutions/default/1.sql

@@ -0,0 +1,44 @@
+# Users schema
+
+# --- !Ups
+
+CREATE TABLE users (
+       id INT NOT NULL AUTO_INCREMENT,
+       email VARCHAR(255) NOT NULL,
+       fname VARCHAR(255) NOT NULL,
+       lname VARCHAR(255) NOT NULL,
+       password CHAR(60) NOT NULL,
+       PRIMARY KEY (id),
+       UNIQUE(email)
+);
+
+CREATE TABLE tokens (
+       user INT NOT NULL,
+       access CHAR(44) NOT NULL,
+       refresh CHAR(44),
+       expire TIMESTAMP NOT NULL,
+       refresh_expire TIMESTAMP,
+       created TIMESTAMP NOT NULL,
+       revoked BOOLEAN NOT NULL DEFAULT false,
+       --user_agent varchar(255) NOT NULL,
+       --ip byte(4) NOT NULL,
+       PRIMARY KEY (access),
+       UNIQUE KEY unique_tokens(access, refresh),
+       FOREIGN KEY (user) REFERENCES users(id)
+);
+
+CREATE VIEW active_tokens AS SELECT * FROM tokens
+WHERE NOT revoked AND NOW() < expire;
+
+CREATE VIEW active_refresh_tokens AS SELECT * FROM tokens
+WHERE NOT revoked AND NOW() < refresh_expire;
+
+# --- !Downs
+
+DROP VIEW active_refresh_tokens;
+
+DROP VIEW active_tokens;
+
+DROP TABLE tokens;
+
+DROP TABLE users;

+ 43 - 0
play/src/sbt-test/webroutes/user/server/conf/logback.xml

@@ -0,0 +1,43 @@
+<!-- https://www.playframework.com/documentation/latest/SettingsLogger -->
+<!-- Docs: http://logback.qos.ch/ -->
+<configuration>
+
+  <conversionRule conversionWord="coloredLevel" converterClass="play.api.libs.logback.ColoredLevel" />
+
+  <appender name="FILE" class="ch.qos.logback.core.FileAppender">
+    <file>${application.home:-.}/logs/application.log</file>
+    <encoder>
+      <charset>UTF-8</charset>
+      <pattern>
+        %d{yyyy-MM-dd HH:mm:ss} %highlight(%-5level) %cyan(%logger{36}) %magenta(%X{akkaSource}) %msg%n
+      </pattern>
+    </encoder>
+  </appender>
+
+  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+    <withJansi>true</withJansi>
+    <encoder>
+      <charset>UTF-8</charset>
+      <pattern>
+        %d{yyyy-MM-dd HH:mm:ss} %highlight(%-5level) %cyan(%logger{36}) %magenta(%X{akkaSource}) %msg%n
+      </pattern>
+    </encoder>
+  </appender>
+
+  <appender name="ASYNCFILE" class="ch.qos.logback.classic.AsyncAppender">
+    <appender-ref ref="FILE" />
+  </appender>
+
+  <appender name="ASYNCSTDOUT" class="ch.qos.logback.classic.AsyncAppender">
+    <appender-ref ref="STDOUT" />
+  </appender>
+
+  <logger name="play" level="INFO" />
+  <logger name="application" level="DEBUG" />
+
+  <root level="WARN">
+    <!--<appender-ref ref="ASYNCFILE" />-->
+    <appender-ref ref="ASYNCSTDOUT" />
+  </root>
+
+</configuration>

+ 1 - 0
play/src/sbt-test/webroutes/user/server/conf/messages

@@ -0,0 +1 @@
+# https://www.playframework.com/documentation/latest/ScalaI18N

+ 32 - 0
play/src/sbt-test/webroutes/user/server/conf/routes

@@ -0,0 +1,32 @@
+# Routes
+# This file defines all application routes (Higher priority routes first)
+# https://www.playframework.com/documentation/latest/ScalaRouting
+# ~~~~
+
+GET   /               org.sample.user.controllers.ViewController.index
+
+# Shared Route
+# body: org.sample.user.shared.models.UserRegistration
+# type: org.sample.user.shared.models.UserAuthorization
+PUT   /user/          org.sample.user.controllers.UserController.registerUser
+
+# Shared Route
+# type: Seq[String]
+GET   /user/           org.sample.user.controllers.UserController.getUsers()
+
+# Shared Route
+# body: org.sample.user.shared.models.GrantRequest
+# type: org.sample.user.shared.models.UserAuthorization
+POST  /authorize/     org.sample.user.controllers.UserController.accessToken
+
+# Shared Route
+# body: org.sample.user.shared.models.RefreshRequest
+DELETE /authorize/     org.sample.user.controllers.UserController.revokeAccessToken
+
+# Shared Route
+# mime: text/plain
+# type: String
+GET   /user/self/name/ org.sample.user.controllers.UserController.getName()
+
+# Map static resources from the /public folder to the /assets URL path
+GET   /assets/*file   controllers.Assets.versioned(path="/public", file: Asset)

+ 7 - 0
play/src/sbt-test/webroutes/user/server/conf/testing.conf

@@ -0,0 +1,7 @@
+include "application.conf"
+
+# TODO: Fix CSRF in tests
+#play.filters.disabled += "play.filters.csrf.CSRFFilter"
+play.filters.csrf.header.bypassHeaders {
+  Csrf-Token = "nocheck"
+}

二進制
play/src/sbt-test/webroutes/user/server/public/images/favicon.png


+ 0 - 0
play/src/sbt-test/webroutes/user/server/public/stylesheets/main.css


+ 17 - 0
play/src/sbt-test/webroutes/user/server/public/views/login.html

@@ -0,0 +1,17 @@
+<form class="text-right" id="view-login">
+  <div class="alert alert-danger" style="display: none; text-align: left;"></div>
+  <div class="input-group">
+    <input id="email" type="text" class="form-control" name="email"
+           placeholder="Email">
+  </div>
+  <div class="input-group mb-3">
+    <input id="password" type="password" class="form-control" name="password"
+           placeholder="Password">
+  </div>
+  <div class="btn-group btn-group-justified">
+    <button type="button" class="btn" data-cb="cancel">Cancel</button>
+    <button type="button" class="btn btn-success" data-cb="success">
+      Login
+    </button>
+  </div>
+</form>

+ 27 - 0
play/src/sbt-test/webroutes/user/server/public/views/register.html

@@ -0,0 +1,27 @@
+<form class="text-right" id="view-register">
+  <div class="alert alert-danger" style="display: none; text-align: left;"></div>
+  <div class="input-group mb-3">
+    <input id="email" type="text" class="form-control" name="email"
+           placeholder="Email">
+  </div>
+  <div class="input-group">
+    <input id="password" type="password" class="form-control" name="password"
+           placeholder="Password">
+  </div>
+  <div class="input-group mb-3">
+    <input id="password2" type="password" class="form-control" name="password2"
+           placeholder="Repeat Password">
+  </div>
+  <div class="input-group mb-3">
+    <input id="fname" type="text" class="form-control" name="fname"
+           placeholder="First Name">
+    <input id="lname" type="text" class="form-control" name="lname"
+           placeholder="Last Name">
+  </div>
+  <div class="btn-group btn-group-justified">
+    <button type="button" class="btn" data-cb="cancel">Cancel</button>
+    <button type="button" class="btn btn-success" data-cb="success">
+      Register
+    </button>
+  </div>
+</form>

+ 536 - 0
play/src/sbt-test/webroutes/user/server/test/ApplicationSpec.scala

@@ -0,0 +1,536 @@
+import play.api.test.Helpers._
+import play.api.test.{FakeRequest,WithApplication}
+import play.api.mvc.{Results,Headers}
+import play.api.libs.json.{JsObject,Json}
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.tagobjects.Slow
+import org.scalatestplus.play._
+import org.scalatestplus.play.guice.GuiceOneServerPerSuite
+import org.sample.user.shared.models.{User,UserAuthorization}
+import scala.concurrent.duration._
+import java.util.Base64
+
+class OAuthSpec extends PlaySpec
+    with BeforeAndAfterAll
+    with Results
+    with GuiceOneServerPerSuite {
+
+  val email = "tuser@sample.org"
+  val password = "password"
+  val fname = "test"
+  val lname = "user"
+
+  val users = Seq(
+    (User(0, "test", "user", "tuser@sample.org"), "password"),
+    (User(0, "another", "user", "usert@sample.org"), "password")
+  )
+
+  implicit class CSRFWrapper[T](requ: FakeRequest[T]) {
+    def withCSRFToken() = requ.withHeaders(
+      requ.headers.add(("Csrf-Token", "nocheck"))
+    )
+  }
+
+  def makeAuthHeader(username: String, password: String) =
+    Headers(("Authorization" -> s"Basic ${new String(Base64.getEncoder.encode(s"$username:$password".getBytes))}"))
+
+  def makeAuthHeader(auth: UserAuthorization) =
+    Headers(("Authorization" -> s"${auth.tokenType} ${auth.accessToken}"))  
+
+  override def beforeAll = {
+    for ((User(_, fname, lname, email), password) <- users) {
+      val Some(resp) = route(app, FakeRequest(PUT, "/user/").withJsonBody(
+        Json.parse(s"""|{
+                       |  "fname": "$fname",
+                       |  "lname": "$lname",
+                       |  "email": "$email",
+                       |  "password": "$password"
+                       |}""".stripMargin)
+      ).withCSRFToken)
+      status(resp) mustEqual OK
+    }
+  }
+
+  "the token endpoint" should {
+    "reject an empty request" in {
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken
+      )
+      status(resp) mustEqual BAD_REQUEST
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_request\", "+
+          "error_description=\"required parameter: grant_type\"")
+      )
+    }
+
+    "reject an improperly formatted request" in {
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withBody("""{"grant_type" "password"}""")
+        .withHeaders(
+          makeAuthHeader(email, password).add(("Content-Type", "application/json"))
+        ).withCSRFToken
+      )
+      status(resp) mustEqual BAD_REQUEST
+      // Won't get an OAuth response header.  Will fail before processing.
+    }
+
+    "reject a missing grant_type" in {
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("{}"))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken
+      )
+      status(resp) mustEqual BAD_REQUEST
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_request\", "+
+          "error_description=\"required parameter: grant_type\"")
+      )
+    }
+  }
+
+  "the token endpoint for password grants" should {
+    "accept a valid login" in {
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken
+      )
+      status(resp) mustEqual OK
+      contentAsJson(resp).as[UserAuthorization] must matchPattern {
+        case UserAuthorization(_, _, _, _) =>
+      }
+    }
+
+    "issue unique access tokens for successive logins" in {
+      val requ = FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken
+      val Some(resp1) = route(app, requ)
+      val Some(resp2) = route(app, requ)
+      status(resp2) mustEqual OK
+      val resp1Auth = contentAsJson(resp1).as[UserAuthorization]
+      val resp2Auth = contentAsJson(resp2).as[UserAuthorization]
+      resp1Auth must matchPattern {
+        case UserAuthorization(_, _, _, _) =>
+      }
+      resp2Auth must matchPattern {
+        case UserAuthorization(_, _, _, _) =>
+      }
+      resp1Auth.accessToken mustNot equal(resp2Auth.accessToken)
+      resp1Auth.refreshToken mustNot equal(resp2Auth.refreshToken)
+    }
+
+    "reject an unregistered user" in {
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(
+          "_"+users(0)._1.email,
+          "_"+users(0)._2
+        ))
+        .withCSRFToken
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_client\", "+
+          "error_description=\"Invalid client or client is not authorized\""))
+    }
+
+    "reject an incorrect password" in {
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, "_"+users(0)._2))
+        .withCSRFToken
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_client\", "+
+          "error_description=\"Invalid client or client is not authorized\"")
+      )
+    }
+
+    "reject an incorrect username" in {
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader("_"+users(0)._1.email, users(0)._2))
+        .withCSRFToken
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_client\", "+
+          "error_description=\"Invalid client or client is not authorized\"")
+      )
+    }
+
+    "reject an requests without Authorization headers" in {
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse(s"""{"grant_type": "password"}""")
+      ).withCSRFToken)
+      status(resp) mustEqual BAD_REQUEST
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_request\", "+
+          "error_description=\"Client credential is not found\"")
+      )
+    }
+  }
+
+  "the token endpoint for refresh grants" should {
+    "accept a given refresh token" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken
+      ).get).as[UserAuthorization]
+
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withCSRFToken
+      )
+      status(resp) mustEqual OK
+      val newAuth = contentAsJson(resp).as[UserAuthorization]
+      newAuth must matchPattern {
+        case UserAuthorization(_, _, _, _) =>
+      }
+      newAuth.accessToken mustNot equal(auth.accessToken)
+      newAuth.refreshToken mustNot equal(auth.refreshToken)
+    }
+
+    "reject a given refresh token on the second attempt" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken
+      ).get).as[UserAuthorization]
+
+      val Some(resp1) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withCSRFToken
+      )
+      status(resp1) mustEqual OK
+      contentAsJson(resp1).as[UserAuthorization] must matchPattern {
+        case UserAuthorization(_, _, _, _) =>
+      }
+      val Some(resp2) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withCSRFToken
+      )
+      status(resp2) mustEqual UNAUTHORIZED
+      headers(resp2) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_client\", "+
+          "error_description=\"Invalid client or client is not authorized\"")
+      )
+    }
+
+    "reject a revoked refresh token" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken
+      ).get).as[UserAuthorization]
+      val Some(resp1) = route(app, FakeRequest(DELETE, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withHeaders(makeAuthHeader(auth))
+        .withCSRFToken
+      )
+      status(resp1) mustEqual OK
+      val Some(resp2) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withCSRFToken
+      )
+      status(resp2) mustEqual UNAUTHORIZED
+      headers(resp2) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_client\", "+
+          "error_description=\"Invalid client or client is not authorized\"")
+      )
+    }
+
+    "reject a given refresh token with an incorrect username" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken
+      ).get).as[UserAuthorization]
+
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "_${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withCSRFToken
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_client\", "+
+          "error_description=\"Invalid client or client is not authorized\"")
+      )
+    }
+
+    "reject an unprovided refresh token" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken
+      ).get).as[UserAuthorization]
+      val fakeToken = (auth.refreshToken.charAt(0)+1) +
+        auth.refreshToken.substring(1);
+      val Some(resp) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "$fakeToken"
+                                     |}""".stripMargin))
+        .withCSRFToken
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_client\", "+
+          "error_description=\"Invalid client or client is not authorized\"")
+      )
+    }
+  }
+
+  "the secured endpoint" should {
+    "accept an authorized request" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(email, password))
+        .withCSRFToken
+      ).get).as[UserAuthorization]
+      val Some(resp) = route(app, FakeRequest(GET, "/user/self/name/")
+        .withHeaders(makeAuthHeader(auth))
+        .withCSRFToken
+      )
+      status(resp) mustEqual OK
+      contentAsString(resp) must
+        equal(s"${users(0)._1.fname} ${users(0)._1.lname}")
+    }
+
+    "reject an unauthorized request" in {
+      val Some(resp) = route(app, FakeRequest(GET, "/user/self/name/")
+        .withCSRFToken
+      )
+      status(resp) mustEqual BAD_REQUEST
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_request\", "+
+          "error_description=\"Access token is not found\"")
+      )
+    }
+
+    "reject an forged authorized request" in {
+      val fakeToken = "7pKNy790TV5lKVjQw3k/pwJmMS8XBhHaLTVaI6ftd5M="
+      val Some(resp) = route(app, FakeRequest(GET, "/user/self/name/")
+        .withHeaders(("Authorization" -> s"Bearer $fakeToken"))
+        .withCSRFToken
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_token\", "+
+          "error_description=\"The access token is not found\"")
+      )
+    }
+
+    "reject a refreshed access token" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken
+      ).get).as[UserAuthorization]
+      val Some(resp1) = route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withCSRFToken
+      )
+      status(resp1) mustEqual OK
+      val Some(resp2) = route(app, FakeRequest(GET, "/user/self/name/")
+        .withHeaders(makeAuthHeader(auth))
+        .withCSRFToken
+      )
+      status(resp2) mustEqual UNAUTHORIZED
+      headers(resp2) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_token\", "+
+          "error_description=\"The access token is invalid\"")
+      )
+    }
+
+    "reject a revoked access token" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken
+      ).get).as[UserAuthorization]
+      val  Some(resp1) = route(app, FakeRequest(DELETE, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withHeaders(makeAuthHeader(auth))
+        .withCSRFToken
+      )
+      status(resp1) mustEqual OK
+      val Some(resp2) = route(app, FakeRequest(GET, "/user/self/name/")
+        .withHeaders(makeAuthHeader(auth))
+        .withCSRFToken
+      )
+      status(resp2) mustEqual UNAUTHORIZED
+      headers(resp2) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_token\", "+
+          "error_description=\"The access token is invalid\"")
+      )
+    }
+
+    /*
+    "reject an expired access token" taggedAs(Slow) in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(email, password))
+      ).get).as[UserAuthorization]
+      Thread.sleep((1 hour).toMillis)
+      val Some(resp) = route(app, FakeRequest(GET, "/user/self/name/")
+        .withHeaders(makeAuthHeader(auth)))
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_token\", "+
+          "error_description=\"The access token is invalid\"")
+      )
+    }
+    */
+  }
+
+  "the token endpoint for revokation requests" should {
+    "accept a valid request" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken
+      ).get).as[UserAuthorization]
+      val Some(resp) = route(app, FakeRequest(DELETE, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withHeaders(makeAuthHeader(auth))
+        .withCSRFToken
+      )
+      status(resp) mustEqual OK
+      contentAsString(resp) mustEqual("")
+    }
+
+    "reject a request with an invalid authorization header" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken
+      ).get).as[UserAuthorization]
+      val fakeToken = (auth.accessToken.charAt(0)+1) +
+        auth.accessToken.substring(1);
+      val Some(resp) = route(app, FakeRequest(DELETE, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withHeaders("Authorization" -> s"Bearer $fakeToken")
+        .withCSRFToken
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_token\", "+
+          "error_description=\"The access token is not found\"")
+      )
+    }
+
+    "reject a request without an authorization header" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken
+      ).get).as[UserAuthorization]
+      val Some(resp) = route(app, FakeRequest(DELETE, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withCSRFToken
+      )
+      status(resp) mustEqual BAD_REQUEST
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_request\", "+
+          "error_description=\"Access token is not found\"")
+      )
+    }
+
+    "reject a request with an unissued refresh token" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken
+      ).get).as[UserAuthorization]
+      val fakeToken = (auth.refreshToken.charAt(0)+1) +
+        auth.refreshToken.substring(1);
+      val Some(resp) = route(app, FakeRequest(DELETE, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "${users(0)._1.email}",
+                                     |  "refresh_token": "${fakeToken}"
+                                     |}""".stripMargin))
+        .withHeaders(makeAuthHeader(auth))
+        .withCSRFToken
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_token\", "+
+          "error_description=\"The access token is invalid\"")
+      )
+    }
+
+    "reject a request with an incorrect username" in {
+      val auth = contentAsJson(route(app, FakeRequest(POST, "/authorize/")
+        .withJsonBody(Json.parse("""{"grant_type": "password"}"""))
+        .withHeaders(makeAuthHeader(users(0)._1.email, users(0)._2))
+        .withCSRFToken
+      ).get).as[UserAuthorization]
+      val Some(resp) = route(app, FakeRequest(DELETE, "/authorize/")
+        .withJsonBody(Json.parse(s"""|{
+                                     |  "grant_type": "refresh_token",
+                                     |  "client_id": "_${users(0)._1.email}",
+                                     |  "refresh_token": "${auth.refreshToken}"
+                                     |}""".stripMargin))
+        .withHeaders(makeAuthHeader(auth))
+        .withCSRFToken
+      )
+      status(resp) mustEqual UNAUTHORIZED
+      headers(resp) must contain ("WWW-Authenticate" ->
+        ("Bearer error=\"invalid_client\", "+
+          "error_description=\"Invalid client or client is not authorized\"")
+      )
+    }
+  }
+}

+ 370 - 0
play/src/sbt-test/webroutes/user/server/test/IntegrationSpec.scala

@@ -0,0 +1,370 @@
+import play.api.test.Helpers._
+import play.api.test.{FakeRequest}
+import play.api.libs.json.{JsObject,Json}
+import org.openqa.selenium.By
+import org.scalatest.{Assertion,BeforeAndAfterAll,BeforeAndAfterEach}
+import org.scalatestplus.play._
+import org.scalatestplus.play.guice.GuiceOneServerPerSuite
+import org.sample.user.shared.models.User
+import scala.concurrent.{ExecutionContext,Future,blocking}
+import scala.concurrent.duration._
+import java.util.concurrent.TimeoutException
+
+class IntegrationSpec extends PlaySpec
+    with BeforeAndAfterAll
+    with BeforeAndAfterEach
+    with GuiceOneServerPerSuite
+    with AllBrowsersPerTest {
+
+  val users = Seq(
+    (User(0, "test", "user", "tuser@sample.org"), "password"),
+    (User(0, "another", "user", "usert@sample.org"), "password")
+  )
+
+  override def beforeAll = {
+    val Some(resp) = route(app, FakeRequest(PUT, "/user/").withJsonBody(
+      Json.parse(s"""|{
+                     |  "fname": "${users(0)._1.fname}",
+                     |  "lname": "${users(0)._1.lname}",
+                     |  "email": "${users(0)._1.email}",
+                     |  "password": "${users(0)._2}"
+                     |}""".stripMargin)
+    ))
+    status(resp) mustEqual OK
+  }
+
+  override lazy val browsers = Vector(
+    FirefoxInfo(firefoxProfile),
+    //ChromeInfo
+  )
+
+  val address = s"http://localhost:$port"
+
+  /* Will repeatedly attempt to test until a result is returned.
+   * A None value implies the test is not yet ready to run and will be attempted
+   * again after the frequency has passed..
+   * 
+   * A Some value means the test has completed with the contained assertion
+   * result.
+   */
+  def poll(
+    test: () => Option[Assertion],
+    frequency: Duration = 100.millisecond
+  ): Future[Assertion] = {
+    test() match {
+      case Some(result) => Future.successful(result)
+      case None => {
+        blocking {
+          Thread.sleep(frequency.toMillis)
+        }
+        poll(test, frequency)
+      }
+    }
+  }
+
+  def waitForReady[T](
+    timeout: Duration = 1 minute,
+    period: Duration = 100 millisecond
+  )(fn: => T): Future[T] =
+    if (executeScript("return jQuery.active == 0 && selAsyncCount == 0;").asInstanceOf[Boolean])
+      Future.successful(fn)
+    else if (timeout < 0.second)
+      Future.failed(new TimeoutException())
+    else {
+      blocking {
+        Thread.sleep(period.toMillis)
+      }
+      waitForReady(timeout - period, period)(fn)
+    }
+
+  def sharedTests(browser: BrowserInfo) = {
+    "The register menu" must {
+      "display when register button is clicked " + browser.name in {
+        go to address
+        click on id("btn-signup")
+        waitForReady() {
+          click on id("view-register").webElement.findElement(By.xpath("./.."))
+          id("content").webElement.getText mustEqual ("")
+        }
+      }
+
+      def testUserList() = {
+        val Some(resp) = route(app, FakeRequest(GET, "/user/"))
+        status(resp) mustEqual OK
+        contentAsJson(resp).as[Seq[String]] must matchPattern {
+          case Seq(user1, user2) =>
+        }
+      }
+
+      "accept a new user " + browser.name in {
+        go to address
+        click on id("btn-signup")
+        waitForReady() {
+          textField("email").value = users(1)._1.email
+          name("password").webElement.sendKeys(users(1)._2)
+          name("password2").webElement.sendKeys(users(1)._2)
+          textField("fname").value = users(1)._1.fname
+          textField("lname").value = users(1)._1.lname
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            find("#view-register") must be (None)
+            id("btn-logout").webElement must be ('displayed)
+            id("content").webElement.getText
+              .mustEqual(s"Hello, ${users(1)._1.fname} ${users(1)._1.lname}!")
+            testUserList()
+          }
+        }
+      }
+
+      "reject a used email " + browser.name in {
+        go to address
+        click on id("btn-signup")
+        waitForReady() {
+          textField("email").value = users(0)._1.email
+          name("password").webElement.sendKeys("_"+users(0)._2)
+          name("password2").webElement.sendKeys("_"+users(0)._2)
+          textField("fname").value = "_"+users(0)._1.fname
+          textField("lname").value = "_"+users(0)._1.lname
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            cssSelector(".alert-danger").webElement.getText
+              .mustEqual("Error: This email address is already registered.")
+            id("btn-logout").webElement mustNot be ('displayed)
+            id("content").webElement.getText mustEqual("")
+            testUserList()
+          }
+        }
+      }
+
+      "reject an invalid email " + browser.name in {
+        go to address
+        click on id("btn-signup")
+        waitForReady() {
+          textField("email").value = "hello World!"
+          name("password").webElement.sendKeys("_"+users(0)._2)
+          name("password2").webElement.sendKeys("_"+users(0)._2)
+          textField("fname").value = "_"+users(0)._1.fname
+          textField("lname").value = "_"+users(0)._1.lname
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            cssSelector(".alert-danger").webElement.getText
+              .mustEqual("Error: Invalid email.")
+            id("btn-logout").webElement mustNot be ('displayed)
+            id("content").webElement.getText mustEqual("")
+            testUserList()
+          }
+        }
+      }
+
+      "reject a password below 8 characters " + browser.name in {
+        go to address
+        click on id("btn-signup")
+        waitForReady() {
+          textField("email").value = "_"+users(0)._1.email
+          name("password").webElement.sendKeys("1234567")
+          name("password2").webElement.sendKeys("1234567")
+          textField("fname").value = "_"+users(0)._1.fname
+          textField("lname").value = "_"+users(0)._1.lname
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            cssSelector(".alert-danger").webElement.getText
+              .mustEqual("Error: Password too short (Minimum 8 characters).")
+            id("btn-logout").webElement mustNot be ('displayed)
+            id("content").webElement.getText mustEqual("")
+            testUserList()
+          }
+        }
+      }
+
+      "reject a non-matching passwords " + browser.name in {
+        go to address
+        click on id("btn-signup")
+        waitForReady() {
+          textField("email").value = "_"+users(0)._1.email
+          name("password").webElement.sendKeys(users(0)._2)
+          name("password2").webElement.sendKeys("_"+users(0)._2)
+          textField("fname").value = "_"+users(0)._1.fname
+          textField("lname").value = "_"+users(0)._1.lname
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            cssSelector(".alert-danger").webElement.getText
+              .mustEqual("Error: Passwords do not match.")
+            id("btn-logout").webElement mustNot be ('displayed)
+            id("content").webElement.getText mustEqual("")
+            testUserList()
+          }
+        }
+      }
+    }
+
+    "The login menu" must {
+      "display when login button is clicked " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          click on id("view-login").webElement.findElement(By.xpath("./.."))
+          id("btn-logout").webElement mustNot be ('displayed)
+          id("content").webElement.getText mustEqual("")
+        }
+      }
+
+      "accept user created from API " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = users(0)._1.email
+          name("password").webElement.sendKeys(users(0)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            find("#view-login") must be (None)
+            id("btn-logout").webElement must be ('displayed)
+            id("content").webElement.getText
+              .mustEqual(s"Hello, ${users(0)._1.fname} ${users(0)._1.lname}!")
+          }
+        }
+      }
+
+      "accept user created from UI " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = users(1)._1.email
+          name("password").webElement.sendKeys(users(1)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            find("#view-login") must be (None)
+            id("btn-logout").webElement must be ('displayed)
+            id("content").webElement.getText
+              .mustEqual(s"Hello, ${users(1)._1.fname} ${users(1)._1.lname}!")
+          }
+        }
+      }
+
+      "reject an incorrect email " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = "_"+users(1)._1.email
+          name("password").webElement.sendKeys(users(0)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            cssSelector(".alert-danger").webElement.getText
+              .mustEqual("Error: Invalid client or client is not authorized")
+            id("btn-logout").webElement mustNot be ('displayed)
+            id("content").webElement.getText mustEqual("")
+          }
+        }
+      }
+
+      "reject an incorrect password " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = users(1)._1.email
+          name("password").webElement.sendKeys("_"+users(0)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            cssSelector(".alert-danger").webElement.getText
+              .mustEqual("Error: Invalid client or client is not authorized")
+            id("btn-logout").webElement mustNot be ('displayed)
+            id("content").webElement.getText mustEqual("")
+          }
+        }
+      }
+
+      "reject an incorrect email and password " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = "_"+users(1)._1.email
+          name("password").webElement.sendKeys("_"+users(0)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            cssSelector(".alert-danger").webElement.getText
+              .mustEqual("Error: Invalid client or client is not authorized")
+            id("btn-logout").webElement mustNot be ('displayed)
+            id("content").webElement.getText mustEqual("")
+          }
+        }
+      }
+
+      "maintain login info across refresh " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = users(1)._1.email
+          name("password").webElement.sendKeys(users(1)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            go to address
+            waitForReady() {
+              find("#view-login") must be (None)
+                id("btn-logout").webElement must be ('displayed)
+              id("content").webElement.getText
+                .mustEqual(s"Hello, ${users(1)._1.fname} ${users(1)._1.lname}!")
+              id("btn-logout").webElement must be ('displayed)
+            }
+          }
+        }
+      }
+    }
+
+    "The logout button" must {
+      "not display when not logged in " + browser.name in {
+        go to address
+        waitForReady() {
+          id("btn-logout").webElement mustNot be ('displayed)
+          id("content").webElement.getText mustEqual("")
+        }
+      }
+
+      "display when logged in" + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = users(0)._1.email
+          name("password").webElement.sendKeys(users(0)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            id("btn-logout").webElement must be ('displayed)
+          }
+        }
+      }
+
+      "remove user information when clicked " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = users(0)._1.email
+          name("password").webElement.sendKeys(users(0)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            click on id("btn-logout")
+            waitForReady() {
+              id("btn-logout").webElement mustNot be ('displayed)
+              id("content").webElement.getText mustEqual("")
+            }
+          }
+        }
+      }
+
+      "remove stored user information when clicked " + browser.name in {
+        go to address
+        click on id("btn-login")
+        waitForReady() {
+          textField("email").value = users(0)._1.email
+          name("password").webElement.sendKeys(users(0)._2)
+          click on cssSelector("*[data-cb='success']")
+          waitForReady() {
+            click on id("btn-logout")
+            waitForReady() {
+              go to address
+              id("btn-logout").webElement mustNot be ('displayed)
+              id("content").webElement.getText mustEqual("")
+            }
+          }
+        }
+      }
+    }
+  }
+}

+ 5 - 0
play/src/sbt-test/webroutes/user/shared/src/main/scala/org/sample/user/shared/SharedMessages.scala

@@ -0,0 +1,5 @@
+package name.tflucke.tonegame.shared
+
+object SharedMessages {
+  def itWorks = "It works!"
+}

+ 90 - 0
play/src/sbt-test/webroutes/user/shared/src/main/scala/org/sample/user/shared/models/GrantRequest.scala

@@ -0,0 +1,90 @@
+package org.sample.user.shared.models
+
+import play.api.libs.json.{Format,Json}
+
+/* Information to receive an access token */
+sealed trait GrantRequest {
+  def grantType: String
+  def scope: Option[String]
+}
+
+case class GrantError (
+  val error: String,
+  val errorDescription: String
+)
+
+case class PasswordRequest (
+  val scope: Option[String] = None
+) extends GrantRequest {
+  val grantType = "password"
+}
+
+/* Information to receive an access token */
+case class RefreshRequest (
+  val clientId: String,
+  val refreshToken: String,
+  val scope: Option[String] = None
+) extends GrantRequest {
+  val grantType = "refresh_token"
+}
+
+/* Access token response */
+case class UserAuthorization (
+  //val scope: String,
+  val accessToken: String,
+  val tokenType: String,
+  val expiresIn: scala.concurrent.duration.FiniteDuration,
+  val refreshToken: String
+)
+
+object GrantError {
+  import play.api.libs.json.{JsonConfiguration,JsonNaming}
+
+  implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
+  implicit val errorormat = Json.format[GrantError]
+}
+
+object PasswordRequest {
+  import play.api.libs.json.{JsonConfiguration,JsonNaming}
+
+  implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
+  implicit val requestFormat = Json.format[PasswordRequest]
+}
+
+object RefreshRequest {
+  import play.api.libs.json.{JsonConfiguration,JsonNaming}
+
+  implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
+  implicit val requestFormat = Json.format[RefreshRequest]
+}
+
+object GrantRequest {
+  import play.api.libs.functional.syntax._
+  import play.api.libs.json.{JsObject,Reads,Writes}
+
+  implicit val requestWrites = Writes[GrantRequest](requ =>
+    (requ match {
+      case pass: PasswordRequest => Json.toJson(pass)
+      case refr: RefreshRequest => Json.toJson(refr)
+    }).asInstanceOf[JsObject] + ("grant_type", Json.toJson(requ.grantType))
+  )
+
+  implicit val requestReads = Reads[GrantRequest](json =>
+    (json \ "grant_type").as[String] match {
+      case "password" => Json.fromJson[PasswordRequest](json)
+      case "refresh_token" => Json.fromJson[RefreshRequest](json)
+      case _ => ???
+    }
+  )
+}
+
+object UserAuthorization {
+  import scala.concurrent.duration._
+  import play.api.libs.functional.syntax._
+  import play.api.libs.json.{JsonConfiguration,JsonNaming}
+
+  implicit val config = JsonConfiguration(JsonNaming.SnakeCase)
+  implicit val durationFormat = Format.of[Long]
+    .inmap[FiniteDuration](_.seconds, _.toSeconds)
+  implicit val authorizationFormat = Json.format[UserAuthorization]
+}

+ 27 - 0
play/src/sbt-test/webroutes/user/shared/src/main/scala/org/sample/user/shared/models/User.scala

@@ -0,0 +1,27 @@
+package org.sample.user.shared.models
+
+import play.api.libs.json.{Format,Json}
+
+/* Basic User information */
+case class User (
+  val id: Long,
+  val fname: String,
+  val lname: String,
+  val email: String
+)
+
+/* Information to register a new user */
+case class UserRegistration (
+  val fname: String,
+  val lname: String,
+  val email: String,
+  val password: String
+)
+
+object User {
+  implicit val userFormat = Json.format[User]
+}
+
+object UserRegistration {
+  implicit val registerFormat = Json.format[UserRegistration]
+}

+ 3 - 0
play/src/sbt-test/webroutes/user/test

@@ -0,0 +1,3 @@
+# Test that the project compiles
+> compile
+> test

+ 0 - 0
play/src/test/resources/empty-route


+ 0 - 0
plugin/src/test/resources/no-shared-route → play/src/test/resources/no-shared-route


+ 0 - 0
plugin/src/test/resources/user-route → play/src/test/resources/user-route


+ 14 - 12
plugin/src/test/scala/name/tflucke/webroutes/unit/parsers/PlayParserTest.scala → play/src/test/scala/com/tflucke/webroutes/unit/parsers/PlayParserTest.scala

@@ -1,9 +1,9 @@
-package name.tflucke.webroutes.unit.parsers
+package com.tflucke.webroutes.unit.parsers
 
 import sbt.File
 import scala.io.Source
-import name.tflucke.webroutes.RouteDef
-import name.tflucke.webroutes.parsers.PlayParser
+import com.tflucke.webroutes.RouteDef
+import com.tflucke.webroutes.parsers.PlayParser
 import org.scalatest._
 import java.io.FileNotFoundException
 
@@ -12,18 +12,19 @@ class PlayParserTest extends PropSpec with Matchers {
 
   val emptyFile = makeRouteFile("empty-route")
   val noSharedFile = makeRouteFile("no-shared-route")
-  val nonexistantFile = new File("does-not-exist") /* Cannot load non-existent resource */
+  /* Cannot load non-existent resource */
+  val nonexistantFile = new File("does-not-exist")
   val userFile = makeRouteFile("user-route")
 
   property("an empty file should produce an empty list") {
-    PlayParser().parseFile(emptyFile).size should be (0)
+    PlayParser.parseFile(emptyFile).size should be (0)
   }
 
   property("the users route file should produce a list of 5 elements ") {
     apis.size should be (5)
   }
 
-  val apis = PlayParser().parseFile(userFile)
+  val apis = PlayParser.parseFile(userFile)
   val queryApi = apis.find(api => api.fn == "query")
   val addApi = apis.find(api => api.fn == "addUser")
   val getApi = apis.find(api => api.fn == "get")
@@ -93,9 +94,8 @@ class PlayParserTest extends PropSpec with Matchers {
   property("the add api should have return type User") {
     addApi.get.retType should be ("org.sample.users.shared.User")
   }
-  property("the add api should have only a User argument") {
-    addApi.get.args.size should be (1)
-    addApi.get.args(0).replace(" ", "") should be ("_body:org.sample.users.shared.User")
+  property("the add api should have only a User body argument") {
+    addApi.get.bodyType should be (Some("org.sample.users.shared.User"))
   }
 
   property("the users route file should produce a update api") {
@@ -117,9 +117,11 @@ class PlayParserTest extends PropSpec with Matchers {
     updateApi.get.retType should be ("org.sample.users.shared.User")
   }
   property("the update api should have a Long and User arguments") {
-    updateApi.get.args.size should be (2)
+    updateApi.get.args.size should be (1)
     updateApi.get.args(0).replace(" ", "") should be ("id:Long")
-    updateApi.get.args(1).replace(" ", "") should be ("_body:org.sample.users.shared.User")
+  }
+  property("the update api should have only a User body argument") {
+    addApi.get.bodyType should be (Some("org.sample.users.shared.User"))
   }
 
   property("the users route file should produce a delete api") {
@@ -149,7 +151,7 @@ class PlayParserTest extends PropSpec with Matchers {
   //   "given a non-existent file" should {
   //     "produce FileNotFoundException" in {
   //       assertThrows[FileNotFoundException] {
-  //         PlayParser().parseFile(nonexistantFile)
+  //         PlayParser.parseFile(nonexistantFile)
   //       }
   //     }
   //   }

+ 59 - 0
plugin/src/main/scala/com/tflucke/webroutes/RPCGenerator.scala

@@ -0,0 +1,59 @@
+package com.tflucke.webroutes
+
+import sbt.{File,IO}
+import com.tflucke.webroutes.parsers.Parser
+import com.tflucke.webroutes.endpoints.EndpointFile
+
+/** Creates scala RPC APIs from definition files.
+  * 
+  * @author Thomas Flucke
+  */
+object RPCGenerator {
+  def apply(definitionFiles: Seq[EndpointFile], outpath: File) =
+    generateRPCFiles(definitionFiles, outpath)
+
+  def generateRPCFiles(
+    definitionFiles: Seq[EndpointFile],
+    outpath: File
+  ): Seq[File] = {
+    definitionFiles.flatMap({ file =>
+      val outFiles = file.definitions.groupBy[(String, String)]({
+        case (route, _) => (route.pack, route.controller)
+      }).map({ case ((pack, controller), routes) => {
+        val content = s"""package ${pack}
+
+import com.tflucke.webroutes.{APIRoute,APIRouteBody}
+import play.api.libs.json.{JsValue,Reads,Writes,Json}
+import org.scalajs.dom.XMLHttpRequest
+import org.scalajs.dom.ext.Ajax.InputData
+
+object ${controller} {
+${routes.map({
+  case (route, formatters) => route.toDefinition(formatters)
+}).mkString("\n")}
+}
+"""
+        val outFile = makeControllerFile(pack, controller, outpath)
+        IO.write(outFile, content)
+        outFile
+      } }).toSeq
+      outFiles
+    })
+  }
+
+  def makeControllerFile(path: String, controller: String, baseDir: File): File = {
+    val pattern = "\\.?([^.]+)(.*)".r
+    path match {
+      case pattern(head, rest) => {
+        val nextDir = new File(baseDir, head)
+        if (!nextDir.exists) {
+          nextDir.mkdir()
+        }
+        makeControllerFile(rest, controller, nextDir)
+      }
+      case _ => {
+        new File(baseDir, s"${controller}.scala")
+      }
+    }
+  }
+}

+ 39 - 0
plugin/src/main/scala/com/tflucke/webroutes/RestRPC.scala

@@ -0,0 +1,39 @@
+package com.tflucke.webroutes
+
+import sbt._
+import Keys.{sourceGenerators,resourceManaged,libraryDependencies}
+import com.tflucke.webroutes.parsers.Parser
+import com.tflucke.webroutes.endpoints.EndpointFile
+import org.portablescala.sbtplatformdeps.PlatformDepsPlugin.autoImport._
+
+/** The main entry point for the plugin.
+  * 
+  * Defines project tasks and default settings.
+  * 
+  * @author Thomas Flucke
+  */
+object RestRPC extends AutoPlugin {
+
+  object autoImport {
+    val generateRpc = taskKey[Seq[File]]("Generate scala client RPC APIs")
+    val apiDefinitions = taskKey[Seq[EndpointFile]]("List of files containing API definitions and their parsers")
+
+    lazy val baseRestRPCSettings: Seq[Def.Setting[_]] = Seq(
+      libraryDependencies += "org.tflucke" %%% "rest-rpc" % "0.1.0"
+    )
+
+    lazy val compileRestRPCSettings: Seq[Def.Setting[_]] = Seq(
+      apiDefinitions := Seq.empty,
+      sourceGenerators += generateRpc,
+      generateRpc := {
+        println("Generating Scala RPC objects...")
+        RPCGenerator(apiDefinitions.value, (Compile / resourceManaged).value)
+      }
+    )
+  }
+
+  import autoImport._
+
+  override lazy val projectSettings = baseRestRPCSettings ++
+    inConfig(Compile)(compileRestRPCSettings)
+}

+ 60 - 0
plugin/src/main/scala/com/tflucke/webroutes/RouteDef.scala

@@ -0,0 +1,60 @@
+package com.tflucke.webroutes
+
+import com.tflucke.webroutes.formatter._
+
+/* TODO: Right now a lot of this is hard coded strings which may become fragile.
+ */
+/** The metainformation for an endpoint nessicary to create an RPC API.
+  * 
+  * @author Thomas Flucke
+  */
+case class RouteDef(
+  method: String,
+  url: String,
+  pack: String,
+  controller: String,
+  fn: String,
+  args: Array[String],
+  bodyType: Option[String] = None,
+  bodyFormat: String = "json",
+  retType: String = "Any",
+  mime: String = "application/json"
+) extends FormatterTypes {
+  def interpolatedUrl: String = {
+    ":(?<symbol>[^/]+)".r.replaceAllIn(url, (matc) => "\\${${symbol}}")
+  }
+
+  def formatToMime(format: String) = format match {
+    case JSON => "application/json"
+    case TEXT => "text/plain"
+    case BINARY => ???
+    case FORM => ???
+  }
+
+  def mimeToFormat(mime: String) = mime match {
+    case "application/json" => JSON
+    case "text/json" => JSON
+    case "text/plain" => TEXT
+    case _ => ???
+  }
+
+  def toDefinition(fmters: Map[String, Formatter]): String = bodyType match {
+    case Some(bType) => s"""
+def ${fn}(${args.mkString(", ")}) : APIRouteBody[${bType}, ${retType}] = new APIRouteBody[${bType}, ${retType}](\"${method}\", s\"${interpolatedUrl}\", \"${mime}\") {
+    protected def convertBody(body: ${bType}): InputData =
+        ${fmters(bodyFormat).genSerializer("body", "InputData")}
+    protected def convertResult(xhr: XMLHttpRequest): ${retType} =
+        ${fmters(mimeToFormat(mime)).genDeserializer("xhr.responseText", retType)}
+    protected def acceptHeader = \"$mime\"
+    protected def contentTypeHeader = \"${formatToMime(bodyFormat)}\"
+}
+"""
+    case None => s"""
+def ${fn}(${args.mkString(", ")}) : APIRoute[${retType}] = new APIRoute[${retType}](\"${method}\", s\"${interpolatedUrl}\", \"${mime}\") {
+    protected def convert(xhr: XMLHttpRequest): ${retType} =
+        ${fmters(mimeToFormat(mime)).genDeserializer("xhr.responseText", retType)}
+    protected def acceptHeader = \"$mime\"
+}
+"""
+  }
+}

+ 24 - 0
plugin/src/main/scala/com/tflucke/webroutes/endpoints/EndpointFile.scala

@@ -0,0 +1,24 @@
+package com.tflucke.webroutes.endpoints
+
+import sbt.File
+import com.tflucke.webroutes.RouteDef
+import com.tflucke.webroutes.parsers.Parser
+import com.tflucke.webroutes.formatter._
+
+case class EndpointFile(
+  val file: File,
+  val parser: Parser,
+  val textFmt: TextFormatter = PlainTextFormatter,
+  val jsonFmt: JsonFormatter = NoJsonFormatter,
+  val binFmt:  BinaryFormatter = NoBinaryFormatter,
+  val formFmt: FormFormatter = NoFormFormatter
+) extends FormatterTypes {
+
+  def definitions: Seq[(RouteDef, Map[String, Formatter])] = 
+    parser.parseFile(file).map((_, Map(
+      TEXT -> textFmt,
+      JSON -> jsonFmt,
+      BINARY -> binFmt,
+      FORM -> formFmt
+    )))
+}

+ 17 - 0
plugin/src/main/scala/com/tflucke/webroutes/endpoints/ImplicitEndpointFile.scala

@@ -0,0 +1,17 @@
+package com.tflucke.webroutes.endpoints
+
+import sbt.File
+import com.tflucke.webroutes.parsers.Parser
+import com.tflucke.webroutes.formatter._
+
+object ImplicitEndpointFile {
+  def apply(
+    file: File,
+    parser: Parser
+  ) (implicit
+    textFmt: TextFormatter = PlainTextFormatter,
+    jsonFmt: JsonFormatter = NoJsonFormatter,
+    binFmt:  BinaryFormatter = NoBinaryFormatter,
+    formFmt: FormFormatter = NoFormFormatter
+  ) = new EndpointFile(file, parser, textFmt, jsonFmt, binFmt, formFmt)
+}

+ 14 - 0
plugin/src/main/scala/com/tflucke/webroutes/formatter/Formatter.scala

@@ -0,0 +1,14 @@
+package com.tflucke.webroutes.formatter
+
+sealed trait Formatter {
+  def genDeserializer(body: String, typ: String): String
+  def genSerializer(body: String, typ: String): String
+}
+
+trait JsonFormatter extends Formatter
+
+trait TextFormatter extends Formatter
+
+trait FormFormatter extends Formatter
+
+trait BinaryFormatter extends Formatter

+ 8 - 0
plugin/src/main/scala/com/tflucke/webroutes/formatter/FormatterTypes.scala

@@ -0,0 +1,8 @@
+package com.tflucke.webroutes.formatter
+
+trait FormatterTypes {
+  val TEXT = "text"
+  val JSON = "json"
+  val BINARY = "blob"
+  val FORM = "formdata"
+}

+ 21 - 0
plugin/src/main/scala/com/tflucke/webroutes/formatter/NoFormatter.scala

@@ -0,0 +1,21 @@
+package com.tflucke.webroutes.formatter
+
+object NoTextFormatter extends TextFormatter {
+  def genDeserializer(body: String, typ: String): String = ???
+  def genSerializer(body: String, typ: String): String = ???
+}
+
+object NoJsonFormatter extends JsonFormatter {
+  def genDeserializer(body: String, typ: String): String = ???
+  def genSerializer(body: String, typ: String): String = ???
+}
+
+object NoBinaryFormatter extends BinaryFormatter {
+  def genDeserializer(body: String, typ: String): String = ???
+  def genSerializer(body: String, typ: String): String = ???
+}
+
+object NoFormFormatter extends FormFormatter {
+  def genDeserializer(body: String, typ: String): String = ???
+  def genSerializer(body: String, typ: String): String = ???
+}

+ 6 - 0
plugin/src/main/scala/com/tflucke/webroutes/formatter/PlainTextFormatter.scala

@@ -0,0 +1,6 @@
+package com.tflucke.webroutes.formatter
+
+object PlainTextFormatter extends TextFormatter {
+  def genDeserializer(body: String, typ: String): String = s"$body"
+  def genSerializer(body: String, typ: String): String = s"$body.toString"
+}

+ 15 - 0
plugin/src/main/scala/com/tflucke/webroutes/parsers/Parser.scala

@@ -0,0 +1,15 @@
+package com.tflucke.webroutes.parsers
+
+import sbt.File
+import com.tflucke.webroutes.RouteDef
+
+/** Interface which takes a file and parses the API definitions.
+  * 
+  * If a parser does not exist for the format your definitions are in, you may
+  * create a new parser extending this trait.
+  * 
+  * @author Thomas Flucke
+  */
+trait Parser {
+  def parseFile(input: File): Seq[RouteDef]
+}

+ 0 - 23
plugin/src/main/scala/name/tflucke/webroutes/RouteDef.scala

@@ -1,23 +0,0 @@
-package name.tflucke.webroutes
-
-case class RouteDef(
-  method: String,
-  url: String,
-  pack: String,
-  controller: String,
-  fn: String,
-  args: Array[String],
-  retType: String = "Any",
-  retFormat: String = "json"
-) {
-  def interpolatedUrl: String = {
-    ":(?<symbol>[^/]+)".r.replaceAllIn(url, (matc) => "\\${${symbol}}")
-  }
-
-  def toDefinition(): String = s"""
-def ${fn}(${args.mkString(", ")}) : APIRoute[${retType}] = new APIRoute[${retType}](\"${method}\", s\"${interpolatedUrl}\", \"${retFormat}\") {
-    import play.api.libs.json.{JsValue,Reads}
-    protected def convert(json: JsValue): ${retType} = json.as[${retType}]
-}
-"""
-}

+ 0 - 47
plugin/src/main/scala/name/tflucke/webroutes/RouteGenerator.scala

@@ -1,47 +0,0 @@
-package name.tflucke.webroutes
-
-import sbt.{File,IO}
-import java.nio.file.Path
-import name.tflucke.webroutes.parsers.Parser
-
-object RouteGenerator {
-  def apply(routeFiles: Seq[(Path, Parser)], outpath: File) = generateRoutes(routeFiles, outpath)
-
-  def generateRoutes(routeFiles: Seq[(Path, Parser)], outpath: File): Seq[File] = {
-    routeFiles.flatMap({ case (routesFile: Path, parser: Parser) => {
-      val outFiles = parser.parseFile(routesFile.toFile).groupBy[(String, String)](
-        (route) => (route.pack, route.controller)
-      ).map(tuple => {
-        val ((pack, controller), routes) = tuple
-        val content = s"""package ${pack}
-
-import name.tflucke.webroutes.APIRoute
-
-object ${controller} {
-${routes.mkString("\n")}
-}
-"""
-        val outFile = makeControllerFile(pack, controller, outpath)
-        IO.write(outFile, content)
-        outFile
-      }).toSeq
-      outFiles
-    }})
-  }
-
-  def makeControllerFile(path: String, controller: String, baseDir: File): File = {
-    val pattern = "\\.?([^.]+)(.*)".r
-    path match {
-      case pattern(head, rest) => {
-        val nextDir = new File(baseDir, head)
-        if (!nextDir.exists) {
-          nextDir.mkdir()
-        }
-        makeControllerFile(rest, controller, nextDir)
-      }
-      case _ => {
-        new File(baseDir, s"${controller}.scala")
-      }
-    }
-  }
-}

+ 0 - 27
plugin/src/main/scala/name/tflucke/webroutes/WebRoutes.scala

@@ -1,27 +0,0 @@
-package name.tflucke.webroutes
-
-import sbt._
-import Keys.{sourceGenerators,resourceManaged}
-import name.tflucke.webroutes.parsers.Parser
-
-object WebRoutes extends AutoPlugin {
-  object autoImport {
-    val generateJsRoutes = taskKey[Seq[File]]("Generate scala client routes objects")
-    val apiDefinitions = taskKey[Seq[(java.nio.file.Path, Parser)]]("List of files containing API definitions and their parsers")
-
-    lazy val baseWebRouteSettings: Seq[Def.Setting[_]] = Seq(
-      sourceGenerators += generateJsRoutes,
-      // TODO: Automatically add dependancy
-      //libraryDependencies += "name.tflucke" %%% "web-routes" % "0.1.0",
-      generateJsRoutes := {
-        println("Generating Scala Web route objects...")
-        RouteGenerator(apiDefinitions.value, (Compile / resourceManaged).value)
-      }
-    )
-  }
-  import autoImport._
-
-  override lazy val projectSettings =
-    inConfig(Compile)(baseWebRouteSettings) ++
-    inConfig(Test)(baseWebRouteSettings)
-}

+ 0 - 8
plugin/src/main/scala/name/tflucke/webroutes/parsers/Parser.scala

@@ -1,8 +0,0 @@
-package name.tflucke.webroutes.parsers
-
-import sbt.File
-import name.tflucke.webroutes.RouteDef
-
-trait Parser {
-  def parseFile(input: File): Seq[RouteDef]
-}

+ 0 - 38
plugin/src/main/scala/name/tflucke/webroutes/parsers/PlayParser.scala

@@ -1,38 +0,0 @@
-package name.tflucke.webroutes.parsers
-
-import sbt.File
-import scala.io.Source
-import name.tflucke.webroutes.RouteDef
-
-class PlayParser extends Parser {
-  val routeStartPattern = raw"\s*#\s*Shared\s+Route\s*"
-  val propPattern = raw"\s*#\s*\w+\s*:\s*[^\s]+\s*"
-  val routePattern = raw"\s*(GET|PUT|POST|DELETE)\s+(/[^\s]*)+\s+@?([\.\w]+)\.([\w]+)\.([\w]+)\s*(\(\s*(\w+\s*:\s*\w+\s*)?(,\s*\w+\s*:\s*\w+)?\s*\))?"
-  val sharedRoutePattern = s"${routeStartPattern}\n((${propPattern}\n)*)${routePattern}".r
-
-  def parseFile(input: File): Seq[RouteDef] =
-    (for (route <- sharedRoutePattern.findAllMatchIn(Source.fromFile(input).mkString)) yield {
-      val propPattern = "\\s*#\\s*(\\w+)\\s*:\\s*([^\\s]+)\\s*\n".r
-      val props = propPattern.findAllMatchIn(route.group(1)).map(m => (m.group(1), m.group(2))).toList
-      def getProp(name: String): Option[String] = props.find(_._1.equalsIgnoreCase(name)).map(_._2)
-      val method = route.group(3)
-      val path = route.group(4)
-      val pack = route.group(5)
-      val obj = route.group(6)
-      val function = route.group(7)
-      val args = (Option(route.group(9)).getOrElse("") + Option(route.group(10)).getOrElse("")).split(",").filter(!_.isEmpty)
-      val argsWithBody = getProp("body") match {
-        case Some(typ) => args :+ s"_body:$typ"
-        case None => args
-      }
-      RouteDef(method, path, pack, obj, function, argsWithBody,
-        getProp("type") getOrElse "Any",
-        "text")//getProp("mime") getOrElse "json"
-    }).toList
-}
-
-object PlayParser {
-  val instance = new PlayParser()
-
-  def apply() = instance
-}

+ 1 - 1
project/build.properties

@@ -1 +1 @@
-sbt.version=1.3.10
+sbt.version=1.3.10

+ 8 - 0
test.sh

@@ -0,0 +1,8 @@
+#!/bin/sh
+
+if which xvfb-run > /dev/null; then
+    xvfb-run -s "-screen 0, 1366x768x24" sbt test scripted
+else
+    echo "Xvfb not installed..." 1>&2
+    sbt test scripted
+fi