From d00a11fd101e0894f1279dad3c19c6dcdaf71f6b Mon Sep 17 00:00:00 2001 From: Jesse Luehrs Date: Wed, 13 Feb 2013 19:34:07 -0600 Subject: initial sketch --- .gitignore | 2 + build.sbt | 7 +++ src/main/scala/router.scala | 136 +++++++++++++++++++++++++++++++++++++++++ src/test/scala/basic.scala | 143 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 288 insertions(+) create mode 100644 .gitignore create mode 100644 build.sbt create mode 100644 src/main/scala/router.scala create mode 100644 src/test/scala/basic.scala diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..007798c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/project diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..bba2cfb --- /dev/null +++ b/build.sbt @@ -0,0 +1,7 @@ +name := "scala-path-router" + +version := "0.01" + +scalaVersion := "2.10.0" + +libraryDependencies += "org.scalatest" % "scalatest_2.10" % "1.9.1" % "test" diff --git a/src/main/scala/router.scala b/src/main/scala/router.scala new file mode 100644 index 0000000..f1fbfa7 --- /dev/null +++ b/src/main/scala/router.scala @@ -0,0 +1,136 @@ +package router + +import scala.collection.mutable.ArrayBuffer +import scala.util.matching.Regex + +class Router[T] { + val routes = new ArrayBuffer[Route[T]]() + + def addRoute ( + path: String, + target: T, + defaults: Map[String, String] = Map(), + validations: Map[String, Regex] = Map() + ) { + routes += new Route(path, defaults, validations, target) + } + + def insertRoute ( + path: String, + target: T, + defaults: Map[String, String] = Map(), + validations: Map[String, Regex] = Map(), + at: Int = 0 + ) { + routes insert ( + at min routes.length, + new Route(path, defaults, validations, target) + ) + } + + def route (path: String): Option[Match[T]] = { + def _route ( + components: Seq[String], + routes: List[Route[T]] + ): Option[Match[T]] = routes match { + case r :: rs => r.route(components) match { + case Some(found) => Some(found) + case None => _route(components, rs) + } + case _ => None + } + _route(path.split("/"), routes.toList) + } + + def uriFor (mapping: Map[String, String]): String = { + throw new Error("unimplemented") + } +} + +class Route[T] ( + val path: String, + val defaults: Map[String, String], + val validations: Map[String, Regex], + val target: T +) { + def route( + parts: Seq[String], + components: Seq[String] = components, + mapping: Map[String, String] = defaults + ): Option[Match[T]] = { + if (components.length == 0 && parts.length == 0) { + Some(new Match[T](path, mapping, target)) + } + else if (components.length == 0 || parts.length == 0) { + None + } + else { + components.head match { + case Optional(name) => { + throw new Error("unsupported") + } + case Variable(name) => { + if (validate(name, parts.head)) { + route(parts.tail, components.tail, mapping + (name -> parts.head)) + } + else { + None + } + } + case literal => parts.head match { + case `literal` => route(parts.tail, components.tail, mapping) + case _ => None + } + } + } + } + + override def toString = path + + private lazy val components = + path.split("/").filter(_.length > 0) + + private lazy val length = + components.length + + private lazy val lengthWithoutOptionals = + components.filter(!isOptional(_)).length + + private lazy val requiredVariableComponentNames = + for (c <- components if isVariable(c) && !isOptional(c)) + yield getComponentName(c) + + private lazy val optionalVariableComponentNames = + for (c <- components if isVariable(c) && isOptional(c)) + yield getComponentName(c) + + private val Optional = """^\?:(.*)$""".r + private val Variable = """^\??:(.*)$""".r + + private def isOptional (component: String): Boolean = component match { + case Optional(_) => true + case _ => false + } + + private def isVariable (component: String): Boolean = component match { + case Variable(_) => true + case _ => false + } + + private def getComponentName (component: String) = component match { + case Variable(name) => Some(name) + case _ => None + } + + private def validate (name: String, component: String): Boolean = + validations get name match { + case Some(rx) => rx.findFirstIn(component).nonEmpty + case None => true + } +} + +class Match[T] ( + val path: String, + val mapping: Map[String, String], + val target: T +) diff --git a/src/test/scala/basic.scala b/src/test/scala/basic.scala new file mode 100644 index 0000000..15280e5 --- /dev/null +++ b/src/test/scala/basic.scala @@ -0,0 +1,143 @@ +import org.scalatest.FunSuite +// import router.test._ + +import router.Router + +class Basic extends FunSuite { + val yearRx = """\d{4}""".r + val monthRx = """\d|10|11|12""".r + val dayRx = """\d|[12]\d|30|31""".r + + val router = new Router[Boolean] + router addRoute ( + "blog/:year/:month/:day", + true, + defaults = Map( + "controller" -> "blog", + "action" -> "show_date" + ), + validations = Map( + "year" -> yearRx, + "month" -> monthRx, + "day" -> dayRx + ) + ) + + router insertRoute ( + "blog", + true, + defaults = Map( + "controller" -> "blog", + "action" -> "index" + ) + ) + + router insertRoute ( + "blog/:action/:id", + true, + at = 2, + defaults = Map( + "controller" -> "blog" + ), + validations = Map( + "action" -> """\D+""".r, + "id" -> """\d+""".r + ) + ) + + router insertRoute ( + "test/?:x/?:y", + true, + at = 1000000, + defaults = Map( + "controller" -> "test", + "x" -> "x", + "y" -> "y" + ) + ) + + def testRoute (router: Router[Boolean], path: String, mapping: Map[String, String]) { + val om = router.route(path) + assert(om.isDefined) + val m = om.get + assert(m.mapping.size == mapping.size) + assert(m.mapping.forall { case (k, v) => mapping(k) == v }) + assert(m.target === true) + } + + test ("routes are created in the correct order") { + assert(router.routes(0).path === "blog") + assert(router.routes(2).path === "blog/:action/:id") + assert(router.routes(3).path === "test/?:x/?:y") + } + + test ("routes match properly") { + testRoute( + router, "blog", Map( + "controller" -> "blog", + "action" -> "index" + ) + ) + + testRoute( + router, "blog/2006/12/5", Map( + "controller" -> "blog", + "action" -> "show_date", + "year" -> "2006", + "month" -> "12", + "day" -> "5" + ) + ) + + testRoute( + router, "blog/1920/12/10", Map( + "controller" -> "blog", + "action" -> "show_date", + "year" -> "1920", + "month" -> "12", + "day" -> "10" + ) + ) + + testRoute( + router, "blog/edit/5", Map( + "controller" -> "blog", + "action" -> "edit", + "id" -> "5" + ) + ) + + testRoute( + router, "blog/show/123", Map( + "controller" -> "blog", + "action" -> "show", + "id" -> "123" + ) + ) + + testRoute( + router, "blog/some_crazy_long_winded_action_name/12356789101112131151", Map( + "controller" -> "blog", + "action" -> "some_crazy_long_winded_action_name", + "id" -> "12356789101112131151" + ) + ) + + testRoute( + router, "blog/delete/5", Map( + "controller" -> "blog", + "action" -> "delete", + "id" -> "5" + ) + ) + + // TODO support optionals + // testRoute( + // router, "test/x1", Map( + // "controller" -> "test", + // "x" -> "x1", + // "y" -> "y" + // ) + // ) + } +} -- cgit v1.2.3