Просмотр исходного кода

Refactored and added tests.

Renamed project with more specific jargon and wrote a README describing basic purpose
+ usage.

Refactored parser into a sperate component so that it can replaced for portability.

Added test framework and unit tests for the Play! parser.
Thomas Flucke 5 лет назад
Родитель
Сommit
7e39a26bbe

+ 150 - 0
README.md

@@ -0,0 +1,150 @@
+# Scala REST RPC
+
+## What is REST?
+
+Rest is a programming paradigm built around using HTTP methods and status codes
+to communicate between a client and server.
+
+A RESTful API uses HTTP methods to communicate actions.  For example `GET` is a
+request for information.  `PUT` means insert or update information.  And `DELETE`
+removes information.
+
+The path describes the data to operate on.  For example `/user/123/address` might
+refer to the address of a user with the id 123.
+
+The body of a request contains the details about the request.  For example the
+body of the request `PUT /user/123/address` might contain a new address to
+associate with the user.
+
+The response body will often contain the result of the transaction or an error
+message.
+
+## What is RPC?
+
+RPC is a programming paradigm where a program calls a function which then runs on
+another system.  From the client program's perspective, the API looks just like a
+normal function call.  But the arguments to the function are actually sent to the
+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.
+
+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
+function call into this HTTP request:
+```HTTP
+PUT /user/123/address HTTP/1.1
+Accept: application/json
+
+{
+   "line1": "123 Home Ave.",
+   "line2": null,
+   "zip_code": 12345,
+   "city": "St. Sample",
+   "state": "CA"
+}
+```
+And then transform the server's response into a strongly typed response.  All
+without writing any HTTP manipulation.
+
+## Why use REST RPC
+
+Several reasons:
+
+* Type safety between client and server
+* Cleanliness of client code
+* Simplifies tracking the relationships between front-end and back-end
+* Flexibility in renaming APIs during development
+
+## How do I use REST RPC?
+
+First, add the plugin to SBT by inserting this line into `project/plugins.sbt`:
+```scala
+addSbtPlugin("name.tflucke"       % "sbt-rest-rpc"            % "0.1.0")
+```
+
+Second, add the plugin to your project in `build.sbt`:
+```scala
+enablePlugins(RestRpc)
+
+/* Or if you are using subprojects */
+
+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"
+)
+```
+
+### 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:
+
+One is to create a routes file similar to the one play! framework uses.  Even if
+it is not used by the server, REST RPC can still parse it to create the wrapper
+APIs.
+
+Another option is to write an `APIParser` for whichever tool you use to create
+your APIs.  If you feel like you have something useful and robust, feel free to
+share and we might add it to the plugin for other users.
+
+### What if I my client communicates with multiple servers?
+
+This is not a problem.  Add a config file containing a list of APIs for each of
+the servers or one file with a complete list.  REST RPC will generate wrapper
+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?
+
+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)
+```
+Outputs:
+```
+/user/123/address
+```
+
+Additionally, the second call allows the user to specify HTTP arguments
+separately from RPC arguments.  For example:
+```scala
+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`).
+
+> 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.
+
+## 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
+* Source file parsers

+ 4 - 3
build.sbt

@@ -1,5 +1,4 @@
 lazy val root = project.in(file("."))
-  //.settings(settings)
   .aggregate(
     plugin,
     library
@@ -10,7 +9,7 @@ lazy val root = project.in(file("."))
 lazy val plugin = (project in file("plugin"))
   .enablePlugins(SbtPlugin)
   .settings(
-    name := "sbt-web-routes",
+    name := "sbt-rest-rpc",
     organization := "name.tflucke",
     //maintainer := "Thomas Flucke <admin@tflucke.name>",
     version := "0.1.0",
@@ -18,6 +17,8 @@ lazy val plugin = (project in file("plugin"))
       "-deprecation",
       "-unchecked",
     ),
+    libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.8" % "test",
+    logBuffered in Test := false
   )
 
 /****** The Plugin Library ******/
@@ -25,7 +26,7 @@ lazy val plugin = (project in file("plugin"))
 lazy val library = (project in file("library"))
   .enablePlugins(ScalaJSPlugin)
   .settings(
-    name := "web-routes",
+    name := "rest-rpc",
     organization := "name.tflucke",
     //maintainer := "Thomas Flucke <admin@tflucke.name>",
     version := "0.1.0",

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

@@ -14,7 +14,7 @@ case class RouteDef(
     ":(?<symbol>[^/]+)".r.replaceAllIn(url, (matc) => "\\${${symbol}}")
   }
 
-  override def toString: String = s"""
+  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}]

+ 9 - 31
plugin/src/main/scala/name/tflucke/webroutes/RouteGenerator.scala

@@ -1,39 +1,17 @@
 package name.tflucke.webroutes
 
 import sbt.{File,IO}
+import java.nio.file.Path
+import name.tflucke.webroutes.parsers.Parser
 
 object RouteGenerator {
-  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]+.controllers).([\w]+).([\w]+)\s*(\((\w+\s*:\s*\w+\s*)?(,\s*\w+\s*:\s*\w+)?\))?"
-  // TODO: Fix this pattern since it's easier to interpret than the current one
-  //val sharedRoutePattern = s"${routeStartPattern}\n((${propPattern}\n)*)\n${routePattern}\n".r
+  def apply(routeFiles: Seq[(Path, Parser)], outpath: File) = generateRoutes(routeFiles, outpath)
 
-  val sharedRoutePattern = ("\\s*#\\s*Shared\\s+Route\\s*\n" +
-    "((\\s*#\\s*\\w+\\s*:\\s*[^\\s]+\\s*\n)*)" +
-    "\\s*(GET|PUT|POST|DELETE)\\s+(/[^\\s]*)+\\s+@?([\\.\\w]+.controllers).([\\w]+).([\\w]+)\\s*(\\((\\w+\\s*:\\s*\\w+\\s*)?(,\\s*\\w+\\s*:\\s*\\w+)?\\))?").r
-
-  def apply(routeFiles: Seq[java.nio.file.Path], outpath: File) = generateRoutes(routeFiles, outpath)
-
-  def generateRoutes(routeFiles: Seq[java.nio.file.Path], outpath: File): Seq[File] = {
-    routeFiles.flatMap((routesFile: java.nio.file.Path) => {
-      import scala.io.Source
-
-      val routeGroups = (for (route <- sharedRoutePattern.findAllMatchIn(Source.fromFile(routesFile.toFile).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)))
-        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(",")
-        RouteDef(method, path, pack, obj, function, args,
-          getProp("type") getOrElse "Any",
-          "text")//getProp("mime") getOrElse "json"
-      }).toList.groupBy[(String, String)]((route) => (route.pack, route.controller))
-      val outFiles = routeGroups.map(tuple => {
+  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}
 
@@ -48,7 +26,7 @@ ${routes.mkString("\n")}
         outFile
       }).toSeq
       outFiles
-    })
+    }})
   }
 
   def makeControllerFile(path: String, controller: String, baseDir: File): File = {

+ 4 - 6
plugin/src/main/scala/name/tflucke/webroutes/WebRoutes.scala

@@ -1,11 +1,13 @@
 package name.tflucke.webroutes
 
 import sbt._
-import Keys._
+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,
@@ -13,11 +15,7 @@ object WebRoutes extends AutoPlugin {
       //libraryDependencies += "name.tflucke" %%% "web-routes" % "0.1.0",
       generateJsRoutes := {
         println("Generating Scala Web route objects...")
-        // TODO: Figure out what the minimum changes necessary are.
-        println(generateJsRoutes.inputFiles)
-        println(generateJsRoutes.inputFileChanges.created)
-        println(generateJsRoutes.inputFileChanges.modified)
-        RouteGenerator(generateJsRoutes.inputFiles, (Compile / resourceManaged).value)
+        RouteGenerator(apiDefinitions.value, (Compile / resourceManaged).value)
       }
     )
   }

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

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

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

@@ -0,0 +1,38 @@
+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
+}

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


+ 17 - 0
plugin/src/test/resources/no-shared-route

@@ -0,0 +1,17 @@
+# Routes
+# This file defines all application routes (Higher priority routes first)
+# https://www.playframework.com/documentation/latest/ScalaRouting
+# ~~~~
+
+# An example controller showing a sample home page
+GET     /                                   org.sample.users.HomeController.index
+GET     /user/:id                           org.sample.users.UserController.get( id: Long )
+PUT     /user                               org.sample.users.UserController.addUser()
+POST    /user/:id                           org.sample.users.UserController.updateUser (id: Long)
+DELETE  /user/:id                           org.sample.users.UserController.delete(id :Long)
+
+
+# Map static resources from the /public folder to the /assets URL path
+GET     /assets/*file                       controllers.Assets.versioned(path="/public", file: Asset)
+# Forward Webjar requests to the webjar routes
+->      /webjars                            webjars.Routes

+ 30 - 0
plugin/src/test/resources/user-route

@@ -0,0 +1,30 @@
+# Routes
+# This file defines all application routes (Higher priority routes first)
+# https://www.playframework.com/documentation/latest/ScalaRouting
+# ~~~~
+
+# An example controller showing a sample home page
+GET     /                                   org.sample.users.HomeController.index
+# Shared Route
+# type: Seq[org.sample.users.shared.User]
+GET     /user                               org.sample.users.UserController.query
+# Shared Route
+# type: org.sample.users.shared.User
+GET     /user/:id                           org.sample.users.UserController.get( id: Long )
+# Shared Route
+# body: org.sample.users.shared.User
+# type: org.sample.users.shared.User
+PUT     /user                               org.sample.users.UserController.addUser()
+# Shared Route
+# body: org.sample.users.shared.User
+# type: org.sample.users.shared.User
+POST    /user/:id                           org.sample.users.UserController.updateUser (id: Long)
+# Shared Route
+# type: org.sample.users.shared.User
+DELETE  /user/:id                           org.sample.users.UserController.delete(id :Long)
+
+
+# Map static resources from the /public folder to the /assets URL path
+GET     /assets/*file                       controllers.Assets.versioned(path="/public", file: Asset)
+# Forward Webjar requests to the webjar routes
+->      /webjars                            webjars.Routes

+ 157 - 0
plugin/src/test/scala/name/tflucke/webroutes/unit/parsers/PlayParserTest.scala

@@ -0,0 +1,157 @@
+package name.tflucke.webroutes.unit.parsers
+
+import sbt.File
+import scala.io.Source
+import name.tflucke.webroutes.RouteDef
+import name.tflucke.webroutes.parsers.PlayParser
+import org.scalatest._
+import java.io.FileNotFoundException
+
+class PlayParserTest extends PropSpec with Matchers {
+  def makeRouteFile(name: String): File = new File(getClass.getClassLoader.getResource(name).getPath)
+
+  val emptyFile = makeRouteFile("empty-route")
+  val noSharedFile = makeRouteFile("no-shared-route")
+  val nonexistantFile = new File("does-not-exist") /* Cannot load non-existent resource */
+  val userFile = makeRouteFile("user-route")
+
+  property("an empty file should produce an empty list") {
+    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 queryApi = apis.find(api => api.fn == "query")
+  val addApi = apis.find(api => api.fn == "addUser")
+  val getApi = apis.find(api => api.fn == "get")
+  val updateApi = apis.find(api => api.fn == "updateUser")
+  val deleteApi = apis.find(api => api.fn == "delete")
+
+  property("the users route file should produce a get api") {
+    getApi should not be None
+  }
+  property("the get api should use the GET method") {
+    getApi.get.method should be ("GET")
+  }
+  property("the get api should have the correct URL") {
+    getApi.get.url should be ("/user/:id")
+  }
+  property("the get api should have the correct package") {
+    getApi.get.pack should be ("org.sample.users")
+  }
+  property("the get api should have the UserController") {
+    getApi.get.controller should be ("UserController")
+  }
+  property("the get api should have return type User") {
+    getApi.get.retType should be ("org.sample.users.shared.User")
+  }
+  property("the get api should have a single Long argument") {
+    getApi.get.args.size should be (1)
+    getApi.get.args(0).replace(" ", "") should be ("id:Long")
+  }
+
+  property("the users route file should produce a query api") {
+    queryApi should not be None
+  }
+  property("the query api should use the GET method") {
+    queryApi.get.method should be ("GET")
+  }
+  property("the query api should have the correct URL") {
+    queryApi.get.url should be ("/user")
+  }
+  property("the query api should have the correct package") {
+    queryApi.get.pack should be ("org.sample.users")
+  }
+  property("the query api should have the UserController") {
+    queryApi.get.controller should be ("UserController")
+  }
+  property("the query api should have return type Seq[User]") {
+    queryApi.get.retType should be ("Seq[org.sample.users.shared.User]")
+  }
+  property("the query api should have no arguments") {
+    queryApi.get.args.size should be (0)
+  }
+
+  property("the users route file should produce a add api") {
+    addApi should not be None
+  }
+  property("the add api should use the PUT method") {
+    addApi.get.method should be ("PUT")
+  }
+  property("the add api should have the correct URL") {
+    addApi.get.url should be ("/user")
+  }
+  property("the add api should have the correct package") {
+    addApi.get.pack should be ("org.sample.users")
+  }
+  property("the add api should have the UserController") {
+    addApi.get.controller should be ("UserController")
+  }
+  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 users route file should produce a update api") {
+    updateApi should not be None
+  }
+  property("the update api should use the POST method") {
+    updateApi.get.method should be ("POST")
+  }
+  property("the update api should have the correct URL") {
+    updateApi.get.url should be ("/user/:id")
+  }
+  property("the update api should have the correct package") {
+    updateApi.get.pack should be ("org.sample.users")
+  }
+  property("the update api should have the UserController") {
+    updateApi.get.controller should be ("UserController")
+  }
+  property("the update api should have return type User") {
+    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(0).replace(" ", "") should be ("id:Long")
+    updateApi.get.args(1).replace(" ", "") should be ("_body:org.sample.users.shared.User")
+  }
+
+  property("the users route file should produce a delete api") {
+    deleteApi should not be None
+  }
+  property("the delete api should use the DELETE method") {
+    deleteApi.get.method should be ("DELETE")
+  }
+  property("the delete api should have the correct URL") {
+    deleteApi.get.url should be ("/user/:id")
+  }
+  property("the delete api should have the correct package") {
+    deleteApi.get.pack should be ("org.sample.users")
+  }
+  property("the delete api should have the UserController") {
+    deleteApi.get.controller should be ("UserController")
+  }
+  property("the delete api should have return type User") {
+    deleteApi.get.retType should be ("org.sample.users.shared.User")
+  }
+  property("the delete api should have a Long and User arguments") {
+    deleteApi.get.args.size should be (1)
+    getApi.get.args(0).replace(" ", "") should be ("id:Long")
+  }
+
+  // "PlayParser" when {
+  //   "given a non-existent file" should {
+  //     "produce FileNotFoundException" in {
+  //       assertThrows[FileNotFoundException] {
+  //         PlayParser().parseFile(nonexistantFile)
+  //       }
+  //     }
+  //   }
+  // }
+}

+ 1 - 1
project/plugins.sbt

@@ -1,3 +1,3 @@
 val ScalaJSVersion = Option(System.getenv("SCALAJS_VERSION")).getOrElse("1.0.0")
 
-addSbtPlugin("org.scala-js"       % "sbt-scalajs"               % ScalaJSVersion)
+addSbtPlugin("org.scala-js"         % "sbt-scalajs"               % ScalaJSVersion)