Kaynağa Gözat

Formating changes and adding support for JVM.

Thomas Flucke 4 yıl önce
ebeveyn
işleme
77db7b0c04

+ 16 - 15
build.sbt

@@ -20,7 +20,7 @@ def commonSettings = Seq(
     "-unchecked",
     "-feature"
   ),
-  crossScalaVersions := Seq("3.1.0", "2.12.11", "2.12.10")
+  crossScalaVersions := Seq("2.12.11", "2.12.10")
 )
 
 /****** The Actual Plugin ******/
@@ -29,9 +29,9 @@ lazy val plugin = (project in file("plugin"))
   .enablePlugins(SbtPlugin)
   .settings(commonSettings)
   .settings(
-    addSbtPlugin("org.portable-scala" % "sbt-platform-deps" % "1.0.0"),
+    addSbtPlugin("org.portable-scala" % "sbt-platform-deps" % "1.0.1"),
     name := "sbt-rest-rpc",
-    version := s"0.3.1$VersionSuffix",
+    version := s"0.3.2$VersionSuffix",
     Compile / resourceGenerators += Def.task {
       val file = (Compile / resourceManaged).value / "version.txt"
       IO.write(file, (libraryJvm / version).value)
@@ -51,23 +51,24 @@ lazy val library = crossProject(JSPlatform, JVMPlatform)
   .settings(commonSettings)
   .settings(
     name := "rest-rpc",
-    version := s"0.3.1$VersionSuffix",
-    crossScalaVersions += "2.13.3"
+    version := s"0.3.2$VersionSuffix",
+    crossScalaVersions ++= Seq("2.13.7", "2.13.3")
   )
   .jvmSettings(
     libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.0",
     libraryDependencies +=  "org.scalaj" %% "scalaj-http" % "2.4.2"
   )
   .jsSettings(
+    crossScalaVersions ++= Seq("3.1.0"),
     libraryDependencies ++= (if (scalaJSVersion.startsWith("0.6.")) Seq(
       // Wrapper library for JS dom to scala
       // Docs: https://scala-js.github.io/scala-js-dom/
       // Scaladocs: https://www.javadoc.io/doc/org.scala-js/scalajs-dom_sjs1.0.0-M7_2.12/0.9.6/org/scalajs/dom/index.html
-      "org.scala-js" %%% "scalajs-dom" % "0.9.8",
-      "com.typesafe.play" %%% "play-json" % "2.8.1"
+      "org.scala-js"      %%% "scalajs-dom" % "0.9.8",
+      "com.typesafe.play" %%% "play-json"   % "2.8.1"
     ) else Seq(
-      "org.scala-js" %%% "scalajs-dom" % "1.2.0",
-      "com.typesafe.play" %%% "play-json" % "2.9.0"
+      "org.scala-js"      %%% "scalajs-dom" % "2.1.0",
+      "com.typesafe.play" %%% "play-json"   % "2.10.0-RC5"
     ))
   )
 lazy val libraryJvm = library.jvm
@@ -79,11 +80,11 @@ lazy val playPlugin = (project in file("play"))
   .enablePlugins(SbtPlugin)
   .settings(commonSettings)
   .settings(
-    addSbtPlugin("com.tflucke" % "sbt-rest-rpc" % "0.3.1-SNAPSHOT"),
+    addSbtPlugin("com.tflucke" % "sbt-rest-rpc" % "0.3.2-SNAPSHOT"),
     name := "sbt-rest-rpc-play",
-    version := s"0.3.1$VersionSuffix",
+    version := s"0.3.2$VersionSuffix",
     libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.8" % Test,
-    logBuffered in Test := false,
+    Test / logBuffered := false,
     scriptedLaunchOpts := { scriptedLaunchOpts.value ++ Seq(
       "-Xmx1024M",
       "-Dplugin.version="+version.value,
@@ -98,14 +99,14 @@ lazy val openapiPlugin = (project in file("openapi"))
   .enablePlugins(SbtPlugin)
   .settings(commonSettings)
   .settings(
-    addSbtPlugin("com.tflucke" % "sbt-rest-rpc" % "0.3.1-SNAPSHOT"),
+    addSbtPlugin("com.tflucke" % "sbt-rest-rpc" % "0.3.2-SNAPSHOT"),
     name := "sbt-rest-rpc-openapi",
-    version := s"0.3.1$VersionSuffix",
+    version := s"0.3.2$VersionSuffix",
     libraryDependencies ++= Seq(
       "io.swagger.parser.v3" % "swagger-parser" % "2.0.22",
       "org.scalatest" %% "scalatest" % "3.0.8" % Test
     ),
-    logBuffered in Test := false,
+    Test / logBuffered := false,
     scriptedLaunchOpts := { scriptedLaunchOpts.value ++ Seq(
       "-Xmx1024M",
       "-Dplugin.version="+version.value,

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

@@ -38,6 +38,7 @@ object Http {
 
   private def wrapFailureException[T](x: Try[scalaj.http.HttpResponse[String]]) = x match {
     case Success(res) if res.isSuccess => Success(new Http.Response(res))
+    case Success(res)                  => ???
     case Failure(res: HttpStatusException) => Failure(new HTTPException(res))
     case Failure(res) => Failure(res)
   }

+ 7 - 5
play/src/main/scala/com/tflucke/webroutes/endpoints/PlayEndpointFile.scala

@@ -3,17 +3,19 @@ package com.tflucke.webroutes.endpoints
 import sbt.{File,Project}
 import com.tflucke.webroutes.parsers.PlayParser
 import com.tflucke.webroutes.formatter._
+import com.tflucke.webroutes.models.URL
 
 object PlayEndpointFile {
   def apply(
-    server: Project,
-    textFmt: TextFormatter = PlainTextFormatter,
-    jsonFmt: JsonFormatter = PlayJsonFormatter,
+    server:  Project,
+    url:     String          = "",
+    textFmt: TextFormatter   = PlainTextFormatter,
+    jsonFmt: JsonFormatter   = PlayJsonFormatter,
     binFmt:  BinaryFormatter = NoBinaryFormatter,
-    formFmt: FormFormatter = NoFormFormatter
+    formFmt: FormFormatter   = NoFormFormatter
   ) = new EndpointFile(
     new File(server.base, "conf/routes"),
-    PlayParser,
+    PlayParser(URL.fromString(url) { (str) => None }),
     textFmt,
     jsonFmt,
     binFmt,

+ 58 - 52
play/src/main/scala/com/tflucke/webroutes/parsers/PlayParser.scala

@@ -43,14 +43,14 @@ import URL.{EmbeddedSymbol,QuerySymbol}
   * 
   * @author Thomas Flucke
   */
-object PlayParser extends Parser {
-  val importPattern = raw"^\s*->".r
-  val modifierPattern = raw"^\s*+\s*(\w+)".r
+case class PlayParser(url: URL) extends Parser {
+  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 emptyLinePattern  = raw"^\s*#?\s*$$".r
   val nonCommentPattern = raw"^\s*[^#]".r
-  val propPattern = raw"^\s*#\s*(\w+)\s*:\s*([^\s]+)\s*$$".r
-  val routePattern =
+  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
 
   private def nextOption(it: Iterator[String]) =
@@ -59,14 +59,14 @@ object PlayParser extends Parser {
   def parseFile(input: File, log: Logger): Seq[RouteDef] = {
     def parseIterator(it: Iterator[String]): Seq[RouteDef] = {
       nextOption(it) match {
-        case Some(routeStartPattern()) =>
+        case Some(routeStartPattern())  =>
           parseRoute(it, input.getName(), log) match {
             case Success(route) => route +: parseIterator(it)
-            case Failure(err) =>
+            case Failure(err)   =>
               log.error(err.getMessage())
               parseIterator(it)
           }
-        case Some(importPattern()) =>
+        case Some(importPattern())      =>
           // TODO
           log.error("Importing route files is not yet supported.")
           parseIterator(it)
@@ -74,63 +74,68 @@ object PlayParser extends Parser {
           // TODO
           log.warn(s"Route modifier ($mod) is not recognized and will be ignored.")
           parseIterator(it)
-        case Some(_) => parseIterator(it)
-        case None => Nil
+        case Some(_)                    => parseIterator(it)
+        case None                       => Nil
       }
     }
     parseIterator(Source.fromFile(input).getLines())
   }
 
   private def parseRoute(
-    it: Iterator[String],
+    it:       Iterator[String],
     filename: String,
-    log: Logger,
-    props: Map[String, String] = Map.empty
+    log:      Logger,
+    props:    Map[String, String] = Map.empty
   ): Try[RouteDef] = nextOption(it) match {
-    case None => Failure(
+    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)) =>
+    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)) =>
+    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 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))
+      val routeUrl           = URL.fromString(path) {
+        case symbolReg(sym)   => Some(EmbeddedSymbol(sym))
+        case longSymReg(sym)  => Some(EmbeddedSymbol(sym))
         case regexSymReg(sym) => Some(EmbeddedSymbol(sym))
-        case _ => None
+        case _                => None
       }
       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,
+        case (pack, obj, function, args) => RouteDef(
+          method,
+          url + routeUrl.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(routeUrl.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)
+          retMime
+        )
       })
     }
-    case Some(line) =>
+    case Some(line)                              =>
       log.verbose(s"Unparsable line in $filename:\n$line\n.")
       parseRoute(it, filename, log, props)
   }
@@ -145,7 +150,7 @@ object PlayParser extends Parser {
           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 rest1        = rest.trim
             val (typ, rest2) = if (rest1.nonEmpty && rest1(0) == ':')
               rest1.substring(1).trim.span(x =>
                 x.isLetterOrDigit || x == '_' || x == '.' || x == '[' || x == ']'
@@ -165,20 +170,21 @@ object PlayParser extends Parser {
               None
             }
           }).toSeq.flatten).getOrElse(Nil)))
-      case _ => Failure(new ParseException(s"Unparsable scala line:\n$line\n.", 0))
+      case _                                      =>
+        Failure(new ParseException(s"Unparsable scala line:\n$line\n.", 0))
     }
   }
 
   def getReturnType(mime: Option[String], typ: Option[String]) =
     (mime, typ) match {
-      case (Some(retMime), Some(retType)) => (retMime.trim, retType.trim)
-      case (Some("text/json"), None) => ("application/json", "Any")
+      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 (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.trim)
-      case (None, None) => ("text/plain", "String")
+      case (None,            Some(retType)) => ("application/json", retType.trim)
+      case (None,                     None) => ("text/plain", "String")
     }
 }

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

@@ -3,10 +3,10 @@ package org.sample.dummy
 import org.scalajs.dom.document
 import org.scalajs.dom.raw.HTMLInputElement
 import scala.scalajs.js.annotation.JSExportTopLevel
-import scala.util.{Try,Success,Failure}
 import scala.concurrent.ExecutionContext.Implicits.global
 import scala.concurrent.duration._
 import scala.concurrent.Future
+import scala.util.{Try,Success,Failure}
 import org.sample.dummy.shared.models._
 import org.sample.dummy.controllers.PojoController
 import com.tflucke.webroutes.Headers

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

@@ -11,7 +11,7 @@ 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"),
+  val apis = PlayParser(URL()).parseFile(makeRouteFile("play-examples-route"),
     Logger.Null)
 
   {

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

@@ -18,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, Logger.Null).size should be (0)
+    PlayParser(URL()).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, Logger.Null)
+  val apis = PlayParser(URL()).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)

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

@@ -19,9 +19,11 @@ object RPCGenerator {
     log: Logger
   ): Seq[File] = {
     definitionFiles.flatMap({ file =>
+      log.info(s"Creating routes for definition file ${file.file}")
       val outFiles = file.definitions(log).groupBy[(FullName, Symbol)]({
         case (route, _) => (route.pack, route.controller)
       }).map({ case ((pack, controller), routes) => {
+        log.info(s"Creating routes for controller $pack.$controller")
         val content = s"""package ${pack}
 
 import com.tflucke.webroutes.{Http,APIRoute,APIRouteBody}
@@ -37,6 +39,7 @@ ${routes.map({
         IO.write(outFile, content)
         outFile
       } }).toSeq
+      log.info(s"Generated ${outFiles.size} routes.")
       outFiles
     })
   }

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

@@ -15,7 +15,7 @@ import scala.io.Source
   */
 object RestRPC extends AutoPlugin {
 
-  val version = "0.3.1-SNAPSHOT" //Source.fromResource("version.txt").mkString
+  val version = "0.3.2-SNAPSHOT" //Source.fromResource("version.txt").mkString
 
   object autoImport {
     val generateRpc = taskKey[Seq[File]]("Generate scala client RPC APIs")

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

@@ -6,12 +6,12 @@ import com.tflucke.webroutes.parsers.Parser
 import com.tflucke.webroutes.formatter._
 
 case class EndpointFile(
-  val file: File,
-  val parser: Parser,
-  val textFmt: TextFormatter = PlainTextFormatter,
-  val jsonFmt: JsonFormatter = NoJsonFormatter,
-  val binFmt:  BinaryFormatter = NoBinaryFormatter,
-  val formFmt: FormFormatter = NoFormFormatter
+  file:    File,
+  parser:  Parser,
+  textFmt: TextFormatter   = PlainTextFormatter,
+  jsonFmt: JsonFormatter   = NoJsonFormatter,
+  binFmt:  BinaryFormatter = NoBinaryFormatter,
+  formFmt: FormFormatter   = NoFormFormatter
 ) {
 
   def definitions(log: Logger): Seq[(RouteDef, Map[Format.Format, Formatter])] = 

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

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

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

@@ -1,5 +1,7 @@
 package com.tflucke.webroutes.models
 
+import scala.language.implicitConversions
+
 /** Enum representing each of the possible formats to transmit data.
   * 
   * @author Thomas Flucke

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

@@ -9,7 +9,7 @@ package com.tflucke.webroutes.models
  * can be broken up so that scala specific logic is in a single unit.
  */
 case class FullName(
-  syms: Seq[String],
+  syms:   Seq[String],
   params: Seq[FullName] = Nil
 ) {
   override def toString = s"""${syms.mkString(".")}""" + (

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

@@ -1,5 +1,7 @@
 package com.tflucke.webroutes.models
 
+import scala.language.implicitConversions
+
 /** Enumeration of all possible HTTP methods.
   * 
   * @author Thomas Flucke

+ 7 - 5
plugin/src/main/scala/com/tflucke/webroutes/models/MimeType.scala

@@ -1,13 +1,15 @@
 package com.tflucke.webroutes.models
 
+import scala.language.implicitConversions
+
 /** Represents an IANA mime type.
   * 
   * @author Thomas Flucke
   */
 case class MimeType(
-  typ: String,
+  typ:     String,
   subtype: String,
-  params: Map[String, String] = Map.empty
+  params:  Map[String, String] = Map.empty
 ) {
   def addParameter(key: String, value: String) =
     MimeType(typ, subtype, params + (key -> value))
@@ -21,10 +23,10 @@ case class MimeType(
 }
 
 object MimeType {
-  val text = MimeType("text", "plain")
+  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 form   = MimeType("application", "x-www-form-urlencoded")
+  val json   = MimeType("application", "json")
 
   val regex = """^([-\w.+]+)/([-\w+.]+)(;([-\w+.]+=[-\w+.]+)?)*$""".r
 

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

@@ -7,34 +7,34 @@ import com.tflucke.webroutes.formatter._
   * @author Thomas Flucke
   */
 case class RouteDef(
-  method: Method.Method,
-  url: URL,
-  pack: FullName,
+  method:     Method.Method,
+  url:        URL,
+  pack:       FullName,
   controller: Symbol,
-  fn: Symbol,
-  args: Seq[(Symbol, FullName, Option[String])],
-  bodyType: Option[FullName] = None,
-  bodyFormat: Format.Format = Format.JSON,
-  retType: FullName = FullName(Seq("Any")),
-  mime: MimeType = MimeType.json
+  fn:         Symbol,
+  args:       Seq[(Symbol, FullName, Option[String])],
+  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.JSON   => MimeType.json
+    case Format.TEXT   => MimeType.text
     case Format.BINARY => ???
-    case Format.FORM => ???
+    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 _ => ???
+    case MimeType("text", "json", _)        => Format.JSON
+    case MimeType("text", "plain", _)       => Format.TEXT
+    case _                                  => ???
   }
 
   private def argStr = args.map({
-    case (sym, typ, None) => s"${sym.name}: $typ"
+    case (sym, typ, None)         => s"${sym.name}: $typ"
     case (sym, typ, Some(defaul)) => s"${sym.name}: $typ = $defaul"
   }).mkString(", ")
 

+ 34 - 16
plugin/src/main/scala/com/tflucke/webroutes/models/URL.scala

@@ -10,17 +10,27 @@ package com.tflucke.webroutes.models
   * @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.QueryValue] = Map.empty[String, URL.QueryValue]
+  protocol: Option[String]              = None,
+  host:     String                      = "",
+  port:     Option[Short]               = None,
+  path:     Seq[URL.PathStep]           = Seq.empty,
+  query:    Map[String, URL.QueryValue] = Map.empty[String, URL.QueryValue]
 ) {
   import URL.{EmbeddedSymbol,QuerySymbol}
 
+  def concat(other: URL) = URL(
+    protocol orElse other.protocol,
+    host,
+    port,
+    path ++ other.path,
+    query ++ other.query
+  )
+
+  def +(other: URL) = concat(other)
+
   def interpolate = protocol.map(_+"://").getOrElse("") +
     host +
-    port.map(p => f":$p%hu").getOrElse("") +
+    port.map(p => f":$p%d").getOrElse("") +
     path.map(_.fold(identity, { case EmbeddedSymbol(name, encoder) =>
       encoder(name)
     })).mkString("/") +
@@ -36,8 +46,8 @@ object URL {
   type PathStep = Either[String, EmbeddedSymbol]
   type QueryValue = Either[String, QuerySymbol]
   case class EmbeddedSymbol(
-    val name: String,
-    val encoder: (String) => String = EmbeddedSymbol.defaultEncoder
+    name:    String,
+    encoder: (String) => String = EmbeddedSymbol.defaultEncoder
   )
 
   object EmbeddedSymbol {
@@ -45,8 +55,8 @@ object URL {
   }
 
   case class QuerySymbol(
-    val name: String,
-    val encoder: (String, Symbol) => String = QuerySymbol.defaultEncoder
+    name:    String,
+    encoder: (String, Symbol) => String = QuerySymbol.defaultEncoder
   )
 
   object QuerySymbol {
@@ -67,23 +77,31 @@ object URL {
   // TODO: Unit test this
   def fromString(str: String)(symParser: String => Option[EmbeddedSymbol]) = {
     val protoReg = raw"""(\w+://)?"""
-    val hostReg = raw"([-a-zA-Z0-9\.]*)"
-    val portReg = raw"(:\d+)?"
-    val pathReg = raw"(/[^\\]*)*"
+    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
+    val regex    = s"^${protoReg}${hostReg}${portReg}${pathReg}${queryReg}$$".r
 
     def toSteps(strs: Seq[String]): Seq[PathStep] = strs match {
-      case Nil => Nil
+      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))
+    def parseProto(str: String) = Some(str.substring(0, str.length - 3))
 
     str match {
+      case regex(null, host, null, null, query, _, _) =>
+        URL(None, host)
+      case regex(proto, host, null, null, query, _, _) => 
+        URL(parseProto(proto), host)
+      case regex(null, host, port, null, query, _, _) =>
+        URL(None, host, parsePort(port))
+      case regex(proto, host, port, null, query, _, _) =>
+        URL(parseProto(proto), host, parsePort(port))
       case regex(null, host, null, path, query, _, _) =>
         URL(None, host, None, toSteps(Seq(path.split("/", -1):_*)))
       case regex(proto, host, null, path, query, _, _) => 

+ 1 - 1
project/build.properties

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

+ 4 - 2
project/plugins.sbt

@@ -1,5 +1,7 @@
-val ScalaJSVersion = Option(System.getenv("SCALAJS_VERSION")).getOrElse("1.0.0")
+val ScalaJSVersion = Option(System.getenv("SCALAJS_VERSION")).getOrElse("1.5.1")
 
-addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject"  % "1.0.0")
+addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject"  % "1.1.0")
 
 addSbtPlugin("org.scala-js"         % "sbt-scalajs"               % ScalaJSVersion)
+
+addSbtPlugin("ch.epfl.scala"      % "sbt-scala3-migrate"        % "0.5.0")