Jelajahi Sumber

Created a strictly styled AST for route parsing.

Some of the functions for parsing these can and should be unit tested in the core
plugin.

This will hopefully make the process less closely tied to the Play <-> Scala.js
environment.
Thomas Flucke 5 tahun lalu
induk
melakukan
140e5f56f3
20 mengubah file dengan 370 tambahan dan 154 penghapusan
  1. 4 2
      play/src/main/scala/com/tflucke/webroutes/formatter/PlayJsonFormatter.scala
  2. 20 11
      play/src/main/scala/com/tflucke/webroutes/parsers/PlayParser.scala
  3. 0 0
      play/src/sbt-test/webroutes/dummy/server/logs/application.log
  4. 4 1
      play/src/sbt-test/webroutes/user/server/conf/testing.conf
  5. 0 0
      play/src/sbt-test/webroutes/user/server/logs/application.log
  6. 72 36
      play/src/test/scala/com/tflucke/webroutes/unit/parsers/PlayParserTest.scala
  7. 15 16
      plugin/src/main/scala/com/tflucke/webroutes/RPCGenerator.scala
  8. 0 60
      plugin/src/main/scala/com/tflucke/webroutes/RouteDef.scala
  9. 7 7
      plugin/src/main/scala/com/tflucke/webroutes/endpoints/EndpointFile.scala
  10. 4 2
      plugin/src/main/scala/com/tflucke/webroutes/formatter/Formatter.scala
  11. 0 8
      plugin/src/main/scala/com/tflucke/webroutes/formatter/FormatterTypes.scala
  12. 10 8
      plugin/src/main/scala/com/tflucke/webroutes/formatter/NoFormatter.scala
  13. 4 2
      plugin/src/main/scala/com/tflucke/webroutes/formatter/PlainTextFormatter.scala
  14. 16 0
      plugin/src/main/scala/com/tflucke/webroutes/models/Format.scala
  15. 37 0
      plugin/src/main/scala/com/tflucke/webroutes/models/FullName.scala
  16. 12 0
      plugin/src/main/scala/com/tflucke/webroutes/models/Method.scala
  17. 42 0
      plugin/src/main/scala/com/tflucke/webroutes/models/MimeType.scala
  18. 63 0
      plugin/src/main/scala/com/tflucke/webroutes/models/RouteDef.scala
  19. 59 0
      plugin/src/main/scala/com/tflucke/webroutes/models/URL.scala
  20. 1 1
      plugin/src/main/scala/com/tflucke/webroutes/parsers/Parser.scala

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

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

+ 20 - 11
play/src/main/scala/com/tflucke/webroutes/parsers/PlayParser.scala

@@ -2,7 +2,7 @@ package com.tflucke.webroutes.parsers
 
 import sbt.File
 import scala.io.Source
-import com.tflucke.webroutes.RouteDef
+import com.tflucke.webroutes.models.{Format,FullName,RouteDef,URL}
 
 /** A parse which will extract information from a Play! Framework routes file.
   * 
@@ -52,28 +52,37 @@ object PlayParser extends Parser {
       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 symbolReg = raw":(\w+)".r
+      val path = URL.fromString(route.group(4)) {
+        case symbolReg(sym) => Some(Symbol(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))
+      })
       val (retMime, retType) = getReturnType(getProp("mime"), getProp("type"))
-      RouteDef(method, path, pack, obj, function, args, getProp("body"),
-        getProp("content") getOrElse "json",
-        retType,
+      RouteDef(method, path, pack, obj, function, args, getProp("body").map(FullName(_)),
+        getProp("content").map(Format.fromString _) getOrElse Format.JSON,
+        FullName(retType),
         retMime)
     }).toList
 
   def getReturnType(mime: Option[String], typ: Option[String]) =
     (mime, typ) match {
-      case (Some(retMime), Some(retType)) => (retMime, retType)
+      case (Some(retMime), Some(retType)) => (retMime.trim, retType.trim)
       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, Some(retType)) => ("application/json", retType.trim)
       case (None, None) => ("text/plain", "String")
     }
 }

+ 0 - 0
play/src/sbt-test/webroutes/dummy/server/logs/application.log


+ 4 - 1
play/src/sbt-test/webroutes/user/server/conf/testing.conf

@@ -4,4 +4,7 @@ include "application.conf"
 #play.filters.disabled += "play.filters.csrf.CSRFFilter"
 play.filters.csrf.header.bypassHeaders {
   Csrf-Token = "nocheck"
-}
+}
+
+# TODO: See if test file can run evolutions instead
+play.evolutions.autoApply=true

+ 0 - 0
play/src/sbt-test/webroutes/user/server/logs/application.log


+ 72 - 36
play/src/test/scala/com/tflucke/webroutes/unit/parsers/PlayParserTest.scala

@@ -2,7 +2,7 @@ package com.tflucke.webroutes.unit.parsers
 
 import sbt.File
 import scala.io.Source
-import com.tflucke.webroutes.RouteDef
+import com.tflucke.webroutes.models.{FullName,Method,RouteDef,URL}
 import com.tflucke.webroutes.parsers.PlayParser
 import org.scalatest._
 import java.io.FileNotFoundException
@@ -25,52 +25,66 @@ class PlayParserTest extends PropSpec with Matchers {
   }
 
   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")
+  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")
+    getApi.get.method should be (Method.GET)
   }
   property("the get api should have the correct URL") {
-    getApi.get.url should be ("/user/:id")
+    getApi.get.url should matchPattern {
+      case URL(_, _, _, Seq(Left(""), Left("user"), Right('id)), _) =>
+    }
   }
   property("the get api should have the correct package") {
-    getApi.get.pack should be ("org.sample.users")
+    getApi.get.pack should matchPattern {
+      case FullName(Seq("org", "sample", "users"), Nil) =>
+    }
   }
   property("the get api should have the UserController") {
-    getApi.get.controller should be ("UserController")
+    getApi.get.controller.name should be ("UserController")
   }
   property("the get api should have return type User") {
-    getApi.get.retType should be ("org.sample.users.shared.User")
+    getApi.get.retType should matchPattern {
+      case FullName(Seq("org", "sample", "users", "shared", "User"), Nil) =>
+    }
   }
   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")
+    getApi.get.args(0) should be (('id, FullName("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")
+    queryApi.get.method should be (Method.GET)
   }
   property("the query api should have the correct URL") {
-    queryApi.get.url should be ("/user")
+    queryApi.get.url should matchPattern {
+      case URL(_, _, _, Seq(Left(""), Left("user")), _) =>
+    }
   }
   property("the query api should have the correct package") {
-    queryApi.get.pack should be ("org.sample.users")
+    queryApi.get.pack should matchPattern {
+      case FullName(Seq("org", "sample", "users"), Nil) =>
+    }
   }
   property("the query api should have the UserController") {
-    queryApi.get.controller should be ("UserController")
+    queryApi.get.controller.name should be ("UserController")
   }
   property("the query api should have return type Seq[User]") {
-    queryApi.get.retType should be ("Seq[org.sample.users.shared.User]")
+    queryApi.get.retType should matchPattern {
+      case FullName(Seq("Seq"), Seq(
+        FullName(Seq("org", "sample", "users", "shared", "User"), Nil)
+      )) =>
+    }
   }
   property("the query api should have no arguments") {
     queryApi.get.args.size should be (0)
@@ -80,71 +94,93 @@ class PlayParserTest extends PropSpec with Matchers {
     addApi should not be None
   }
   property("the add api should use the PUT method") {
-    addApi.get.method should be ("PUT")
+    addApi.get.method should be (Method.PUT)
   }
   property("the add api should have the correct URL") {
-    addApi.get.url should be ("/user")
+    addApi.get.url should matchPattern {
+      case URL(_, _, _, Seq(Left(""), Left("user")), _) =>
+    }
   }
   property("the add api should have the correct package") {
-    addApi.get.pack should be ("org.sample.users")
+    addApi.get.pack should matchPattern {
+      case FullName(Seq("org", "sample", "users"), Nil) =>
+    }
   }
   property("the add api should have the UserController") {
-    addApi.get.controller should be ("UserController")
+    addApi.get.controller.name should be ("UserController")
   }
   property("the add api should have return type User") {
-    addApi.get.retType should be ("org.sample.users.shared.User")
+    addApi.get.retType should matchPattern {
+      case FullName(Seq("org", "sample", "users", "shared", "User"), Nil) =>
+    }
   }
   property("the add api should have only a User body argument") {
-    addApi.get.bodyType should be (Some("org.sample.users.shared.User"))
+    addApi.get.bodyType should matchPattern {
+      case Some(FullName(Seq("org", "sample", "users", "shared", "User"), Nil)) =>
+    }
   }
 
   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")
+    updateApi.get.method should be (Method.POST)
   }
   property("the update api should have the correct URL") {
-    updateApi.get.url should be ("/user/:id")
+    updateApi.get.url should matchPattern {
+      case URL(_, _, _, Seq(Left(""), Left("user"), Right('id)), _) =>
+    }
   }
   property("the update api should have the correct package") {
-    updateApi.get.pack should be ("org.sample.users")
+    updateApi.get.pack should matchPattern {
+      case FullName(Seq("org", "sample", "users"), Nil) =>
+    }
   }
   property("the update api should have the UserController") {
-    updateApi.get.controller should be ("UserController")
+    updateApi.get.controller.name should be ("UserController")
   }
   property("the update api should have return type User") {
-    updateApi.get.retType should be ("org.sample.users.shared.User")
+    updateApi.get.retType should matchPattern {
+      case FullName(Seq("org", "sample", "users", "shared", "User"), Nil) =>
+    }
   }
   property("the update api should have a Long and User arguments") {
     updateApi.get.args.size should be (1)
-    updateApi.get.args(0).replace(" ", "") should be ("id:Long")
+    updateApi.get.args(0) should be (('id, FullName("Long")))
   }
   property("the update api should have only a User body argument") {
-    addApi.get.bodyType should be (Some("org.sample.users.shared.User"))
+    addApi.get.bodyType should matchPattern {
+      case Some(FullName(Seq("org", "sample", "users", "shared", "User"), Nil)) =>
+    }
   }
 
   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")
+    deleteApi.get.method should be (Method.DELETE)
   }
   property("the delete api should have the correct URL") {
-    deleteApi.get.url should be ("/user/:id")
+    deleteApi.get.url should matchPattern {
+      case URL(_, _, _, Seq(Left(""), Left("user"), Right('id)), _) =>
+    }
   }
   property("the delete api should have the correct package") {
-    deleteApi.get.pack should be ("org.sample.users")
+    deleteApi.get.pack should matchPattern {
+      case FullName(Seq("org", "sample", "users"), Nil) =>
+    }
   }
   property("the delete api should have the UserController") {
-    deleteApi.get.controller should be ("UserController")
+    deleteApi.get.controller.name should be ("UserController")
   }
   property("the delete api should have return type User") {
-    deleteApi.get.retType should be ("org.sample.users.shared.User")
+    deleteApi.get.retType should matchPattern {
+      case FullName(Seq("org", "sample", "users", "shared", "User"), Nil) =>
+    }
   }
   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")
+    getApi.get.args(0) should be (('id, FullName("Long")))
   }
 
   // "PlayParser" when {

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

@@ -1,6 +1,7 @@
 package com.tflucke.webroutes
 
 import sbt.{File,IO}
+import com.tflucke.webroutes.models.FullName
 import com.tflucke.webroutes.parsers.Parser
 import com.tflucke.webroutes.endpoints.EndpointFile
 
@@ -17,7 +18,7 @@ object RPCGenerator {
     outpath: File
   ): Seq[File] = {
     definitionFiles.flatMap({ file =>
-      val outFiles = file.definitions.groupBy[(String, String)]({
+      val outFiles = file.definitions.groupBy[(FullName, Symbol)]({
         case (route, _) => (route.pack, route.controller)
       }).map({ case ((pack, controller), routes) => {
         val content = s"""package ${pack}
@@ -25,15 +26,14 @@ object RPCGenerator {
 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} {
+object ${controller.name} {
 ${routes.map({
   case (route, formatters) => route.toDefinition(formatters)
 }).mkString("\n")}
 }
 """
-        val outFile = makeControllerFile(pack, controller, outpath)
+        val outFile = makeControllerFile(pack.syms, controller, outpath)
         IO.write(outFile, content)
         outFile
       } }).toSeq
@@ -41,19 +41,18 @@ ${routes.map({
     })
   }
 
-  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")
+  def makeControllerFile(
+    path: Seq[String],
+    controller: Symbol,
+    baseDir: File
+  ): File = path match {
+    case 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 - 60
plugin/src/main/scala/com/tflucke/webroutes/RouteDef.scala

@@ -1,60 +0,0 @@
-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\"
-}
-"""
-  }
-}

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

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

+ 4 - 2
plugin/src/main/scala/com/tflucke/webroutes/formatter/Formatter.scala

@@ -1,8 +1,10 @@
 package com.tflucke.webroutes.formatter
 
+import com.tflucke.webroutes.models.FullName
+
 sealed trait Formatter {
-  def genDeserializer(body: String, typ: String): String
-  def genSerializer(body: String, typ: String): String
+  def genDeserializer(body: String, typ: FullName): String
+  def genSerializer(body: String, typ: FullName): String
 }
 
 trait JsonFormatter extends Formatter

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

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

+ 10 - 8
plugin/src/main/scala/com/tflucke/webroutes/formatter/NoFormatter.scala

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

+ 4 - 2
plugin/src/main/scala/com/tflucke/webroutes/formatter/PlainTextFormatter.scala

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

+ 16 - 0
plugin/src/main/scala/com/tflucke/webroutes/models/Format.scala

@@ -0,0 +1,16 @@
+package com.tflucke.webroutes.models
+
+/** Enum representing each of the possible formats to transmit data.
+  * 
+  * @author Thomas Flucke
+  */
+/* These were based on the formats used in javascript AJAX calls, but that may be
+ * too inflexible for future uses.  An alternative could be to replace this with
+ * MimeTypes and restrict the mimetypes to a subset defined here where necessary. 
+ */
+object Format extends Enumeration {
+  type Format = Value
+  val FORM, JSON, TEXT, BINARY = Value
+
+  implicit def fromString(str: String) = withName(str.toUpperCase)
+}

+ 37 - 0
plugin/src/main/scala/com/tflucke/webroutes/models/FullName.scala

@@ -0,0 +1,37 @@
+package com.tflucke.webroutes.models
+
+/** Represents a full name of a package or type which can be used to reference it
+  * from any context.
+  * 
+  * @author Thomas Flucke
+  */
+/* This right now seems pretty strictly tied into scala.  May want to see if this
+ * can be broken up so that scala specific logic is in a single unit.
+ */
+case class FullName(
+  syms: Seq[String],
+  params: Seq[FullName] = Nil
+) {
+  override def toString = s"""${syms.mkString(".")}""" + (
+    if (params.nonEmpty) s"""[${params.mkString(",")}]""" else ""
+  )
+
+  def :+(sym: String) = FullName(syms.toSeq :+ sym)
+  def ++(newSyms: Seq[String]) = FullName(syms.toSeq ++ newSyms.toSeq)
+  def +:(sym: String) = FullName(sym +: syms.toSeq)
+  def ++:(newSyms: Seq[String]) = FullName(newSyms.toSeq ++: syms.toSeq)
+}
+
+object FullName {
+  def apply(strs: Seq[String]): FullName = new FullName(strs)
+  // TODO: Unit test this
+  def apply(str: String): FullName = {
+    val regex = raw"^([^\[]+)(\[(.+)\])?$$".r
+    str match {
+      case regex(base, _, null) => new FullName(base.split("\\s*\\.\\s*"))
+      case regex(base, _, generic) => new FullName(
+        base.split("\\s*\\.\\s*"),
+        generic.split("\\s*,\\s*").map(FullName(_)).toSeq
+      )}
+  }
+}

+ 12 - 0
plugin/src/main/scala/com/tflucke/webroutes/models/Method.scala

@@ -0,0 +1,12 @@
+package com.tflucke.webroutes.models
+
+/** Enumeration of all possible HTTP methods.
+  * 
+  * @author Thomas Flucke
+  */
+object Method extends Enumeration {
+  type Method = Value
+  val GET, PUT, POST, DELETE, HEAD, PATCH, OPTIONS, TRACE, CONNECT = Value
+
+  implicit def fromString(str: String) = withName(str.toUpperCase)
+}

+ 42 - 0
plugin/src/main/scala/com/tflucke/webroutes/models/MimeType.scala

@@ -0,0 +1,42 @@
+package com.tflucke.webroutes.models
+
+/** Represents an IANA mime type.
+  * 
+  * @author Thomas Flucke
+  */
+case class MimeType(
+  typ: String,
+  subtype: String,
+  params: Map[String, String] = Map.empty
+) {
+  def addParameter(key: String, value: String) =
+    MimeType(typ, subtype, params + (key -> value))
+
+  def addParameters(pairs: (String, String)*) =
+    MimeType(typ, subtype, params ++ pairs)
+
+  private def paramStr = params.map({case (k, v) => s";$k=$v"}).mkString("")
+
+  override def toString = s"$typ/${subtype}$paramStr"
+}
+
+object MimeType {
+  val text = MimeType("text", "plain")
+  val binary = MimeType("application", "octet-stream")
+  val form = MimeType("application", "x-www-form-urlencoded")
+  val json = MimeType("application", "json")
+
+  val regex = """^([-\w.+]+)/([-\w+.]+)(;([-\w+.]+=[-\w+.]+)?)*$""".r
+
+  // TODO: Unit test this
+  implicit def fromString(str: String) = str match {
+    case regex(typ, subtype, _, params) =>
+      MimeType(typ, subtype, Option(params).map(_.split(";").toSeq).getOrElse(Nil)
+        .map(str =>
+          str.split("=") match {
+            case Array(k, v) => (k, v)
+            case _ => throw new Exception("Unreachable code!")
+          }
+        ).toMap)
+  }
+}

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

@@ -0,0 +1,63 @@
+package com.tflucke.webroutes.models
+
+import com.tflucke.webroutes.formatter._
+
+/** The metainformation for an endpoint nessicary to create an RPC API.
+  * 
+  * @author Thomas Flucke
+  */
+case class RouteDef(
+  method: Method.Method,
+  url: URL,
+  pack: FullName,
+  controller: Symbol,
+  fn: Symbol,
+  args: Seq[(Symbol, FullName)],
+  bodyType: Option[FullName] = None,
+  bodyFormat: Format.Format = Format.JSON,
+  retType: FullName = FullName(Seq("Any")),
+  mime: MimeType = MimeType.json
+) {
+
+  def formatToMime(format: Format.Format) = format match {
+    case Format.JSON => MimeType.json
+    case Format.TEXT => MimeType.text
+    case Format.BINARY => ???
+    case Format.FORM => ???
+  }
+
+  def mimeToFormat(mime: MimeType) = mime match {
+    case MimeType("application", "json", _) => Format.JSON
+    case MimeType("text", "json", _) => Format.JSON
+    case MimeType("text", "plain", _) => Format.TEXT
+    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(", ")
+
+  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 =
+        ${fmters(bodyFormat).genSerializer("body", FullName(Seq(
+          "org", "scalajs", "dom", "ext", "Ajax", "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.name}($argStr) = new APIRoute[$retType](\"$method\", s\"${url.interpolatable(scalaInterpolator)}\", \"$mime\") {
+    protected def convert(xhr: XMLHttpRequest): $retType =
+        ${fmters(mimeToFormat(mime)).genDeserializer("xhr.responseText", retType)}
+    protected def acceptHeader = \"$mime\"
+}
+"""
+  }
+}

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

@@ -0,0 +1,59 @@
+package com.tflucke.webroutes.models
+
+/** Represents URL which will respond to HTTP requests.
+  * 
+  * The path of the URL is composed of both literals and variables which may be
+  * filled by a language specific interpolater.
+  * 
+  * The relative paths are represented with an empty string hostname.
+  * 
+  * @author Thomas Flucke
+  */
+case class URL(
+  protocol: Option[String] = None,
+  host: String = "",
+  port: Option[Short] = None,
+  path: Seq[URL.PathStep] = Seq.empty,
+  query: Map[String, URL.PathStep] = Map.empty[String, URL.PathStep]
+) {
+  def interpolatable(fn: Symbol => String) = protocol.map(_+"://").getOrElse("") +
+    host +
+    port.map(p => f":$p%hu").getOrElse("") +
+    path.map(_.fold(identity, fn)).mkString("/")
+}
+
+object URL {
+  type PathStep = Either[String, Symbol]
+
+  // TODO: Unit test this
+  def fromString(str: String)(symParser: String => Option[Symbol]) = {
+    val protoReg = raw"""(\w+://)?"""
+    val hostReg = raw"([-a-zA-Z0-9\.]*)"
+    val portReg = raw"(:\d+)?"
+    val pathReg = raw"(/[^\\]*)*"
+    val queryReg = raw"\??(([-a-zA-Z0-9\.]*=[-a-zA-Z0-9\.]*)(&[-a-zA-Z0-9\.]*=[-a-zA-Z0-9\.]*)*)?"
+    val regex = s"^${protoReg}${hostReg}${portReg}${pathReg}${queryReg}$$".r
+
+    def toSteps(strs: Seq[String]): Seq[PathStep] = strs match {
+      case Nil => Nil
+      case head::rest => symParser(head).toRight(head) +: toSteps(rest)
+    }
+
+    def parsePort(str: String) =
+      Some(Integer.parseInt(str.substring(1), 10).toShort)
+
+    def parseProto(str: String) = Some(str.substring(0, -3))
+
+    str match {
+      case regex(null, host, null, path, query, _, _) =>
+        URL(None, host, None, toSteps(Seq(path.split("/", -1):_*)))
+      case regex(proto, host, null, path, query, _, _) => 
+        URL(parseProto(proto), host, None, toSteps(Seq(path.split("/", -1):_*)))
+      case regex(null, host, port, path, query, _, _) =>
+        URL(None, host, parsePort(port), toSteps(Seq(path.split("/", -1):_*)))
+      case regex(proto, host, port, path, query, _, _) =>
+        URL(parseProto(proto), host, parsePort(port),
+          toSteps(Seq(path.split("/", -1):_*)))
+    }
+  }
+}

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

@@ -1,7 +1,7 @@
 package com.tflucke.webroutes.parsers
 
 import sbt.File
-import com.tflucke.webroutes.RouteDef
+import com.tflucke.webroutes.models.RouteDef
 
 /** Interface which takes a file and parses the API definitions.
   *