From 64e67cfd70f18b67948fd3f7b03e39af9b5d31c3 Mon Sep 17 00:00:00 2001 From: Jesse Luehrs Date: Thu, 28 Feb 2013 23:28:55 -0600 Subject: cleanups --- .gitignore | 3 +- src/main/scala/org/perl8/router/Router.scala | 177 ++++++++++++++++++ src/main/scala/router.scala | 202 --------------------- src/test/scala/basic.scala | 126 ------------- src/test/scala/helpers.scala | 40 ---- src/test/scala/optional.scala | 85 --------- src/test/scala/org/perl8/router/BasicTest.scala | 127 +++++++++++++ src/test/scala/org/perl8/router/OptionalTest.scala | 86 +++++++++ src/test/scala/org/perl8/router/test.scala | 40 ++++ 9 files changed, 431 insertions(+), 455 deletions(-) create mode 100644 src/main/scala/org/perl8/router/Router.scala delete mode 100644 src/main/scala/router.scala delete mode 100644 src/test/scala/basic.scala delete mode 100644 src/test/scala/helpers.scala delete mode 100644 src/test/scala/optional.scala create mode 100644 src/test/scala/org/perl8/router/BasicTest.scala create mode 100644 src/test/scala/org/perl8/router/OptionalTest.scala create mode 100644 src/test/scala/org/perl8/router/test.scala diff --git a/.gitignore b/.gitignore index 007798c..2f7896d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -/target -/project +target/ diff --git a/src/main/scala/org/perl8/router/Router.scala b/src/main/scala/org/perl8/router/Router.scala new file mode 100644 index 0000000..c169b28 --- /dev/null +++ b/src/main/scala/org/perl8/router/Router.scala @@ -0,0 +1,177 @@ +package org.perl8.router + +import scala.collection.mutable.ListBuffer +import scala.util.matching.Regex + +class Router[T] { + def addRoute ( + path: String, + target: T, + defaults: Map[String, String] = Map.empty, + validations: Map[String, Regex] = Map.empty + ) { + routes += new Route(path, defaults, validations, target) + } + + def route (path: String): Option[Match[T]] = { + val components = path.split("/").filter { + case "" => false + case "." => false // XXX do we want to keep this? + case _ => true + } + routes.view.flatMap(r => r.route(components)).headOption + } + + def uriFor (mapping: Map[String, String]): Option[String] = { + // first remove all routes that can't possibly match + // - if the route requires a variable component that doesn't exist in the + // mapping, then it can't match + // - if the route contains a value for a variable component that doesn't + // pass the validation for that component, it can't match + // - if the route contains a default value, and that component also exists + // in the mapping, then the values must match + val possible = routes.toList.flatMap { r => + r.pathWithMapping(mapping).map(p => r -> p) + } + + possible match { + case Nil => None + case (r, path) :: Nil => Some(path) + case _ => { + // then try to disambiguate the remaining possibilities + // - we want the route with the fewest number of "extra" items in the + // mapping, after removing defaults and variable path components + val possibleByRemainder = possible.groupBy { case (r, path) => { + (mapping.keys.toSet -- r.defaults.keys.toSet -- r.variables).size + } } + val found = possibleByRemainder(possibleByRemainder.keys.min) + found match { + case Nil => None + case (r, path) :: Nil => Some(path) + case rs => + throw new AmbiguousRouteMapping(mapping, rs.map(_._1.path)) + } + } + } + } + + private val routes = new ListBuffer[Route] + + private class Route ( + val path: String, + val defaults: Map[String, String], + val validations: Map[String, Regex], + val target: T + ) { + lazy val variables = components.flatMap(getVariableName) + + def route ( + parts: Seq[String], + components: Seq[String] = components, + mapping: Map[String, String] = defaults + ): Option[Match[T]] = { + if (components.filter(!isOptional(_)).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) => { + if (validate(name, parts.head)) { + route(parts.tail, components.tail, mapping + (name -> parts.head)) + } + else { + route(parts, components.tail, mapping) + } + } + 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 + + def pathWithMapping (mapping: Map[String, String]): Option[String] = { + val requiredDefaults = defaults.keys.filter { k => + mapping.isDefinedAt(k) && !variables.contains(k) + } + if (requiredDefaults.forall(k => defaults(k) == mapping(k)) && + requiredVariables.forall(mapping.isDefinedAt)) { + val boundComponents = components.flatMap { + case Optional(v) => { + val component = (mapping get v).flatMap(validComponentValue(v, _)) + defaults.get(v).map { default => + component.flatMap { + case `default` => None + case c => Some(c) + } + }.getOrElse(component) + } + case Variable(v) => validComponentValue(v, (mapping(v))) + case literal => Some(literal) + } + Some(boundComponents.mkString("/")) + } + else { + None + } + } + + private lazy val components = + path.split("/").filter(_.length > 0) + + private lazy val requiredVariables = + components.filter(!isOptional(_)).flatMap(getVariableName) + + private lazy val hasVariable = variables.toSet + + private def isOptional (component: String) = + Optional.findFirstIn(component).nonEmpty + + private def isVariable (component: String) = + Variable.findFirstIn(component).nonEmpty + + private def getVariableName (component: String) = component match { + case Variable(name) => Some(name) + case _ => None + } + + private def validate (name: String, component: String) = + validations.get(name).forall(_.findFirstIn(component).nonEmpty) + + private def validComponentValue (name: String, component: String) = + if (validate(name, component)) Some(component) else None + + private val Optional = """^\?:(.*)$""".r + private val Variable = """^\??:(.*)$""".r + } + + private class AmbiguousRouteMapping ( + mapping: Map[String, String], + paths: Seq[String] + ) extends RuntimeException( + "Ambiguous path descriptor (specified keys " + + mapping.keys.mkString(", ") + + "): could match paths " + + paths.mkString(", ") + ) +} + +class Match[T] ( + val path: String, + val mapping: Map[String, String], + val target: T +) diff --git a/src/main/scala/router.scala b/src/main/scala/router.scala deleted file mode 100644 index 4a9f446..0000000 --- a/src/main/scala/router.scala +++ /dev/null @@ -1,202 +0,0 @@ -package router - -import scala.collection.mutable.ListBuffer -import scala.util.matching.Regex - -class Router[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 route (path: String): Option[Match[T]] = { - def testRoutes ( - components: Seq[String], - routes: List[Route] - ): Option[Match[T]] = routes match { - case r :: rs => r.route(components) match { - case Some(found) => Some(found) - case None => testRoutes(components, rs) - } - case _ => None - } - val components = path.split("/").filter { - case "" => false - case "." => false // XXX do we want to keep this? - case _ => true - } - testRoutes(components, routes.toList) - } - - def uriFor (mapping: Map[String, String]): Option[String] = { - // first remove all routes that can't possibly match - // - if the route requires a variable component that doesn't exist in the - // mapping, then it can't match - // - if the route contains a value for a variable component that doesn't - // pass the validation for that component, it can't match - // - if the route contains a default value, and that component also exists - // in the mapping, then the values must match - val possible = routes.toList.flatMap(r => { - r.pathWithMapping(mapping) match { - case Some(path) => Some(r -> path) - case None => None - } - }) - - possible match { - case Nil => None - case (r, path) :: Nil => Some(path) - case _ => { - // then try to disambiguate the remaining possibilities - // - we want the route with the fewest number of "extra" items in the - // mapping, after removing defaults and variable path components - val possibleByRemainder = possible.groupBy { case (r, path) => { - (mapping.keys.toSet -- r.defaults.keys.toSet -- r.variables).size - } } - val found = possibleByRemainder(possibleByRemainder.keys.min) - found match { - case Nil => None - case (r, path) :: Nil => Some(path) - case rs => - throw new AmbiguousRouteMapping(mapping, rs.map(_._1.path)) - } - } - } - } - - private val routes = new ListBuffer[Route] - - private class Route ( - val path: String, - val defaults: Map[String, String], - val validations: Map[String, Regex], - val target: T - ) { - import Route._ - - lazy val variables = components.flatMap(getVariableName) - - def route( - parts: Seq[String], - components: Seq[String] = components, - mapping: Map[String, String] = defaults - ): Option[Match[T]] = { - if (components.filter(!isOptional(_)).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) => { - if (validate(name, parts.head)) { - route(parts.tail, components.tail, mapping + (name -> parts.head)) - } - else { - route(parts, components.tail, mapping) - } - } - 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 - - def pathWithMapping (mapping: Map[String, String]): Option[String] = { - val requiredDefaults = defaults.keys.filter { k => - mapping.isDefinedAt(k) && !variables.contains(k) - } - if (requiredDefaults.forall(k => defaults(k) == mapping(k)) && - requiredVariables.forall(mapping.isDefinedAt)) { - val boundComponents = components.flatMap { - case Optional(v) => { - val component = (mapping get v).flatMap(validComponentValue(v, _)) - defaults get v match { - case Some(default) => { - component.flatMap { - case `default` => None - case c => Some(c) - } - } - case None => component - } - } - case Variable(v) => validComponentValue(v, (mapping(v))) - case literal => Some(literal) - } - Some(boundComponents.mkString("/")) - } - else { - None - } - } - - private lazy val components = - path.split("/").filter(_.length > 0) - - private lazy val requiredVariables = - components.filter(!isOptional(_)).flatMap(getVariableName) - - private lazy val hasVariable = variables.toSet - - private def isOptional (component: String) = - Optional.findFirstIn(component).nonEmpty - - private def isVariable (component: String) = - Variable.findFirstIn(component).nonEmpty - - private def getVariableName (component: String) = component match { - case Variable(name) => Some(name) - case _ => None - } - - private def validate (name: String, component: String) = - validations get name match { - case Some(rx) => rx.findFirstIn(component).nonEmpty - case None => true - } - - private def validComponentValue (name: String, component: String) = - if (validate(name, component)) { Some(component) } else { None } - } - - private object Route { - private val Optional = """^\?:(.*)$""".r - private val Variable = """^\??:(.*)$""".r - } - - private class AmbiguousRouteMapping( - mapping: Map[String, String], - paths: Seq[String] - ) extends RuntimeException { - override def getMessage (): String = { - "Ambiguous path descriptor (specified keys " + - mapping.keys.mkString(", ") + - "): could match paths " + - paths.mkString(", ") - } - } -} - -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 deleted file mode 100644 index 56655e4..0000000 --- a/src/test/scala/basic.scala +++ /dev/null @@ -1,126 +0,0 @@ -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", - true, - defaults = Map( - "controller" -> "blog", - "action" -> "index" - ) - ) - - router addRoute ( - "blog/:year/:month/:day", - true, - defaults = Map( - "controller" -> "blog", - "action" -> "show_date" - ), - validations = Map( - "year" -> yearRx, - "month" -> monthRx, - "day" -> dayRx - ) - ) - - router addRoute ( - "blog/:action/:id", - true, - defaults = Map( - "controller" -> "blog" - ), - validations = Map( - "action" -> """\D+""".r, - "id" -> """\d+""".r - ) - ) - - router addRoute ( - "test/?:x/?:y", - true, - defaults = Map( - "controller" -> "test", - "x" -> "x", - "y" -> "y" - ) - ) - - test ("routes match properly") { - assert( - router matches "blog", Map( - "controller" -> "blog", - "action" -> "index" - ) - ) - - assert( - router matches "blog/2006/12/5", Map( - "controller" -> "blog", - "action" -> "show_date", - "year" -> "2006", - "month" -> "12", - "day" -> "5" - ) - ) - - assert( - router matches "blog/1920/12/10", Map( - "controller" -> "blog", - "action" -> "show_date", - "year" -> "1920", - "month" -> "12", - "day" -> "10" - ) - ) - - assert( - router matches "blog/edit/5", Map( - "controller" -> "blog", - "action" -> "edit", - "id" -> "5" - ) - ) - - assert( - router matches "blog/show/123", Map( - "controller" -> "blog", - "action" -> "show", - "id" -> "123" - ) - ) - - assert( - router matches "blog/some_crazy_long_winded_action_name/12356789101112131151", Map( - "controller" -> "blog", - "action" -> "some_crazy_long_winded_action_name", - "id" -> "12356789101112131151" - ) - ) - - assert( - router matches "blog/delete/5", Map( - "controller" -> "blog", - "action" -> "delete", - "id" -> "5" - ) - ) - - assert( - router matches "test/x1", Map( - "controller" -> "test", - "x" -> "x1", - "y" -> "y" - ) - ) - } -} diff --git a/src/test/scala/helpers.scala b/src/test/scala/helpers.scala deleted file mode 100644 index f2f561c..0000000 --- a/src/test/scala/helpers.scala +++ /dev/null @@ -1,40 +0,0 @@ -package router - -object test { - import language.implicitConversions - - class RouterHelperOps[T] (router: Router[T]) { - private def assert (condition: Boolean, msg: String) = { - if (condition) { None } else { Some(msg) } - } - - def matches (path: String) = { - assert(router.route(path).isDefined, s"route failed to match $path") - } - - def matches (path: String, mapping: Map[String, String]) = { - (router.uriFor(mapping), router.route(path)) match { - case (Some(uriFor), Some(m)) => { - None.orElse({ - assert(uriFor == path, s"uriFor returned $uriFor, expected $path") - }).orElse({ - assert( - m.mapping.size == mapping.size && - m.mapping.forall { case (k, v) => mapping(k) == v }, - s"route returned ${m.mapping}, expected $mapping" - ) - }) - } - case (None, None) => - Some(s"uriFor and route both failed to match $path") - case (None, _) => - Some(s"uriFor failed to match $path") - case (_, None) => - Some(s"route failed to match $path") - } - } - } - - implicit def routerToOps[T] (router: Router[T]): RouterHelperOps[T] = - new RouterHelperOps(router) -} diff --git a/src/test/scala/optional.scala b/src/test/scala/optional.scala deleted file mode 100644 index ade237c..0000000 --- a/src/test/scala/optional.scala +++ /dev/null @@ -1,85 +0,0 @@ -import org.scalatest.FunSuite -import router.test._ - -import router.Router - -class Optional extends FunSuite { - val router = new Router[Boolean] - - router addRoute ( - ":controller/?:action", - true, - defaults = Map( - "action" -> "index" - ), - validations = Map( - "action" -> """\D+""".r - ) - ) - - router addRoute ( - ":controller/:id/?:action", - true, - defaults = Map( - "action" -> "show" - ), - validations = Map( - "id" -> """\d+""".r - ) - ) - - test ("routes match properly") { - assert( - router matches "people", Map( - "controller" -> "people", - "action" -> "index" - ) - ) - - assert( - router matches "people/new", Map( - "controller" -> "people", - "action" -> "new" - ) - ) - - assert( - router matches "people/create", Map( - "controller" -> "people", - "action" -> "create" - ) - ) - - assert( - router matches "people/56", Map( - "controller" -> "people", - "action" -> "show", - "id" -> "56" - ) - ) - - assert( - router matches "people/56/edit", Map( - "controller" -> "people", - "action" -> "edit", - "id" -> "56" - ) - ) - - assert( - router matches "people/56/remove", Map( - "controller" -> "people", - "action" -> "remove", - "id" -> "56" - ) - ) - - assert( - router matches "people/56/update", Map( - "controller" -> "people", - "action" -> "update", - "id" -> "56" - ) - ) - } -} diff --git a/src/test/scala/org/perl8/router/BasicTest.scala b/src/test/scala/org/perl8/router/BasicTest.scala new file mode 100644 index 0000000..b00d1ae --- /dev/null +++ b/src/test/scala/org/perl8/router/BasicTest.scala @@ -0,0 +1,127 @@ +package org.perl8.router + +import org.scalatest.FunSuite + +import org.perl8.router.test._ + +class BasicTest 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", + true, + defaults = Map( + "controller" -> "blog", + "action" -> "index" + ) + ) + + router addRoute ( + "blog/:year/:month/:day", + true, + defaults = Map( + "controller" -> "blog", + "action" -> "show_date" + ), + validations = Map( + "year" -> yearRx, + "month" -> monthRx, + "day" -> dayRx + ) + ) + + router addRoute ( + "blog/:action/:id", + true, + defaults = Map( + "controller" -> "blog" + ), + validations = Map( + "action" -> """\D+""".r, + "id" -> """\d+""".r + ) + ) + + router addRoute ( + "test/?:x/?:y", + true, + defaults = Map( + "controller" -> "test", + "x" -> "x", + "y" -> "y" + ) + ) + + test ("routes match properly") { + assert( + router matches "blog", Map( + "controller" -> "blog", + "action" -> "index" + ) + ) + + assert( + router matches "blog/2006/12/5", Map( + "controller" -> "blog", + "action" -> "show_date", + "year" -> "2006", + "month" -> "12", + "day" -> "5" + ) + ) + + assert( + router matches "blog/1920/12/10", Map( + "controller" -> "blog", + "action" -> "show_date", + "year" -> "1920", + "month" -> "12", + "day" -> "10" + ) + ) + + assert( + router matches "blog/edit/5", Map( + "controller" -> "blog", + "action" -> "edit", + "id" -> "5" + ) + ) + + assert( + router matches "blog/show/123", Map( + "controller" -> "blog", + "action" -> "show", + "id" -> "123" + ) + ) + + assert( + router matches "blog/some_crazy_long_winded_action_name/12356789101112131151", Map( + "controller" -> "blog", + "action" -> "some_crazy_long_winded_action_name", + "id" -> "12356789101112131151" + ) + ) + + assert( + router matches "blog/delete/5", Map( + "controller" -> "blog", + "action" -> "delete", + "id" -> "5" + ) + ) + + assert( + router matches "test/x1", Map( + "controller" -> "test", + "x" -> "x1", + "y" -> "y" + ) + ) + } +} diff --git a/src/test/scala/org/perl8/router/OptionalTest.scala b/src/test/scala/org/perl8/router/OptionalTest.scala new file mode 100644 index 0000000..1073487 --- /dev/null +++ b/src/test/scala/org/perl8/router/OptionalTest.scala @@ -0,0 +1,86 @@ +package org.perl8.router + +import org.scalatest.FunSuite + +import org.perl8.router.test._ + +class OptionalTest extends FunSuite { + val router = new Router[Boolean] + + router addRoute ( + ":controller/?:action", + true, + defaults = Map( + "action" -> "index" + ), + validations = Map( + "action" -> """\D+""".r + ) + ) + + router addRoute ( + ":controller/:id/?:action", + true, + defaults = Map( + "action" -> "show" + ), + validations = Map( + "id" -> """\d+""".r + ) + ) + + test ("routes match properly") { + assert( + router matches "people", Map( + "controller" -> "people", + "action" -> "index" + ) + ) + + assert( + router matches "people/new", Map( + "controller" -> "people", + "action" -> "new" + ) + ) + + assert( + router matches "people/create", Map( + "controller" -> "people", + "action" -> "create" + ) + ) + + assert( + router matches "people/56", Map( + "controller" -> "people", + "action" -> "show", + "id" -> "56" + ) + ) + + assert( + router matches "people/56/edit", Map( + "controller" -> "people", + "action" -> "edit", + "id" -> "56" + ) + ) + + assert( + router matches "people/56/remove", Map( + "controller" -> "people", + "action" -> "remove", + "id" -> "56" + ) + ) + + assert( + router matches "people/56/update", Map( + "controller" -> "people", + "action" -> "update", + "id" -> "56" + ) + ) + } +} diff --git a/src/test/scala/org/perl8/router/test.scala b/src/test/scala/org/perl8/router/test.scala new file mode 100644 index 0000000..e1fff79 --- /dev/null +++ b/src/test/scala/org/perl8/router/test.scala @@ -0,0 +1,40 @@ +package org.perl8.router + +object test { + import language.implicitConversions + + class RouterHelperOps[T] (router: Router[T]) { + private def assert (condition: Boolean, msg: String) = { + if (condition) None else Some(msg) + } + + def matches (path: String) = { + assert(router.route(path).isDefined, s"route failed to match $path") + } + + def matches (path: String, mapping: Map[String, String]) = { + (router.uriFor(mapping), router.route(path)) match { + case (Some(uriFor), Some(m)) => { + None.orElse({ + assert(uriFor == path, s"uriFor returned $uriFor, expected $path") + }).orElse({ + assert( + m.mapping.size == mapping.size && + m.mapping.forall { case (k, v) => mapping(k) == v }, + s"route returned ${m.mapping}, expected $mapping" + ) + }) + } + case (None, None) => + Some(s"uriFor and route both failed to match $path") + case (None, _) => + Some(s"uriFor failed to match $path") + case (_, None) => + Some(s"route failed to match $path") + } + } + } + + implicit def routerToOps[T] (router: Router[T]): RouterHelperOps[T] = + new RouterHelperOps(router) +} -- cgit v1.2.3-54-g00ecf