Procházet zdrojové kódy

Added additional features from TODO list.

Added JVM support.

Added support for default arguments in PlayParser.

Added support for querystring arguments.
Thomas Flucke před 5 roky
rodič
revize
f84c54a598
24 změnil soubory, kde provedl 1024 přidání a 167 odebrání
  1. 55 12
      build.sbt
  2. 8 5
      library/js/src/main/scala/com/tflucke/webroutes/HTTPException.scala
  3. 42 0
      library/js/src/main/scala/com/tflucke/webroutes/Http.scala
  4. 0 0
      library/js/src/main/scala/com/tflucke/webroutes/TimeoutException.scala
  5. 20 0
      library/jvm/src/main/scala/jvm/com/tflucke/webroutes/HTTPException.scala
  6. 44 0
      library/jvm/src/main/scala/jvm/com/tflucke/webroutes/Http.scala
  7. 5 0
      library/jvm/src/main/scala/jvm/com/tflucke/webroutes/TimeoutException.scala
  8. 50 0
      library/shared/src/main/scala/shared/com/tflucke/webroutes/APIRoute.scala
  9. 0 0
      library/shared/src/main/scala/shared/com/tflucke/webroutes/Headers.scala
  10. 0 75
      library/src/main/scala/com/tflucke/webroutes/APIRoute.scala
  11. 1 1
      play/src/main/scala/com/tflucke/webroutes/formatter/PlayJsonFormatter.scala
  12. 124 28
      play/src/main/scala/com/tflucke/webroutes/parsers/PlayParser.scala
  13. 1 1
      play/src/sbt-test/webroutes/dummy/project/plugins.sbt
  14. 71 0
      play/src/test/resources/play-examples-route
  15. 506 0
      play/src/test/scala/com/tflucke/webroutes/unit/parsers/PlayExampleRouteTest.scala
  16. 16 12
      play/src/test/scala/com/tflucke/webroutes/unit/parsers/PlayParserTest.scala
  17. 8 8
      plugin/src/main/scala/com/tflucke/webroutes/RPCGenerator.scala
  18. 10 4
      plugin/src/main/scala/com/tflucke/webroutes/RestRPC.scala
  19. 3 3
      plugin/src/main/scala/com/tflucke/webroutes/endpoints/EndpointFile.scala
  20. 10 11
      plugin/src/main/scala/com/tflucke/webroutes/models/RouteDef.scala
  21. 44 5
      plugin/src/main/scala/com/tflucke/webroutes/models/URL.scala
  22. 2 2
      plugin/src/main/scala/com/tflucke/webroutes/parsers/Parser.scala
  23. 2 0
      project/plugins.sbt
  24. 2 0
      test.sh

+ 55 - 12
build.sbt

@@ -1,8 +1,12 @@
+val VersionSuffix = Option(System.getenv("VERSION_SUFFIX")).getOrElse("")
+
 lazy val root = project.in(file("."))
   .aggregate(
     plugin,
-    library,
-    playPlugin
+    libraryJvm,
+    libraryJs,
+    playPlugin,
+    //openapiPlugin
   ).settings(
     crossScalaVersions := Nil,
     publish / skip := true,
@@ -26,18 +30,34 @@ lazy val plugin = (project in file("plugin"))
   .settings(
     addSbtPlugin("org.portable-scala" % "sbt-platform-deps" % "1.0.0"),
     name := "sbt-rest-rpc",
-    version := "0.1.0-SNAPSHOT",
-    publishLocal := (publishLocal dependsOn (library / publishLocal)).value
-  ).dependsOn(library)
+    version := s"0.3.1$VersionSuffix",
+    Compile / resourceGenerators += Def.task {
+      val file = (Compile / resourceManaged).value / "version.txt"
+      IO.write(file, (libraryJvm / version).value)
+      Seq(file)
+    }.taskValue,
+    publishLocal := (publishLocal
+      dependsOn (libraryJvm / publishLocal)
+      dependsOn (libraryJs / publishLocal)
+    ).value
+  ).dependsOn(libraryJvm)
+  .dependsOn(libraryJs)
 
 /****** The Plugin Library ******/
 
-lazy val library = (project in file("library"))
-  .enablePlugins(ScalaJSPlugin)
+lazy val library = crossProject(JSPlatform, JVMPlatform)
+  .in(file("library"))
   .settings(commonSettings)
   .settings(
     name := "rest-rpc",
-    version := "0.1.0-SNAPSHOT",
+    version := s"0.3.1$VersionSuffix",
+    crossScalaVersions += "2.13.3"
+  )
+  .jvmSettings(
+    libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.0",
+    libraryDependencies +=  "org.scalaj" %% "scalaj-http" % "2.4.2"
+  )
+  .jsSettings(
     libraryDependencies ++= (if (scalaJSVersion.startsWith("0.6.")) Seq(
       // Wrapper library for JS dom to scala
       // Docs: https://scala-js.github.io/scala-js-dom/
@@ -47,8 +67,10 @@ lazy val library = (project in file("library"))
     ) else Seq(
       "org.scala-js" %%% "scalajs-dom" % "1.0.0",
       "com.typesafe.play" %%% "play-json" % "2.9.0"
-    ))      
+    ))
   )
+lazy val libraryJvm = library.jvm
+lazy val libraryJs = library.js
 
 /******* The Play Plugin *******/
 
@@ -56,12 +78,33 @@ lazy val playPlugin = (project in file("play"))
   .enablePlugins(SbtPlugin)
   .settings(commonSettings)
   .settings(
-    addSbtPlugin("com.tflucke" % "sbt-rest-rpc" % "0.1.0-SNAPSHOT"),
+    addSbtPlugin("com.tflucke" % "sbt-rest-rpc" % "0.3.1-SNAPSHOT"),
     name := "sbt-rest-rpc-play",
-    version := "0.1.0-SNAPSHOT",
+    version := s"0.3.1$VersionSuffix",
     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)
+
+/******* The OpenAPI Plugin *******/
+
+lazy val openapiPlugin = (project in file("openapi"))
+  .enablePlugins(SbtPlugin)
+  .settings(commonSettings)
+  .settings(
+    addSbtPlugin("com.tflucke" % "sbt-rest-rpc" % "0.3.1-SNAPSHOT"),
+    name := "sbt-rest-rpc-openapi",
+    version := s"0.3.1$VersionSuffix",
+    libraryDependencies ++= Seq(
+      "io.swagger.parser.v3" % "swagger-parser" % "2.0.22",
+      "org.scalatest" %% "scalatest" % "3.0.8" % Test
+    ),
+    logBuffered in Test := false,
     scriptedLaunchOpts := { scriptedLaunchOpts.value ++ Seq(
       "-Xmx1024M",
       "-Dplugin.version="+version.value,

+ 8 - 5
library/src/main/scala/com/tflucke/webroutes/HTTPException.scala → library/js/src/main/scala/com/tflucke/webroutes/HTTPException.scala

@@ -1,20 +1,23 @@
 package com.tflucke.webroutes
 
-import org.scalajs.dom.ext.AjaxException
-import scala.util.Try
-
-case class HTTPException(val statusCode: Int, ex: AjaxException)
-    extends Exception {
+import org.scalajs.dom.ext.{Ajax,AjaxException}
 
+class HTTPException(ex: AjaxException) extends Exception {
   type ParserFn[T] = (String) => T
 
   initCause(ex)
 
   override val getMessage = ex.xhr.statusText
 
+  val statusCode = ex.xhr.status
   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)
 }
+
+object HTTPException {
+  def unapply(x: HTTPException): Option[(Int, String)] =
+    Some((x.statusCode, x.responseText))
+}

+ 42 - 0
library/js/src/main/scala/com/tflucke/webroutes/Http.scala

@@ -0,0 +1,42 @@
+package com.tflucke.webroutes
+
+import org.scalajs.dom.XMLHttpRequest
+import org.scalajs.dom.ext.{Ajax,AjaxException}
+import scala.concurrent.{Future,ExecutionContext}
+import scala.util.{Try,Success,Failure}
+
+object Http {
+  def submit(
+    method: String,
+    url: String,
+    body: Option[Http.Request],
+    timeout: Int,
+    headers: Headers,
+    format: String
+  )(implicit ec: ExecutionContext): Future[Http.Response] = Ajax(
+    method,
+    url,
+    body.getOrElse(null),
+    timeout,
+    headers.map,
+    false,
+    format
+  ).transform(wrapFailureException)
+
+  type Request = org.scalajs.dom.ext.Ajax.InputData
+
+  class Response(xhr: XMLHttpRequest) {
+    val responseText = xhr.responseText
+    val status = xhr.status
+    val statusText = xhr.statusText
+    def header(name: String) = Option(xhr.getResponseHeader(name))
+  }
+
+  private def wrapFailureException[T](x: Try[XMLHttpRequest]) = x match {
+    case Success(res) => Success(new Http.Response(res))
+    case Failure(ex: AjaxException) if ex.isTimeout && ex.xhr.status == 0 =>
+      Failure(TimeoutException(ex))
+    case Failure(ex: AjaxException) => Failure(new HTTPException(ex))
+    case Failure(ex) => Failure(ex)
+  }
+}

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


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

@@ -0,0 +1,20 @@
+package com.tflucke.webroutes
+
+class HTTPException(res: scalaj.http.HttpStatusException) extends Exception {
+  type ParserFn[T] = (String) => T
+
+  initCause(res)
+
+  override val getMessage = res.statusLine + ": " + res.body
+
+  val statusCode = res.code
+  val responseText = res.body
+  def responseType = "text/plain"
+  val statusText = res.statusLine
+  def responseObject[T](implicit fn: ParserFn[T]) = fn(res.body)
+}
+
+object HTTPException {
+  def unapply(x: HTTPException): Option[(Int, String)] =
+    Some((x.statusCode, x.responseText))
+}

+ 44 - 0
library/jvm/src/main/scala/jvm/com/tflucke/webroutes/Http.scala

@@ -0,0 +1,44 @@
+package com.tflucke.webroutes
+
+import scala.concurrent.{Future,blocking,ExecutionContext}
+import scala.util.{Try,Success,Failure}
+
+object Http {
+  def submit(
+    method: String,
+    url: String,
+    body: Option[Http.Request],
+    timeout: Int,
+    headers: Headers,
+    format: String
+  )(implicit ec: ExecutionContext): Future[Http.Response] = Future {
+    val request = scalaj.http.Http(url)
+      .method(method)
+      .timeout(timeout, timeout)
+      .headers(headers.map)
+    blocking {
+      ((method, body) match {
+        case ("POST", Some(b)) => request.postData(b)
+        case ("PUT",  Some(b)) => request.put(b)
+        case (_, _) => request
+      }).asString.throwError
+    }
+  } transform(wrapFailureException _)
+
+  type Request = String
+
+  class Response(resp: scalaj.http.HttpResponse[String]) {
+    val responseText = resp.body
+    val status = resp.code
+    val statusText = resp.statusLine
+    def header(name: String) = resp.header(name)
+  }
+
+  import scalaj.http.HttpStatusException
+
+  private def wrapFailureException[T](x: Try[scalaj.http.HttpResponse[String]]) = x match {
+    case Success(res) if res.isSuccess => Success(new Http.Response(res))
+    case Failure(res: HttpStatusException) => Failure(new HTTPException(res))
+    case Failure(res) => Failure(res)
+  }
+}

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

@@ -0,0 +1,5 @@
+package com.tflucke.webroutes
+
+case class TimeoutException(val ex: scalaj.http.HttpResponse[String]) extends Exception {
+  override val getMessage = s"Connection timed out."
+}

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

@@ -0,0 +1,50 @@
+package com.tflucke.webroutes
+
+import scala.concurrent.{Future,ExecutionContext}
+import scala.util.{Try,Success,Failure}
+
+abstract class APIRoute[T](val method: String, val url: String, format: String) {
+  protected def convert(xhr: Http.Response): T
+  protected def acceptHeader: String
+
+  def apply(timeout: Int = 0)(implicit
+    ec: ExecutionContext,
+    headers: Headers = Headers.empty
+  ) : Future[T] = Http.submit(
+    method,
+    url,
+    None,
+    timeout,
+    (headers + ("Accept" -> acceptHeader)),
+    format
+  ).map(convert _)
+}
+
+abstract class APIRouteBody[B, R](
+  val method: String,
+  val url: String,
+  format: String
+) {
+  protected def convertBody(body: B): Http.Request
+  protected def convertResult(resp: Http.Response): R
+  protected def contentTypeHeader: String
+  protected def acceptHeader: String
+
+  def apply(body: B, timeout: Int = 0)(implicit
+    ec: ExecutionContext,
+    headers: Headers = Headers.empty
+  ) : Future[R] = try {
+    Http.submit(
+      method,
+      url,
+      Some(convertBody(body)),
+      timeout,
+      (headers
+        + ("Accept" -> acceptHeader)
+        + ("Content-Type" -> contentTypeHeader)),
+      format
+    ).map(convertResult _)
+  } catch {
+    case ex: Exception => Future.failed(ex)
+  }
+}

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


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

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

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

@@ -4,7 +4,7 @@ import com.tflucke.webroutes.models.FullName
 
 object PlayJsonFormatter extends JsonFormatter {
   def genDeserializer(body: String, typ: FullName): String =
-    s"Json.parse($body).as[$typ]"
+    s"Json.using[Json.WithDefaultValues].parse($body).as[$typ]"
   def genSerializer(body: String, typ: FullName): String =
     s"Json.stringify(Json.toJson($body))"
 }

+ 124 - 28
play/src/main/scala/com/tflucke/webroutes/parsers/PlayParser.scala

@@ -1,8 +1,11 @@
 package com.tflucke.webroutes.parsers
 
-import sbt.File
+import sbt.{File,Logger}
 import scala.io.Source
 import com.tflucke.webroutes.models.{Format,FullName,RouteDef,URL}
+import scala.util.{Try,Success,Failure}
+import java.text.ParseException
+import URL.{EmbeddedSymbol,QuerySymbol}
 
 /** A parse which will extract information from a Play! Framework routes file.
   * 
@@ -41,37 +44,130 @@ import com.tflucke.webroutes.models.{Format,FullName,RouteDef,URL}
   * @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
+  val importPattern = raw"^\s*->".r
+  val modifierPattern = raw"^\s*+\s*(\w+)".r
+  val routeStartPattern = raw"^\s*#\s*Shared\s+Route\s*$$".r
+  val emptyLinePattern = raw"^\s*#?\s*$$".r
+  val nonCommentPattern = raw"^\s*[^#]".r
+  val propPattern = raw"^\s*#\s*(\w+)\s*:\s*([^\s]+)\s*$$".r
+  val routePattern =
+    raw"^\s*(GET|PATCH|POST|PUT|DELETE|HEAD)\s+(/[^\s]*)+\s+(.+)".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 symbolReg = raw":(\w+)".r
-      val path = URL.fromString(route.group(4)) {
-        case symbolReg(sym) => Some(Symbol(sym))
+  private def nextOption(it: Iterator[String]) =
+    if (it.hasNext) Some(it.next()) else None
+
+  def parseFile(input: File, log: Logger): Seq[RouteDef] = {
+    def parseIterator(it: Iterator[String]): Seq[RouteDef] = {
+      nextOption(it) match {
+        case Some(routeStartPattern()) =>
+          parseRoute(it, input.getName(), log) match {
+            case Success(route) => route +: parseIterator(it)
+            case Failure(err) =>
+              log.error(err.getMessage())
+              parseIterator(it)
+          }
+        case Some(importPattern()) =>
+          // TODO
+          log.error("Importing route files is not yet supported.")
+          parseIterator(it)
+        case Some(modifierPattern(mod)) =>
+          // TODO
+          log.warn(s"Route modifier ($mod) is not recognized and will be ignored.")
+          parseIterator(it)
+        case Some(_) => parseIterator(it)
+        case None => Nil
+      }
+    }
+    parseIterator(Source.fromFile(input).getLines())
+  }
+
+  private def parseRoute(
+    it: Iterator[String],
+    filename: String,
+    log: Logger,
+    props: Map[String, String] = Map.empty
+  ): Try[RouteDef] = nextOption(it) match {
+    case None => Failure(
+      new ParseException(s"Incomplete route in file $filename.", 0)
+    )
+    case Some(emptyLinePattern()) => parseRoute(it, filename, log, props)
+    case Some(propPattern(prop, value)) =>
+      parseRoute(it, filename, log, props ++ Seq((prop -> value)))
+    case Some(modifierPattern(mod)) =>
+      log.warn(s"Route modifier ($mod) is not recognized and will be ignored.")
+      parseRoute(it, filename, log, props)
+    case Some(routePattern(method, path, scala)) => {
+      val symbolReg = raw"^:(\w+)$$".r
+      val longSymReg = raw"^\*(\w+)$$".r
+      val regexSymReg = raw"^\$$(\w+)<.*>$$".r
+      val (retMime, retType) = getReturnType(props.get("mime"), props.get("type"))
+      val url = URL.fromString(path) {
+        case symbolReg(sym) => Some(EmbeddedSymbol(sym))
+        case longSymReg(sym) => Some(EmbeddedSymbol(sym))
+        case regexSymReg(sym) => Some(EmbeddedSymbol(sym))
         case _ => None
       }
-      val pack = FullName(route.group(5))
-      val obj = Symbol(route.group(6))
-      val function = Symbol(route.group(7))
-      val args = Option(route.group(9)).map[Seq[String]]({ first =>
-        Option(route.group(10)).map[Seq[String]](first +: _.split(",")).getOrElse(Seq(first))
-      }).getOrElse(Nil).map({ arg =>
-        val arr = arg.split(":")
-        (Symbol(arr(0).trim), FullName(arr(1).trim))
+      parseScalaLine(scala, log).map({
+        case (pack, obj, function, args) => RouteDef(method, url.copy(query = 
+          args.map({
+            case (sym, FullName(Seq("Seq"), _), _) =>
+              QuerySymbol(sym.name, QuerySymbol.repeatEncoder)
+            case (sym, FullName(Seq("scala", "collection", "Seq"), _), _) =>
+              QuerySymbol(sym.name, QuerySymbol.repeatEncoder)
+            case (sym, FullName(Seq("Option"), _), _) =>
+              QuerySymbol(sym.name, QuerySymbol.optionEncoder)
+            case (sym, FullName(Seq("scala", "Option"), _), _) =>
+              QuerySymbol(sym.name, QuerySymbol.optionEncoder)
+            case (sym, _, _) => QuerySymbol(sym.name)
+          }).filterNot(url.path.flatMap({
+            case Right(sym) => Some(sym)
+            case Left(_) => None
+          }).contains).map(arg => (arg.name, Right(arg))).toMap
+        ), pack, obj, function, args,
+          props.get("body").map(FullName(_)),
+          props.get("content").map(Format.fromString _) getOrElse Format.JSON,
+          FullName(retType),
+          retMime)
       })
-      val (retMime, retType) = getReturnType(getProp("mime"), getProp("type"))
-      RouteDef(method, path, pack, obj, function, args, getProp("body").map(FullName(_)),
-        getProp("content").map(Format.fromString _) getOrElse Format.JSON,
-        FullName(retType),
-        retMime)
-    }).toList
+    }
+    case Some(line) =>
+      log.verbose(s"Unparsable line in $filename:\n$line\n.")
+      parseRoute(it, filename, log, props)
+  }
+
+  private def parseScalaLine(line: String, log: Logger) = {
+    val pattern = raw"^@?((\w+\.)*)(\w+)\.(\w+)\s*(\(.*\))?".r
+    line match {
+      case pattern(pack, _, obj, fn, argStrMaybe) =>
+        val argStr = Option(argStrMaybe)
+        Success((FullName(pack.substring(0, pack.length - 1)),
+          Symbol(obj),
+          Symbol(fn),
+          argStr.map(str => str.substring(1, str.length - 1).split(',').map({str =>
+            val (name, rest) = str.trim.span(x => x.isLetterOrDigit || x == '_')
+            val rest1 = rest.trim
+            val (typ, rest2) = if (rest1.nonEmpty && rest1(0) == ':')
+              rest1.substring(1).trim.span(x =>
+                x.isLetterOrDigit || x == '_' || x == '.' || x == '[' || x == ']'
+              )
+            else ("String", rest1)
+            val rest3 = rest2.trim
+            if (name.isEmpty)
+              None
+            else if (rest3.isEmpty)
+              Some((Symbol(name), FullName(typ), None))
+            else if (rest3(0) == '=')
+              None
+            else if (rest3(0) == '?')
+              Some((Symbol(name), FullName(typ), Some(rest3.substring(2).trim)))
+            else {
+              log.error(s"Unexpected character '${rest3(0)}")
+              None
+            }
+          }).toSeq.flatten).getOrElse(Nil)))
+      case _ => Failure(new ParseException(s"Unparsable scala line:\n$line\n.", 0))
+    }
+  }
 
   def getReturnType(mime: Option[String], typ: Option[String]) =
     (mime, typ) match {

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

@@ -5,5 +5,5 @@ 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")
+  case _ => addSbtPlugin("com.tflucke"       % "sbt-rest-rpc-play" % "0.2.0-SNAPSHOT")
 }

+ 71 - 0
play/src/test/resources/play-examples-route

@@ -0,0 +1,71 @@
+# Routes
+# Contains each of the example definitions from the official documentation:
+# https://www.playframework.com/documentation/latest/ScalaRouting
+# Some examples are altered slightly to be compatible with the other examples or
+# to allow for multiple supported combinations with the RPC-Rest syntax.
+
+# Shared Route
+GET   /clients/:id          controllers.Clients.show(id: Long)
+
+# Shared Route
+# Display a client.
+GET   /clients/display/:id  controllers.Clients.display(id: Long)
+
+# Not yet supported
+# ->      /api              api.MyRouter
+
++ nocsrf
+# Shared Route
+POST  /api/new              controllers.Api.newThing
+
+# Shared Route
++ nocsrf
+POST  /api/make             controllers.Api.makeThing
+
+# Shared Route
+GET   /clients/all          controllers.Clients.listAll()
+
+# Shared Route
+GET   /files/*name          controllers.Application.download(name)
+
+# Shared Route
+GET   /items/$id<[0-9]+>    controllers.Items.show(id: Long)
+
+# Shared Route
+GET   /                     controllers.Application.homePage()
+
+# Shared Route
+# Extract the page parameter from the path, or fix the value for /
+GET   /                     controllers.Application.show(page = "home")
+
+# Shared Route
+# Extract the page parameter from the path.
+GET   /:page                controllers.Application.show(page)
+
+# Shared Route
+# Extract the page parameter from the query string.
+GET   /                     controllers.Application.display(page)
+
+# Shared Route
+# Pagination links, like /clients?page=3
+GET   /clients              controllers.Clients.list(page: Int ?= 1)
+
+# Shared Route
+# The version parameter is optional. E.g. /api/list-all?version=3.0
+GET   /api/list-all         controllers.Api.list(version: Option[String])
+
+# Shared Route
+# Redirects to https://www.playframework.com/ with 303 See Other
+GET   /about      controllers.Default.redirect(to = "https://www.playframework.com/")
+
+# Shared Route
+# Responds with 404 Not Found
+GET   /orders     controllers.Default.notFound
+
+# Shared Route
+# Responds with 500 Internal Server Error
+GET   /clients    controllers.Default.error
+
+# Shared Route
+# Responds with 501 Not Implemented
+GET   /posts      controllers.Default.todo

+ 506 - 0
play/src/test/scala/com/tflucke/webroutes/unit/parsers/PlayExampleRouteTest.scala

@@ -0,0 +1,506 @@
+package com.tflucke.webroutes.unit.parsers
+
+import sbt.{File,Logger}
+import com.tflucke.webroutes.models.{FullName,Method,URL}
+import URL.{EmbeddedSymbol,QuerySymbol}
+import com.tflucke.webroutes.models.URL.PathStep
+import com.tflucke.webroutes.parsers.PlayParser
+import org.scalatest.{PropSpec,Matchers}
+
+class PlayExampleRouteTest extends PropSpec with Matchers {
+  def makeRouteFile(name: String): File =
+    new File(getClass.getClassLoader.getResource(name).getPath)
+
+  val apis = PlayParser.parseFile(makeRouteFile("play-examples-route"),
+    Logger.Null)
+
+  {
+    val api = apis.find(api =>
+      api.fn == 'show && api.controller == 'Clients
+    )
+    property("Clients.show route should parse completely") {
+      api should not be None
+    }
+    property("Clients.show route should use the GET method") {
+      api.get.method should be (Method.GET)
+    }
+    property("Clients.show route should use the path /clients/:id") {
+      api.get.url should matchPattern {
+        case URL(_, _, _, Seq(Left(""), Left("clients"),
+          Right(EmbeddedSymbol("id", _))), _) =>
+      }
+    }
+    property("Clients.show route should use the controllers package") {
+      api.get.pack should matchPattern {
+        case FullName(Seq("controllers"), Nil) =>
+      }
+    }
+    property("Clients.show route should use the Clients controller") {
+      api.get.controller.name should be ("Clients")
+    }
+    property("Clients.show route should use the show function") {
+      api.get.fn.name should be ("show")
+    }
+    property("Clients.show route should have a single Long argument") {
+      api.get.args.size should be (1)
+      api.get.args(0) should be (('id, FullName("Long"), None))
+    }
+  }
+
+  {
+    val api = apis.find(api =>
+      api.fn == 'display && api.controller == 'Clients
+    )
+    property("Clients.display route should parse completely") {
+      api should not be None
+    }
+    property("Clients.display route should use the GET method") {
+      api.get.method should be (Method.GET)
+    }
+    property("Clients.display route should use the path /clients/display/:id") {
+      api.get.url should matchPattern {
+        case URL(_, _, _,
+          Seq(Left(""),
+          Left("clients"),
+          Left("display"),
+          Right(EmbeddedSymbol("id", _))), _) =>
+      }
+    }
+    property("Clients.display route should use the controllers package") {
+      api.get.pack should matchPattern {
+        case FullName(Seq("controllers"), Nil) =>
+      }
+    }
+    property("Clients.display route should use the Clients controller") {
+      api.get.controller.name should be ("Clients")
+    }
+    property("Clients.display route should use the display function") {
+      api.get.fn.name should be ("display")
+    }
+    property("Clients.display route should have a single Long argument") {
+      api.get.args.size should be (1)
+      api.get.args(0) should be (('id, FullName("Long"), None))
+    }
+  }
+
+  {
+    val api = apis.find(api => api.fn == 'newThing)
+    property("Api.newThing route should parse completely") {
+      api should not be None
+    }
+    property("Api.newThing route should use the POST method") {
+      api.get.method should be (Method.POST)
+    }
+    property("Api.newThing route should use the path /api/new") {
+      api.get.url should matchPattern {
+        case URL(_, _, _, Seq(Left(""), Left("api"), Left("new")), _) =>
+      }
+    }
+    property("Api.newThing route should use the controllers package") {
+      api.get.pack should matchPattern {
+        case FullName(Seq("controllers"), Nil) =>
+      }
+    }
+    property("Api.newThing route should use the Api controller") {
+      api.get.controller.name should be ("Api")
+    }
+    property("Api.newThing route should use the newThing function") {
+      api.get.fn.name should be ("newThing")
+    }
+    property("Api.newThing route should have no arguments") {
+      api.get.args.size should be (0)
+    }
+  }
+
+  {
+    val api = apis.find(api => api.fn == 'makeThing)
+    property("Api.makeThing route should parse completely") {
+      api should not be None
+    }
+    property("Api.makeThing route should use the POST method") {
+      api.get.method should be (Method.POST)
+    }
+    property("Api.makeThing route should use the path /api/make") {
+      api.get.url should matchPattern {
+        case URL(_, _, _, Seq(Left(""), Left("api"), Left("make")), _) =>
+      }
+    }
+    property("Api.makeThing route should use the controllers package") {
+      api.get.pack should matchPattern {
+        case FullName(Seq("controllers"), Nil) =>
+      }
+    }
+    property("Api.makeThing route should use the Api controller") {
+      api.get.controller.name should be ("Api")
+    }
+    property("Api.makeThing route should use the makeThing function") {
+      api.get.fn.name should be ("makeThing")
+    }
+    property("Api.makeThing route should have no arguments") {
+      api.get.args.size should be (0)
+    }
+  }
+
+  {
+    val api = apis.find(api => api.fn == 'listAll)
+    property("Clients.listAll route should parse completely") {
+      api should not be None
+    }
+    property("Clients.listAll route should use the GET method") {
+      api.get.method should be (Method.GET)
+    }
+    property("Clients.listAll route should use the path /clients/all") {
+      api.get.url should matchPattern {
+        case URL(_, _, _, Seq(Left(""), Left("clients"), Left("all")), _) =>
+      }
+    }
+    property("Clients.listAll route should use the controllers package") {
+      api.get.pack should matchPattern {
+        case FullName(Seq("controllers"), Nil) =>
+      }
+    }
+    property("Clients.listAll route should use the Clients controller") {
+      api.get.controller.name should be ("Clients")
+    }
+    property("Clients.listAll route should use the listAll function") {
+      api.get.fn.name should be ("listAll")
+    }
+    property("Clients.listAll route should have no arguments") {
+      api.get.args.size should be (0)
+    }
+  }
+
+  {
+    val api = apis.find(api => api.fn == 'download)
+    property("Application.download route should parse completely") {
+      api should not be None
+    }
+    property("Application.download route should use the GET method") {
+      api.get.method should be (Method.GET)
+    }
+    property("Application.download route should use the path /files/*name") {
+      api.get.url should matchPattern {
+        case URL(_, _, _, Seq(Left(""), Left("files"),
+          Right(EmbeddedSymbol("name", _))), _) =>
+      }
+    }
+    property("Application.download route should use the controllers package") {
+      api.get.pack should matchPattern {
+        case FullName(Seq("controllers"), Nil) =>
+      }
+    }
+    property("Application.download route should use the Application controller") {
+      api.get.controller.name should be ("Application")
+    }
+    property("Application.download route should use the download function") {
+      api.get.fn.name should be ("download")
+    }
+    property("Application.download route should have a single String argument") {
+      api.get.args.size should be (1)
+      api.get.args(0) should be (('name, FullName("String"), None))
+    }
+  }
+
+  {
+    val api = apis.find(api => api.fn == 'show && api.controller == 'Items)
+    property("Items.show route should parse completely") {
+      api should not be None
+    }
+    property("Items.show route should use the GET method") {
+      api.get.method should be (Method.GET)
+    }
+    property("Items.show route should use the path /items/:id") {
+      api.get.url should matchPattern {
+        case URL(_, _, _, Seq(Left(""), Left("items"),
+          Right(EmbeddedSymbol("id", _))), _) =>
+      }
+    }
+    property("Items.show route should use the controllers package") {
+      api.get.pack should matchPattern {
+        case FullName(Seq("controllers"), Nil) =>
+      }
+    }
+    property("Items.show route should use the Items controller") {
+      api.get.controller.name should be ("Items")
+    }
+    property("Items.show route should use the show function") {
+      api.get.fn.name should be ("show")
+    }
+    property("Items.show route should have a single Long argument") {
+      api.get.args.size should be (1)
+      api.get.args(0) should be (('id, FullName("Long"), None))
+    }
+  }
+
+  {
+    val api = apis.find(api =>
+      api.fn == 'homePage && api.controller == 'Application
+    )
+    property("Application.homePage route should parse completely") {
+      api should not be None
+    }
+    property("Application.homePage route should use the GET method") {
+      api.get.method should be (Method.GET)
+    }
+    property("Application.homePage route should use the path /") {
+      api.get.url should matchPattern {
+        case URL(_, _, _, Seq(Left(""), Left("")), _) =>
+      }
+    }
+    property("Application.homePage route should use the controllers package") {
+      api.get.pack should matchPattern {
+        case FullName(Seq("controllers"), Nil) =>
+      }
+    }
+    property("Application.homePage route should use the Application controller") {
+      api.get.controller.name should be ("Application")
+    }
+    property("Application.homePage route should use the homePage function") {
+      api.get.fn.name should be ("homePage")
+    }
+    property("Application.homePage route should have no arguments") {
+      api.get.args.size should be (0)
+    }
+  }
+
+  {
+    val api = apis.find(api => api.fn == 'show && api.controller == 'Application)
+    property("Application.show route should parse completely") {
+      api should not be None
+    }
+    property("Application.show route should use the GET method") {
+      api.get.method should be (Method.GET)
+    }
+    property("Application.show route should use the path /") {
+      api.get.url should matchPattern {
+        case URL(_, _, _, Seq(Left(""), Left("")), _) =>
+      }
+    }
+    property("Application.show route should use the controllers package") {
+      api.get.pack should matchPattern {
+        case FullName(Seq("controllers"), Nil) =>
+      }
+    }
+    property("Application.show route should use the Application controller") {
+      api.get.controller.name should be ("Application")
+    }
+    property("Application.show route should use the show function") {
+      api.get.fn.name should be ("show")
+    }
+    property("Application.show route should have no arguments") {
+      api.get.args.size should be (0)
+    }
+  }
+
+  {
+    val api = apis.find(api => api.fn == 'display && api.controller == 'Application)
+    property("Application.display route should parse completely") {
+      api should not be None
+    }
+    property("Application.display route should use the GET method") {
+      api.get.method should be (Method.GET)
+    }
+    property("Application.display route should use the path /") {
+      api.get.url should matchPattern {
+        case URL(_, _, _, Seq(Left(""), Left("")), _) =>
+      }
+    }
+    property("Application.display route should use the controllers package") {
+      api.get.pack should matchPattern {
+        case FullName(Seq("controllers"), Nil) =>
+      }
+    }
+    property("Application.display route should use the Application controller") {
+      api.get.controller.name should be ("Application")
+    }
+    property("Application.display route should use the display function") {
+      api.get.fn.name should be ("display")
+    }
+    property("Application.display route should have a single String argument") {
+      api.get.args.size should be (1)
+      api.get.args(0) should be (('page, FullName("String"), None))
+    }
+  }
+
+  {
+    val api = apis.find(api => api.controller =='Clients && api.fn == 'list)
+    property("Clients.list route should parse completely") {
+      api should not be None
+    }
+    property("Clients.list route should use the GET method") {
+      api.get.method should be (Method.GET)
+    }
+    property("Clients.list route should use the path /clients") {
+      api.get.url should matchPattern {
+        case URL(_, _, _, Seq(Left(""), Left("clients")), map)
+            if map("page") == Right(
+              QuerySymbol("page", QuerySymbol.defaultEncoder)
+            ) =>
+      }
+    }
+    property("Clients.list route should use the controllers package") {
+      api.get.pack should matchPattern {
+        case FullName(Seq("controllers"), Nil) =>
+      }
+    }
+    property("Clients.list route should use the Clients controller") {
+      api.get.controller.name should be ("Clients")
+    }
+    property("Clients.list route should use the list function") {
+      api.get.fn.name should be ("list")
+    }
+    property("Clients.list route should have a single Int argument with default") {
+      api.get.args.size should be (1)
+      api.get.args(0) should be (('page, FullName("Int"), Some("1")))
+    }
+  }
+
+  {
+    val api = apis.find(api => api.controller =='Api && api.fn == 'list)
+    property("Api.list route should parse completely") {
+      api should not be None
+    }
+    property("Api.list route should use the GET method") {
+      api.get.method should be (Method.GET)
+    }
+    property("Api.list route should use the path /api/list-all") {
+      api.get.url should matchPattern {
+        case URL(_, _, _, Seq(Left(""), Left("api"), Left("list-all")), map)
+            if map("version") == Right(
+              QuerySymbol("version", QuerySymbol.optionEncoder)
+            ) =>
+      }
+    }
+    property("Api.list route should use the controllers package") {
+      api.get.pack should matchPattern {
+        case FullName(Seq("controllers"), Nil) =>
+      }
+    }
+    property("Api.list route should use the Api controller") {
+      api.get.controller.name should be ("Api")
+    }
+    property("Api.list route should use the list function") {
+      api.get.fn.name should be ("list")
+    }
+    property("Api.list route should have a single Optional argument") {
+      api.get.args.size should be (1)
+      api.get.args(0) should be (('version, FullName("Option[String]"), None))
+    }
+  }
+
+  {
+    val api = apis.find(api => api.fn == 'redirect)
+    property("Default.redirect route should parse completely") {
+      api should not be None
+    }
+    property("Default.redirect route should use the GET method") {
+      api.get.method should be (Method.GET)
+    }
+    property("Default.redirect route should use the path /about") {
+      api.get.url should matchPattern {
+        case URL(_, _, _, Seq(Left(""), Left("about")), _) =>
+      }
+    }
+    property("Default.redirect route should use the controllers package") {
+      api.get.pack should matchPattern {
+        case FullName(Seq("controllers"), Nil) =>
+      }
+    }
+    property("Default.redirect route should use the Default controller") {
+      api.get.controller.name should be ("Default")
+    }
+    property("Default.redirect route should use the redirect function") {
+      api.get.fn.name should be ("redirect")
+    }
+    property("Default.redirect route should have no arguments") {
+      api.get.args.size should be (0)
+    }
+  }
+
+  {
+    val api = apis.find(api => api.fn == 'notFound)
+    property("Default.notFound route should parse completely") {
+      api should not be None
+    }
+    property("Default.notFound route should use the GET method") {
+      api.get.method should be (Method.GET)
+    }
+    property("Default.notFound route should use the path /orders") {
+      api.get.url should matchPattern {
+        case URL(_, _, _, Seq(Left(""), Left("orders")), _) =>
+      }
+    }
+    property("Default.notFound route should use the controllers package") {
+      api.get.pack should matchPattern {
+        case FullName(Seq("controllers"), Nil) =>
+      }
+    }
+    property("Default.notFound route should use the Default controller") {
+      api.get.controller.name should be ("Default")
+    }
+    property("Default.notFound route should use the notFound function") {
+      api.get.fn.name should be ("notFound")
+    }
+    property("Default.notFound route should have no arguments") {
+      api.get.args.size should be (0)
+    }
+  }
+
+  {
+    val api = apis.find(api => api.fn == 'error)
+    property("Default.error route should parse completely") {
+      api should not be None
+    }
+    property("Default.error route should use the GET method") {
+      api.get.method should be (Method.GET)
+    }
+    property("Default.error route should use the path /clients") {
+      api.get.url should matchPattern {
+        case URL(_, _, _, Seq(Left(""), Left("clients")), _) =>
+      }
+    }
+    property("Default.error route should use the controllers package") {
+      api.get.pack should matchPattern {
+        case FullName(Seq("controllers"), Nil) =>
+      }
+    }
+    property("Default.error route should use the Default controller") {
+      api.get.controller.name should be ("Default")
+    }
+    property("Default.error route should use the error function") {
+      api.get.fn.name should be ("error")
+    }
+    property("Default.error route should have no arguments") {
+      api.get.args.size should be (0)
+    }
+  }
+
+  {
+    val api = apis.find(api => api.fn == 'todo)
+    property("Default.todo route should parse completely") {
+      api should not be None
+    }
+    property("Default.todo route should use the GET method") {
+      api.get.method should be (Method.GET)
+    }
+    property("Default.todo route should use the path /posts") {
+      api.get.url should matchPattern {
+        case URL(_, _, _, Seq(Left(""), Left("posts")), _) =>
+      }
+    }
+    property("Default.todo route should use the controllers package") {
+      api.get.pack should matchPattern {
+        case FullName(Seq("controllers"), Nil) =>
+      }
+    }
+    property("Default.todo route should use the Default controller") {
+      api.get.controller.name should be ("Default")
+    }
+    property("Default.todo route should use the todo function") {
+      api.get.fn.name should be ("todo")
+    }
+    property("Default.todo route should have no arguments") {
+      api.get.args.size should be (0)
+    }
+  }
+}

+ 16 - 12
play/src/test/scala/com/tflucke/webroutes/unit/parsers/PlayParserTest.scala

@@ -1,14 +1,15 @@
 package com.tflucke.webroutes.unit.parsers
 
-import sbt.File
-import scala.io.Source
-import com.tflucke.webroutes.models.{FullName,Method,RouteDef,URL}
+import sbt.{File,Logger}
+import com.tflucke.webroutes.models.{FullName,Method,URL}
+import URL.EmbeddedSymbol
 import com.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)
+  def makeRouteFile(name: String): File =
+    new File(getClass.getClassLoader.getResource(name).getPath)
 
   val emptyFile = makeRouteFile("empty-route")
   val noSharedFile = makeRouteFile("no-shared-route")
@@ -17,14 +18,14 @@ class PlayParserTest extends PropSpec with Matchers {
   val userFile = makeRouteFile("user-route")
 
   property("an empty file should produce an empty list") {
-    PlayParser.parseFile(emptyFile).size should be (0)
+    PlayParser.parseFile(emptyFile, Logger.Null).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, Logger.Null)
   val queryApi = apis.find(api => api.fn == 'query)
   val addApi = apis.find(api => api.fn == 'addUser)
   val getApi = apis.find(api => api.fn == 'get)
@@ -39,7 +40,8 @@ class PlayParserTest extends PropSpec with Matchers {
   }
   property("the get api should have the correct URL") {
     getApi.get.url should matchPattern {
-      case URL(_, _, _, Seq(Left(""), Left("user"), Right('id)), _) =>
+      case URL(_, _, _, Seq(Left(""), Left("user"),
+        Right(EmbeddedSymbol(id, _))), _) =>
     }
   }
   property("the get api should have the correct package") {
@@ -57,7 +59,7 @@ class PlayParserTest extends PropSpec with Matchers {
   }
   property("the get api should have a single Long argument") {
     getApi.get.args.size should be (1)
-    getApi.get.args(0) should be (('id, FullName("Long")))
+    getApi.get.args(0) should be (('id, FullName("Long"), None))
   }
 
   property("the users route file should produce a query api") {
@@ -128,7 +130,8 @@ class PlayParserTest extends PropSpec with Matchers {
   }
   property("the update api should have the correct URL") {
     updateApi.get.url should matchPattern {
-      case URL(_, _, _, Seq(Left(""), Left("user"), Right('id)), _) =>
+      case URL(_, _, _, Seq(Left(""), Left("user"),
+        Right(EmbeddedSymbol(id, _))), _) =>
     }
   }
   property("the update api should have the correct package") {
@@ -146,7 +149,7 @@ class PlayParserTest extends PropSpec with Matchers {
   }
   property("the update api should have a Long and User arguments") {
     updateApi.get.args.size should be (1)
-    updateApi.get.args(0) should be (('id, FullName("Long")))
+    updateApi.get.args(0) should be (('id, FullName("Long"), None))
   }
   property("the update api should have only a User body argument") {
     addApi.get.bodyType should matchPattern {
@@ -162,7 +165,8 @@ class PlayParserTest extends PropSpec with Matchers {
   }
   property("the delete api should have the correct URL") {
     deleteApi.get.url should matchPattern {
-      case URL(_, _, _, Seq(Left(""), Left("user"), Right('id)), _) =>
+      case URL(_, _, _, Seq(Left(""), Left("user"),
+        Right(EmbeddedSymbol(id, _))), _) =>
     }
   }
   property("the delete api should have the correct package") {
@@ -180,7 +184,7 @@ class PlayParserTest extends PropSpec with Matchers {
   }
   property("the delete api should have a Long and User arguments") {
     deleteApi.get.args.size should be (1)
-    getApi.get.args(0) should be (('id, FullName("Long")))
+    getApi.get.args(0) should be (('id, FullName("Long"), None))
   }
 
   // "PlayParser" when {

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

@@ -1,6 +1,6 @@
 package com.tflucke.webroutes
 
-import sbt.{File,IO}
+import sbt.{File,IO,Logger}
 import com.tflucke.webroutes.models.FullName
 import com.tflucke.webroutes.parsers.Parser
 import com.tflucke.webroutes.endpoints.EndpointFile
@@ -10,22 +10,22 @@ import com.tflucke.webroutes.endpoints.EndpointFile
   * @author Thomas Flucke
   */
 object RPCGenerator {
-  def apply(definitionFiles: Seq[EndpointFile], outpath: File) =
-    generateRPCFiles(definitionFiles, outpath)
+  def apply(definitionFiles: Seq[EndpointFile], outpath: File, log: Logger) =
+    generateRPCFiles(definitionFiles, outpath, log)
 
   def generateRPCFiles(
     definitionFiles: Seq[EndpointFile],
-    outpath: File
+    outpath: File,
+    log: Logger
   ): Seq[File] = {
     definitionFiles.flatMap({ file =>
-      val outFiles = file.definitions.groupBy[(FullName, Symbol)]({
+      val outFiles = file.definitions(log).groupBy[(FullName, Symbol)]({
         case (route, _) => (route.pack, route.controller)
       }).map({ case ((pack, controller), routes) => {
         val content = s"""package ${pack}
 
-import com.tflucke.webroutes.{APIRoute,APIRouteBody}
+import com.tflucke.webroutes.{Http,APIRoute,APIRouteBody}
 import play.api.libs.json.{JsValue,Reads,Writes,Json}
-import org.scalajs.dom.XMLHttpRequest
 
 object ${controller.name} {
 ${routes.map({
@@ -53,6 +53,6 @@ ${routes.map({
       }
       makeControllerFile(rest, controller, nextDir)
     }
-    case _ => new File(baseDir, s"${controller}.scala")
+    case _ => new File(baseDir, s"${controller.name}.scala")
   }
 }

+ 10 - 4
plugin/src/main/scala/com/tflucke/webroutes/RestRPC.scala

@@ -1,10 +1,11 @@
 package com.tflucke.webroutes
 
 import sbt._
-import Keys.{sourceGenerators,resourceManaged,libraryDependencies}
+import Keys.{sourceGenerators,resourceManaged,libraryDependencies,streams}
 import com.tflucke.webroutes.parsers.Parser
 import com.tflucke.webroutes.endpoints.EndpointFile
 import org.portablescala.sbtplatformdeps.PlatformDepsPlugin.autoImport._
+import scala.io.Source
 
 /** The main entry point for the plugin.
   * 
@@ -14,20 +15,25 @@ import org.portablescala.sbtplatformdeps.PlatformDepsPlugin.autoImport._
   */
 object RestRPC extends AutoPlugin {
 
+  val version = "0.3.1-SNAPSHOT" //Source.fromResource("version.txt").mkString
+
   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")
+    val rpcLogger = taskKey[Logger]("Output log for issues and messages.")
 
     lazy val baseRestRPCSettings: Seq[Def.Setting[_]] = Seq(
-      libraryDependencies += "org.tflucke" %%% "rest-rpc" % "0.1.0"
+      libraryDependencies += "com.tflucke" %%% "rest-rpc" % version,
+      rpcLogger := streams.value.log
     )
 
     lazy val compileRestRPCSettings: Seq[Def.Setting[_]] = Seq(
       apiDefinitions := Seq.empty,
       sourceGenerators += generateRpc,
       generateRpc := {
-        println("Generating Scala RPC objects...")
-        RPCGenerator(apiDefinitions.value, (Compile / resourceManaged).value)
+        val log = rpcLogger.value
+        log.info("Generating Scala RPC objects...")
+        RPCGenerator(apiDefinitions.value, (Compile / resourceManaged).value, log)
       }
     )
   }

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

@@ -1,6 +1,6 @@
 package com.tflucke.webroutes.endpoints
 
-import sbt.File
+import sbt.{File,Logger}
 import com.tflucke.webroutes.models.{Format,RouteDef}
 import com.tflucke.webroutes.parsers.Parser
 import com.tflucke.webroutes.formatter._
@@ -14,8 +14,8 @@ case class EndpointFile(
   val formFmt: FormFormatter = NoFormFormatter
 ) {
 
-  def definitions: Seq[(RouteDef, Map[Format.Format, Formatter])] = 
-    parser.parseFile(file).map((_, Map(
+  def definitions(log: Logger): Seq[(RouteDef, Map[Format.Format, Formatter])] = 
+    parser.parseFile(file, log).map((_, Map(
       Format.TEXT -> textFmt,
       Format.JSON -> jsonFmt,
       Format.BINARY -> binFmt,

+ 10 - 11
plugin/src/main/scala/com/tflucke/webroutes/models/RouteDef.scala

@@ -12,7 +12,7 @@ case class RouteDef(
   pack: FullName,
   controller: Symbol,
   fn: Symbol,
-  args: Seq[(Symbol, FullName)],
+  args: Seq[(Symbol, FullName, Option[String])],
   bodyType: Option[FullName] = None,
   bodyFormat: Format.Format = Format.JSON,
   retType: FullName = FullName(Seq("Any")),
@@ -33,28 +33,27 @@ case class RouteDef(
     case _ => ???
   }
 
-  // TODO: Externalize below into a language backend
-  def scalaInterpolator(sym: Symbol) = s"$${${sym.name}}"
-
-  private def argStr =
-    args.map({case (sym, typ) => s"${sym.name}: $typ"}).mkString(", ")
+  private def argStr = args.map({
+    case (sym, typ, None) => s"${sym.name}: $typ"
+    case (sym, typ, Some(defaul)) => s"${sym.name}: $typ = $defaul"
+  }).mkString(", ")
 
   def toDefinition(fmters: Map[Format.Format, Formatter]): String = bodyType match {
     case Some(bType) => s"""
-def ${fn.name}($argStr) = new APIRouteBody[$bType, $retType](\"$method\", s\"${url.interpolatable(scalaInterpolator)}\", \"$mime\") {
-    protected def convertBody(body: $bType): org.scalajs.dom.ext.Ajax.InputData =
+def ${fn.name}($argStr) = new APIRouteBody[$bType, $retType](\"$method\", s\"${url.interpolate}\", \"$mime\") {
+    protected def convertBody(body: $bType): Http.Request =
         ${fmters(bodyFormat).genSerializer("body", FullName(Seq(
           "org", "scalajs", "dom", "ext", "Ajax", "InputData"
         )))}
-    protected def convertResult(xhr: XMLHttpRequest): $retType =
+    protected def convertResult(xhr: Http.Response): $retType =
         ${fmters(mimeToFormat(mime)).genDeserializer("xhr.responseText", retType)}
     protected def acceptHeader = \"$mime\"
     protected def contentTypeHeader = \"${formatToMime(bodyFormat)}\"
 }
 """
     case None => s"""
-def ${fn.name}($argStr) = new APIRoute[$retType](\"$method\", s\"${url.interpolatable(scalaInterpolator)}\", \"$mime\") {
-    protected def convert(xhr: XMLHttpRequest): $retType =
+def ${fn.name}($argStr) = new APIRoute[$retType](\"$method\", s\"${url.interpolate}\", \"$mime\") {
+    protected def convert(xhr: Http.Response): $retType =
         ${fmters(mimeToFormat(mime)).genDeserializer("xhr.responseText", retType)}
     protected def acceptHeader = \"$mime\"
 }

+ 44 - 5
plugin/src/main/scala/com/tflucke/webroutes/models/URL.scala

@@ -14,19 +14,58 @@ case class URL(
   host: String = "",
   port: Option[Short] = None,
   path: Seq[URL.PathStep] = Seq.empty,
-  query: Map[String, URL.PathStep] = Map.empty[String, URL.PathStep]
+  query: Map[String, URL.QueryValue] = Map.empty[String, URL.QueryValue]
 ) {
-  def interpolatable(fn: Symbol => String) = protocol.map(_+"://").getOrElse("") +
+  import URL.{EmbeddedSymbol,QuerySymbol}
+
+  def interpolate = protocol.map(_+"://").getOrElse("") +
     host +
     port.map(p => f":$p%hu").getOrElse("") +
-    path.map(_.fold(identity, fn)).mkString("/")
+    path.map(_.fold(identity, { case EmbeddedSymbol(name, encoder) =>
+      encoder(name)
+    })).mkString("/") +
+    "?" + query.map({
+      case (name, Left(value)) =>
+        QuerySymbol.defaultEncoder(name, Symbol(value))
+      case (name, Right(QuerySymbol(value, encoder))) =>
+        encoder(name, Symbol(value))
+    }).mkString("&")
 }
 
 object URL {
-  type PathStep = Either[String, Symbol]
+  type PathStep = Either[String, EmbeddedSymbol]
+  type QueryValue = Either[String, QuerySymbol]
+  case class EmbeddedSymbol(
+    val name: String,
+    val encoder: (String) => String = EmbeddedSymbol.defaultEncoder
+  )
+
+  object EmbeddedSymbol {
+    val defaultEncoder = {(v: String) => s"$${${v}}"}
+  }
+
+  case class QuerySymbol(
+    val name: String,
+    val encoder: (String, Symbol) => String = QuerySymbol.defaultEncoder
+  )
+
+  object QuerySymbol {
+    val defaultEncoder = {(id: String, v: Symbol) => s"$id=$${${v.name}}"}
+
+    val csvEncoder = {(id: String, valSym: Symbol) =>
+      s"""$id=$${${valSym.name}.mkString(",")}"""}
+
+    val repeatEncoder = {(id: String, valSym: Symbol) => 
+      s"""$${${valSym.name}.map(v => s"$id=$$v").mkString("&")}"""
+    }
+
+    val optionEncoder = {(id: String, valSym: Symbol) => 
+      s"""$${${valSym.name}.map(v => s"$id=$$v").getOrElse("")}"""
+    }
+  }
 
   // TODO: Unit test this
-  def fromString(str: String)(symParser: String => Option[Symbol]) = {
+  def fromString(str: String)(symParser: String => Option[EmbeddedSymbol]) = {
     val protoReg = raw"""(\w+://)?"""
     val hostReg = raw"([-a-zA-Z0-9\.]*)"
     val portReg = raw"(:\d+)?"

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

@@ -1,6 +1,6 @@
 package com.tflucke.webroutes.parsers
 
-import sbt.File
+import sbt.{File,Logger}
 import com.tflucke.webroutes.models.RouteDef
 
 /** Interface which takes a file and parses the API definitions.
@@ -11,5 +11,5 @@ import com.tflucke.webroutes.models.RouteDef
   * @author Thomas Flucke
   */
 trait Parser {
-  def parseFile(input: File): Seq[RouteDef]
+  def parseFile(input: File, log: Logger): Seq[RouteDef]
 }

+ 2 - 0
project/plugins.sbt

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

+ 2 - 0
test.sh

@@ -1,5 +1,7 @@
 #!/bin/sh
 
+export VERSION_SUFFIX="-SNAPSHOT"
+
 if which xvfb-run > /dev/null; then
     xvfb-run -s "-screen 0, 1366x768x24" sbt test scripted
 else