From a45a54c88d1beb22c6a793226541611b2ade4a6c Mon Sep 17 00:00:00 2001 From: Tim Nieradzik Date: Mon, 23 Mar 2020 12:36:47 +0000 Subject: [PATCH] Route: Require exact matches when parsing Instead of relying on route ordering, a parse should only be successful if no path elements are left. This reverts to the behaviour pre-v0.2.0. URLs will still parse if they contain parameters or a fragment that were not specified in the route. However, this also can be restricted with the newly introduced function `parseArgsStrict()`. In cases where remaining path elements or parameters are expected, the matchers `Elems` and `Params` were added. Further changes include renaming of two `Route` functions: 1. `parse()` -> `parseArgs()` 2. `parseInternal()` -> `parse()` Since these are potentially breaking changes and may require developers to update their routing tables, the version number was increased to v0.3.0. Closes #37. --- manual/introduction.md | 39 +++-- manual/listings.json | 43 +++-- .../main/scala/trail/manual/Listings.scala | 38 +++-- shared/src/main/scala/trail/Path.scala | 2 +- shared/src/main/scala/trail/Route.scala | 154 +++++++++++++++--- .../test/scala/trail/RouteFragmentTests.scala | 19 ++- .../test/scala/trail/RouteParamTests.scala | 66 +++++--- .../test/scala/trail/RouteParseTests.scala | 76 ++++++--- .../test/scala/trail/RoutingTableTests.scala | 4 +- shared/src/test/scala/trail/UrlTests.scala | 11 ++ version.sbt | 2 +- 11 files changed, 330 insertions(+), 124 deletions(-) diff --git a/manual/introduction.md b/manual/introduction.md index 16a1dfc..053fb46 100644 --- a/manual/introduction.md +++ b/manual/introduction.md @@ -1,5 +1,5 @@ # Introduction -Trail is a routing library for Scala. It allows to define type-safe routes, generate URLs and perform pattern matching. +Trail is a routing library for Scala. It allows defining type-safe routes, generating URLs and performing pattern matching. ## Installation Add the following dependencies to your build configuration: @@ -10,35 +10,46 @@ libraryDependencies += "tech.sparse" %%% "trail" % "%version%" // Scala.js, Sca ``` ## Usage -Create a route: +First, import Trail's DSL and define a type-safe route: -Create a string URL by filling the placeholders of the route: +To fill the route's placeholders, call the `url()` or `apply()` functions: -Parse an URL: -+When parsing an URL, Trail maps the values onto Scala types. The result will also contain any unmatched path elements, arguments or the fragment: + -Define and parse a route with a query parameter: +We will now define a route with one query parameter. Here, we are only interested in the arguments and exact path matches. For this use case, Trail provides the function `parseArgs()`: -A query parameter may be optional: +The output shows that additional arguments are still permitted. If this is undesired, you can call `parseArgsStrict()` to parse a route more strictly: ++ +Routes may specify optional query parameters: -A fragment can be specified: +You can match fragments, too: -Create a routing table: -+Since `parseArgs()` disallows additional path elements, you can match them only on specific routes using `Elems`. It should be the last DSL combinator in the route definition: ++ +Similarly, additional parameters can be matched with `Params`: ++ +Routing tables can be expressed with pattern matching: ++ +The underlying `unapply()` function calls `parseArgs()` instead of `parse()`. Therefore, the ordering of routes does not impact precedence. -Note that you can populate the `trail.Path()` data structure yourself and use it in place of its string counterpart. This is useful if you are using a third-party routing library which already provides you with a deconstructed URL: +You may populate the `trail.Path()` data structure yourself and use it in place of an URL. This is useful if an HTTP server already provides the path and arguments of requests: -Define a custom codec that can be used in arguments, parameters and fragments: +Trail defines codecs for common Scala types. You can define a custom codec for any type. These codecs can be used in arguments, parameters and fragments: -Define a custom path element type: +It is possible to define a custom path element type, too: -Encode and decode [URI values](https://en.wikipedia.org/wiki/Percent-encoding): +Trail provides helper utilities, for example to encode and decode [URI values](https://en.wikipedia.org/wiki/Percent-encoding): diff --git a/manual/listings.json b/manual/listings.json index a4a29c0..ebf43ae 100644 --- a/manual/listings.json +++ b/manual/listings.json @@ -4,40 +4,55 @@ "language" : "scala", "result" : "%C3%A4%C3%B6%C3%BC\näöü" }, - "map" : { - "code" : "println(details.parse(\"/details/42\"))", + "url" : { + "code" : "println(details.url(1))\nprintln(details(1))", "language" : "scala", - "result" : "Some(42)" + "result" : "/details/1\n/details/1" }, - "url" : { - "code" : "println(details.url(1)) // Shorter: details(1)", + "query-params-strict" : { + "code" : "println(route.parseArgsStrict(\"/details/sub-page\"))\nprintln(route.parseArgsStrict(\"/details?show=false\"))\nprintln(route.parseArgsStrict(\"/details?show=false&a=b\"))\nprintln(route.parseArgsStrict(\"/details#frag\"))", "language" : "scala", - "result" : "/details/1" + "result" : "None\nSome(false)\nNone\nNone" }, "parse-path" : { - "code" : "val result2 = trail.Path(\"/user/hello\", List(\"show\" -> \"false\")) match {\n case details (a) => s\"details: $a\"\n case userInfo((u, s)) => s\"user: $u, show: $s\"\n}\nprintln(result2)", + "code" : "val (requestPath, requestParams) = (\"/user/hello\", List(\"show\" -> \"false\"))\nval result2 = trail.Path(requestPath, requestParams) match {\n case details (a) => s\"details: $a\"\n case userInfo((u, s)) => s\"user: $u, show: $s\"\n}\nprintln(result2)", "language" : "scala", "result" : "user: hello, show: false" }, + "additional-elems" : { + "code" : "val routeAdditionalElems = Root / \"a\" / Elems\nprintln(routeAdditionalElems.parseArgs(\"/a/b/c\"))", + "language" : "scala", + "result" : "Some(List(b, c))" + }, "query-params-opt" : { - "code" : "val routeParamsOpt = Root / \"details\" & Param[Int](\"id\") & Param[Option[Boolean]](\"show\")\nprintln(routeParamsOpt.parse(\"/details?id=42\"))", + "code" : "val routeParamsOpt = Root / \"details\" & Param[Int](\"id\") & Param[Option[Boolean]](\"show\")\nprintln(routeParamsOpt.parseArgs(\"/details?id=42\"))", "language" : "scala", "result" : "Some((42,None))" }, "parse" : { + "code" : "println(details.parse(\"/details/42\"))\nprintln(details.parse(\"/details/42/sub-page?name=value\"))\nprintln(details.parse(\"/details/42/sub-page?name=value#frag\"))", + "language" : "scala", + "result" : "Some((42,Path(,List(),None)))\nSome((42,Path(sub-page,List((name,value)),None)))\nSome((42,Path(sub-page,List((name,value)),Some(frag))))" + }, + "query-params" : { + "code" : "val route = Root / \"details\" & Param[Boolean](\"show\")\nprintln(route.parseArgs(\"/details/sub-page\"))\nprintln(route.parseArgs(\"/details?show=false\"))\nprintln(route.parseArgs(\"/details?show=false&a=b\"))\nprintln(route.parseArgs(\"/details#frag\"))", + "language" : "scala", + "result" : "None\nSome(false)\nSome(false)\nNone" + }, + "routing-table" : { "code" : "val userInfo = Root / \"user\" / Arg[String] & Param[Boolean](\"show\")\n\nval result = \"/user/hello?show=false\" match {\n case details (a) => s\"details: $a\"\n case userInfo((u, s)) => s\"user: $u, show: $s\"\n}\nprintln(result)", "language" : "scala", "result" : "user: hello, show: false" }, - "query-params" : { - "code" : "val route = Root / \"details\" & Param[Boolean](\"show\")\nprintln(route.parse(\"/details?show=false\"))", + "additional-params" : { + "code" : "val routeAdditionalParams = Root / Arg[String] & Params\nprintln(routeAdditionalParams.parseArgs(\"/a?param1=value1¶m2=value2\"))", "language" : "scala", - "result" : "Some(false)" + "result" : "Some((a,List((param1,value1), (param2,value2))))" }, "route" : { - "code" : "import trail._\nimport shapeless._\n\nval details = Root / \"details\" / Arg[Int]\nprintln(details)", + "code" : "import trail._\nval details = Root / \"details\" / Arg[Int]", "language" : "scala", - "result" : "ConcatRight(ConcatRight(Root,Static(details)),Dynamic(Arg()))" + "result" : null }, "custom-path-elem" : { "code" : "case class Foo(bar: String)\nimplicit object FooElement extends StaticElement[Foo](_.bar)\n\nprintln((Root / Foo(\"asdf\")).url(()))", @@ -50,7 +65,7 @@ "result" : "/export/1,2,3" }, "query-fragment" : { - "code" : "val routeFragment = Root $ Fragment[Int]\nprintln(routeFragment.parse(\"/#42\"))", + "code" : "val routeFragment = Root $ Fragment[Int]\nprintln(routeFragment.parseArgs(\"/#42\"))", "language" : "scala", "result" : "Some(42)" } diff --git a/manual/src/main/scala/trail/manual/Listings.scala b/manual/src/main/scala/trail/manual/Listings.scala index 426834f..5af61ab 100644 --- a/manual/src/main/scala/trail/manual/Listings.scala +++ b/manual/src/main/scala/trail/manual/Listings.scala @@ -7,30 +7,47 @@ object Listings extends App { listing("route") import trail._ - import shapeless._ - val details = Root / "details" / Arg[Int] - println(details) listing("url") - println(details.url(1)) // Shorter: details(1) + println(details.url(1)) + println(details(1)) - listing("map") + listing("parse") println(details.parse("/details/42")) + println(details.parse("/details/42/sub-page?name=value")) + println(details.parse("/details/42/sub-page?name=value#frag")) listing("query-params") val route = Root / "details" & Param[Boolean]("show") - println(route.parse("/details?show=false")) + println(route.parseArgs("/details/sub-page")) + println(route.parseArgs("/details?show=false")) + println(route.parseArgs("/details?show=false&a=b")) + println(route.parseArgs("/details#frag")) + + listing("query-params-strict") + println(route.parseArgsStrict("/details/sub-page")) + println(route.parseArgsStrict("/details?show=false")) + println(route.parseArgsStrict("/details?show=false&a=b")) + println(route.parseArgsStrict("/details#frag")) listing("query-params-opt") val routeParamsOpt = Root / "details" & Param[Int]("id") & Param[Option[Boolean]]("show") - println(routeParamsOpt.parse("/details?id=42")) + println(routeParamsOpt.parseArgs("/details?id=42")) listing("query-fragment") val routeFragment = Root $ Fragment[Int] - println(routeFragment.parse("/#42")) + println(routeFragment.parseArgs("/#42")) - listing("parse") + listing("additional-elems") + val routeAdditionalElems = Root / "a" / Elems + println(routeAdditionalElems.parseArgs("/a/b/c")) + + listing("additional-params") + val routeAdditionalParams = Root / Arg[String] & Params + println(routeAdditionalParams.parseArgs("/a?param1=value1¶m2=value2")) + + listing("routing-table") val userInfo = Root / "user" / Arg[String] & Param[Boolean]("show") val result = "/user/hello?show=false" match { @@ -40,7 +57,8 @@ object Listings extends App { println(result) listing("parse-path") - val result2 = trail.Path("/user/hello", List("show" -> "false")) match { + val (requestPath, requestParams) = ("/user/hello", List("show" -> "false")) + val result2 = trail.Path(requestPath, requestParams) match { case details (a) => s"details: $a" case userInfo((u, s)) => s"user: $u, show: $s" } diff --git a/shared/src/main/scala/trail/Path.scala b/shared/src/main/scala/trail/Path.scala index 5ffb41f..c2c9227 100644 --- a/shared/src/main/scala/trail/Path.scala +++ b/shared/src/main/scala/trail/Path.scala @@ -64,4 +64,4 @@ object PathParser { Path(path, args, fragment) } -} \ No newline at end of file +} diff --git a/shared/src/main/scala/trail/Route.scala b/shared/src/main/scala/trail/Route.scala index 745311d..827ae34 100644 --- a/shared/src/main/scala/trail/Route.scala +++ b/shared/src/main/scala/trail/Route.scala @@ -2,32 +2,114 @@ package trail sealed trait Route[Args] { def url(args: Args): String - def parseInternal(path: Path): Option[(Args, Path)] - def parse(path: Path): Option[Args] = parseInternal(path).map(_._1) - def parse(uri: String): Option[Args] = - parseInternal(PathParser.parse(uri)).map(_._1) + private def process(result: Option[(Args, Path)]): Option[Args] = + result.collect { case (args, p) if p.path.isEmpty => args } - def apply(value: Args): String = url(value) - def unapply(path: Path): Option[Args] = parse(path) - def unapply(uri: String): Option[Args] = parse(uri) + private def processStrict(result: Option[(Args, Path)]): Option[Args] = + result.collect { case (args, p) if + p.path.isEmpty && p.args.isEmpty && p.fragment.isEmpty => args + } + + /** + * Parse given [[Path]] + * + * If successful, the result contains the parsed arguments as well as the + * updated [[Path]] object. If the path was fully parsed, all of its fields + * should be empty. + * + * Use [[parseArgs]] or [[parseArgsStrict]] if you are only interested in the + * parsed arguments. + * + * @return Some((parsed arguments, updated path)) + */ + def parse(path: Path): Option[(Args, Path)] + + /** + * Parse given URI + * + * @see parse(path: Path) + */ + def parse(uri: String): Option[(Args, Path)] = parse(PathParser.parse(uri)) + + /** + * Parse arguments of given [[Path]] + * + * This returns None if `path` contains additional trailing elements missing + * from the route. + */ + def parseArgs(path: Path): Option[Args] = process(parse(path)) + + /** + * Parse arguments of given URI + * + * @see parseArgs(path: Path) + */ + def parseArgs(uri: String): Option[Args] = process(parse(uri)) + + /** + * Parse arguments of given [[Path]] + * + * Same behaviour as [[parseArgs]]. It does not permit any unspecified path + * elements, arguments or a fragment. + */ + def parseArgsStrict(path: Path): Option[Args] = processStrict(parse(path)) + + /** + * Parse arguments of given URI + * + * @see parseArgsStrict(path: Path) + */ + def parseArgsStrict(uri: String): Option[Args] = processStrict(parse(uri)) + + /** + * Generate URL for given arguments + */ + def apply(args: Args): String = url(args) + + /** + * Parse arguments for given [[Path]] + * + * @see [[parseArgs]] + */ + def unapply(path: Path): Option[Args] = parseArgs(path) + + /** + * Parse arguments for given URI + * + * @see [[parseArgs]] + */ + def unapply(uri: String): Option[Args] = parseArgs(uri) + + def &(params: Params.type): Route.ParamsRoute[Args] = Route.ParamsRoute(this) } case object Root extends Route[Unit] { override def url(value: Unit): String = "/" - override def parseInternal(path: Path): Option[(Unit, Path)] = + override def parse(path: Path): Option[(Unit, Path)] = if (!path.path.startsWith("/")) None else Some((), path.copy(path = path.path.tail)) } +case object Elems extends Route[List[String]] { + override def url(value: List[String]): String = value.mkString("/") + override def parse(path: Path): Option[(List[String], Path)] = + Some((PathParser.parseParts(path.path), path.copy(path = ""))) +} + +case object Params + object Route { implicit class Route0Extensions(route: Route[Unit]) { def /[T](arg: Arg[T]): Route.ConcatRight[T] = Route.ConcatRight(route, Route.Dynamic(arg)) def /[T](value: T)(implicit staticElement: StaticElement[T]): Route.ConcatRight[Unit] = Route.ConcatRight(route, Route.Static(staticElement.f(value))) + def /[T](rest: Elems.type): Route.ConcatRight[List[String]] = + Route.ConcatRight(route, Elems) def &[T](param: Param[T]): ParamRoute0[T] = ParamRoute0(route, param) + def &(params: Params.type): ParamsRoute[Unit] = ParamsRoute(route) def $[T](fragment: Fragment[T]): FragmentRoute0[T] = FragmentRoute0(route, fragment) } @@ -46,7 +128,7 @@ object Route { require(!element.contains("/"), "Element must not contain a slash") override def url(value: Unit): String = element - override def parseInternal(path: Path): Option[(Unit, Path)] = { + override def parse(path: Path): Option[(Unit, Path)] = { val untilSlash = path.path.takeWhile(_ != '/') if (untilSlash != element) None else Some(((), path.copy(path = path.path.drop(untilSlash.length + 1)))) @@ -55,7 +137,7 @@ object Route { case class Dynamic[T](arg: Arg[T]) extends Route[T] { override def url(value: T): String = arg.codec.encode(value).getOrElse("") - override def parseInternal(path: Path): Option[(T, Path)] = { + override def parse(path: Path): Option[(T, Path)] = { val untilSlash = path.path.takeWhile(_ != '/') arg.codec.decode(if (untilSlash.isEmpty) None else Some(untilSlash)) .map(value => (value, path.copy(path = @@ -65,29 +147,29 @@ object Route { case class ConcatLeft[L](left: Route[L], right: Route[Unit]) extends Route[L] { override def url(value: L): String = left.url(value) + "/" + right.url(()) - override def parseInternal(path: Path): Option[(L, Path)] = + override def parse(path: Path): Option[(L, Path)] = for { - (lv, lp) <- left.parseInternal(path) - (_ , rp) <- right.parseInternal(lp) + (lv, lp) <- left.parse(path) + (_ , rp) <- right.parse(lp) } yield (lv, rp) } case class ConcatRight[R](left: Route[Unit], right: Route[R]) extends Route[R] { override def url(value: R): String = (if (left == Root) "" else left.url(())) + "/" + right.url(value) - override def parseInternal(path: Path): Option[(R, Path)] = + override def parse(path: Path): Option[(R, Path)] = for { - (_ , lp) <- left.parseInternal(path) - (rv, rp) <- right.parseInternal(lp) + (_ , lp) <- left.parse(path) + (rv, rp) <- right.parse(lp) } yield (rv, rp) } case class ConcatBoth[L, R](left: Route[L], right: Route[R]) extends Route[(L, R)] { override def url(value: (L, R)): String = left.url(value._1) + "/" + right.url(value._2) - override def parseInternal(path: Path): Option[((L, R), Path)] = + override def parse(path: Path): Option[((L, R), Path)] = for { - (lv, lp) <- left.parseInternal(path) - (rv, rp) <- right.parseInternal(lp) + (lv, lp) <- left.parse(path) + (rv, rp) <- right.parse(lp) } yield ((lv, rv), rp) } @@ -97,9 +179,9 @@ object Route { case None => route.url(()) case Some(frag) => route.url(()) + "#" + frag } - override def parseInternal(path: Path): Option[(P, Path)] = + override def parse(path: Path): Option[(P, Path)] = for { - (_, lp) <- route.parseInternal(path) + (_, lp) <- route.parse(path) v <- fragment.codec.decode(lp.fragment) p = lp.copy(fragment = None) } yield (v, p) @@ -111,9 +193,9 @@ object Route { case None => route.url(value._1) case Some(frag) => route.url(value._1) + "#" + frag } - override def parseInternal(path: Path): Option[((A, P), Path)] = + override def parse(path: Path): Option[((A, P), Path)] = for { - (lv, lp) <- route.parseInternal(path) + (lv, lp) <- route.parse(path) rv <- fragment.codec.decode(lp.fragment) p = lp.copy(fragment = None) } yield ((lv, rv), p) @@ -125,9 +207,9 @@ object Route { .fold("")(v => param.name + "=" + URI.encode(v)) route.url(()) + (if (arg.isEmpty) "" else "?" + arg) } - override def parseInternal(path: Path): Option[(P, Path)] = + override def parse(path: Path): Option[(P, Path)] = for { - (_, lp) <- route.parseInternal(path) + (_, lp) <- route.parse(path) arg = lp.args.find(_._1 == param.name) v <- param.codec.decode(arg.map(_._2)) p = lp.copy(args = lp.args.diff(arg.toList)) @@ -145,12 +227,30 @@ object Route { base + delimiter + encodedParam } } - override def parseInternal(path: Path): Option[((A, P), Path)] = + override def parse(path: Path): Option[((A, P), Path)] = for { - (lv, lp) <- route.parseInternal(path) + (lv, lp) <- route.parse(path) arg = lp.args.find(_._1 == param.name) rv <- param.codec.decode(arg.map(_._2)) p = lp.copy(args = lp.args.diff(arg.toList)) } yield ((lv, rv), p) } + + case class ParamsRoute[A](route: Route[A]) extends Route[(A, List[(String, String)])] { + override def url(value: (A, List[(String, String)])): String = { + val (valueRoute, args) = value + val base = route.url(valueRoute) + if (args.isEmpty) base + else { + val argsEncoded = args.map { case (n, v) => n + "=" + URI.encode(v) } + base + (if (base.contains("?")) "&" else "?") + argsEncoded.mkString("&") + } + } + + override def parse(path: Path): Option[((A, List[(String, String)]), Path)] = + for { + (lv, lp) <- route.parse(path) + p = lp.copy(args = List()) + } yield ((lv, lp.args), p) + } } diff --git a/shared/src/test/scala/trail/RouteFragmentTests.scala b/shared/src/test/scala/trail/RouteFragmentTests.scala index a45072f..b2ddc0d 100644 --- a/shared/src/test/scala/trail/RouteFragmentTests.scala +++ b/shared/src/test/scala/trail/RouteFragmentTests.scala @@ -24,8 +24,8 @@ class RouteFragmentTests extends FunSpec with Matchers { it("Parse route with empty fragment") { val route = Root $ Fragment[String] - assert(route.parse("/").isEmpty) - assert(route.parse("/#").contains("")) + assert(route.parseArgs("/").isEmpty) + assert(route.parseArgs("/#").contains("")) } it("Generate URL for route with empty fragment") { @@ -36,8 +36,8 @@ class RouteFragmentTests extends FunSpec with Matchers { it("Parse route with optional fragment") { val route = Root $ Fragment[Option[String]] - assert(route.parse("/").contains(None)) - assert(route.parse("/#").contains(Some(""))) + assert(route.parseArgs("/").contains(None)) + assert(route.parseArgs("/#").contains(Some(""))) } it("Generate URL for route with optional fragment") { @@ -60,12 +60,17 @@ class RouteFragmentTests extends FunSpec with Matchers { it("Parse route with fragment") { val route = Root $ Fragment[String] - assert(route.parse("/").isEmpty) - assert(route.parse("/#value").contains("value")) + assert(route.parseArgs("/").isEmpty) + assert(route.parseArgs("/#value").contains("value")) } it("Parse route with parameter and fragment") { val route = Root & Param[String]("test") $ Fragment[Int] - assert(route.parse("/?test=value#42").contains(("value", 42))) + assert(route.parseArgs("/?test=value#42").contains(("value", 42))) + } + + it("Parse route with additional parameter and fragment") { + val route = Root & Params $ Fragment[Int] + assert(route.parseArgs("/?test=value#42").contains(() -> List("test" -> "value") -> 42)) } } diff --git a/shared/src/test/scala/trail/RouteParamTests.scala b/shared/src/test/scala/trail/RouteParamTests.scala index e214b11..183edbc 100644 --- a/shared/src/test/scala/trail/RouteParamTests.scala +++ b/shared/src/test/scala/trail/RouteParamTests.scala @@ -87,73 +87,91 @@ class RouteParamTests extends FunSpec with Matchers { it("Parsing route with one parameter") { val route = Root & Param[String]("test") - assert(route.parse("/?test=value") + assert(route.parseArgs("/?test=value") .contains("value")) - assert(route.parse("/?test=äöü").contains("äöü")) - assert(route.parse("/?test=%C3%A4%C3%B6%C3%BC") + assert(route.parseArgs("/?test=äöü").contains("äöü")) + assert(route.parseArgs("/?test=%C3%A4%C3%B6%C3%BC") .contains("äöü")) } it("Parsing route with unspecified parameter") { val route = Root & Param[String]("test") - assert(route.parse("/").isEmpty) - assert(route.parse("/?test2=value").isEmpty) + assert(route.parseArgs("/").isEmpty) + assert(route.parseArgs("/?test2=value").isEmpty) } it("Parsing route with two parameters") { val route = Root & Param[String]("test") & Param[Int]("test2") - val parsed = route.parse("/?test=value&test2=42") + val parsed = route.parseArgs("/?test=value&test2=42") assert(parsed.contains(("value", 42))) } it("Parsing route with additional parameters") { - // Ignore parameters that are not specified in the route + // By default, allow parameters that are not specified in the route val route = Root & Param[String]("test") - val parsed = route.parse("/?test=value&test2=value2") + val parsed = route.parseArgs("/?test=value&test2=value2") assert(parsed.contains("value")) + + val route2 = Root & Params + assert(route2.parseArgs("/?test=value").contains(() -> List("test" -> "value"))) + + val route3 = Root & Param[String]("test") & Params + assert(route3.parseArgs("/?test=value&test2=value2").contains("value" -> List("test2" -> "value2"))) } it("Parsing non-root route with two optional parameters") { val route = Root / "register" & Param[Option[String]]("plan") & Param[Option[String]]("redirect") - assert(route.parse("/register?plan=test") + assert(route.parseArgs("/register?plan=test") .contains((Option("test"), Option.empty[String]))) - assert(route.parse("/register?plan=test&redirect=test2") + assert(route.parseArgs("/register?plan=test&redirect=test2") .contains((Option("test"), Option("test2")))) - assert(route.parse("/register?redirect=test2") + assert(route.parseArgs("/register?redirect=test2") .contains((Option.empty[String], Option("test2")))) } it("Parsing route with optional parameter") { val route = Root & Param[Option[String]]("test") - assert(route.parse("/") - .contains(Option.empty[String])) - assert(route.parse("/?test2=value") - .contains(Option.empty[String])) - assert(route.parse("/?test=value") - .contains(Option("value"))) - assert(route.parse("/?test=value&test2=value") - .contains(Option("value"))) + + assert(route.parseArgs("/").contains(Option.empty[String])) + assert(route.parseArgs("/?test=value").contains(Option("value"))) + + assert(route.parseArgs("/?test2=value").contains(None)) + assert(route.parseArgs("/?test=value&test2=value").contains(Some("value"))) } it("Parsing route with duplicated name") { val route = Root & Param[String]("test") & Param[String]("test") - assert(route.parse("/?test=v1&test=v2") + assert(route.parseArgs("/?test=v1&test=v2") .contains(("v1", "v2"))) } it("Parsing route with duplicated name and different types") { val route = Root & Param[Int]("test") & Param[String]("test") // When the two parameters have different types, the order matters - assert(route.parse("/?test=value&test=42").isEmpty) - assert(route.parse("/?test=42&test=value") + assert(route.parseArgs("/?test=value&test=42").isEmpty) + assert(route.parseArgs("/?test=42&test=value") .contains((42, "value"))) } it("Only match parameter routes with same path") { val route = Root / "api" / "catalogue" / "content" & Param[String]("category") - assert(route.parse("/catalogue/content?category=Audio").isEmpty) - assert(route.parse("/api/catalogue/content?category=Audio") + assert(route.parseArgs("/catalogue/content?category=Audio").isEmpty) + assert(route.parseArgs("/api/catalogue/content?category=Audio") .contains("Audio")) } + + it("Parsing routes with additional parameters in strict mode") { + // In strict mode, forbid parameters that are not specified in the route + val route = Root & Param[String]("test") + val parsed = route.parseArgsStrict("/?test=value&test2=value2") + assert(parsed.isEmpty) + + val route2 = Root & Param[Option[String]]("test") + assert(route2.parseArgsStrict("/").contains(Option.empty[String])) + assert(route2.parseArgsStrict("/?test=value").contains(Option("value"))) + + assert(route2.parseArgsStrict("/?test2=value").isEmpty) + assert(route2.parseArgsStrict("/?test=value&test2=value").isEmpty) + } } diff --git a/shared/src/test/scala/trail/RouteParseTests.scala b/shared/src/test/scala/trail/RouteParseTests.scala index c306565..02a9097 100644 --- a/shared/src/test/scala/trail/RouteParseTests.scala +++ b/shared/src/test/scala/trail/RouteParseTests.scala @@ -7,70 +7,81 @@ import scala.util.Try class RouteParseTests extends FunSpec with Matchers { it("Parse root") { val root = Root - assert(root.parse("/").contains(())) + assert(root.parseArgs("/").contains(())) + + // Additional path elements cannot be specified + assert(root.parseArgs("/test").isEmpty) + } + + it("Parse root in strict mode") { + val root = Root + assert(root.parseArgsStrict("/").contains(())) + assert(root.parseArgsStrict("/test").isEmpty) + assert(root.parseArgsStrict("/?user=bob").isEmpty) + assert(root.parseArgsStrict("/#test").isEmpty) } it("Parse one path element") { val root = Root / "test" - assert(root.parse("/").isEmpty) - assert(root.parse("/test").contains(())) - assert(root.parse("/test2").isEmpty) + assert(root.parseArgs("/").isEmpty) + assert(root.parseArgs("/test").contains(())) + assert(root.parseArgs("/test2").isEmpty) } it("Route with one argument can be parsed") { val route = Root / "user" / Arg[String] - assert(route.parse(trail.Path("/user/bob")) === Some("bob")) - assert(route.parse(trail.Path("/user/bob/test")) === Some("bob")) + assert(route.parseArgs(trail.Path("/user/bob")) === Some("bob")) + assert(route.parseArgs(trail.Path("/user/bob/test")).isEmpty) } it("Route can be parsed from Path") { val userInfo = Root / "user" / Arg[String] / Arg[Boolean] - val parsed = userInfo.parse(trail.Path("/user/bob/true")) + val parsed = userInfo.parseArgs(trail.Path("/user/bob/true")) assert(parsed === Some(("bob", true))) - userInfo.parse(trail.Path("/user/bob")) shouldBe empty + userInfo.parseArgs(trail.Path("/user/bob")) shouldBe empty } it("Route with query parameters can be parsed from Path") { val userInfo = Root / "user" / Arg[String] / Arg[Boolean] & Param[Int]("n") - val parsed = userInfo.parse(trail.Path("/user/bob/true", List("n" -> "42"))) + val parsed = userInfo.parseArgs(trail.Path("/user/bob/true", List("n" -> "42"))) assert(parsed === Some((("bob", true), 42))) } it("Route with optional path element can be parsed") { val disk = Root / Arg[Option[Long]] - assert(disk.parse("/").contains(None)) - assert(disk.parse("/42").contains(Some(42L))) + assert(disk.parseArgs("/").contains(None)) + assert(disk.parseArgs("/42").contains(Some(42L))) } it("Route with optional path element can be parsed (2)") { val disk = Root / "disk" / Arg[Option[Long]] - assert(disk.parse("/disk").contains(None)) - assert(disk.parse("/disk/").contains(None)) - assert(disk.parse("/disk/42").contains(Some(42L))) + assert(disk.parseArgs("/disk").contains(None)) + assert(disk.parseArgs("/disk/").contains(None)) + assert(disk.parseArgs("/disk/42").contains(Some(42L))) } it("Route can be parsed from string") { val userInfo = Root / "user" / Arg[String] / Arg[Boolean] - val parsed = userInfo.parse("/user/bob/true") + val parsed = userInfo.parseArgs("/user/bob/true") parsed shouldBe defined assert(parsed === Some(("bob", true))) - // It is possible to provide more path elements than specified in the route - userInfo.parse("/user/bob/true/true") shouldBe defined + // It is forbidden to provide more path elements than specified in the route + userInfo.parseArgs("/user/bob/true/true") shouldBe empty - userInfo.parse("/user/bob") shouldBe empty - userInfo.parse("/user/bob/1") shouldBe empty - userInfo.parse("/usr/bob/1") shouldBe empty + userInfo.parseArgs("/user/bob") shouldBe empty + userInfo.parseArgs("/user/bob/1") shouldBe empty + userInfo.parseArgs("/usr/bob/1") shouldBe empty } it("Boolean argument can be parsed") { val userInfo = Root / "user" / Arg[String] / Arg[Boolean] - assert(userInfo.parse("/user/hello/false") + assert(userInfo.parseArgs("/user/hello/false") .contains(("hello", false))) } @@ -84,8 +95,25 @@ class RouteParseTests extends FunSpec with Matchers { } val route = Root / "size" & Param[Set[Long]]("folders") - assert(route.parse(route(Set[Long]())).contains(Set[Long]())) - assert(route.parse(route(Set[Long](1))).contains(Set[Long](1))) - assert(route.parse(route(Set[Long](1, 2, 3))).contains(Set[Long](1, 2, 3))) + assert(route.parseArgs(route(Set[Long]())).contains(Set[Long]())) + assert(route.parseArgs(route(Set[Long](1))).contains(Set[Long](1))) + assert(route.parseArgs(route(Set[Long](1, 2, 3))).contains(Set[Long](1, 2, 3))) + } + + it("Match any path elements") { + val route = Elems + assert(route.parseArgs("/a/b/c").contains(List("a", "b", "c"))) + } + + it("Route with additional path elements") { + val route = Root / Elems + assert(route.parseArgs("/a/b/c").contains(List("a", "b", "c"))) + + val route2 = Root / "a" / Elems + assert(route2.parseArgs("/a/b/c").contains(List("b", "c"))) + + val route3 = Root / "a" / Elems & Param[String]("test") + assert(route3.parseArgs("/a/b/c?test=value").contains( + List("b", "c") -> "value")) } } diff --git a/shared/src/test/scala/trail/RoutingTableTests.scala b/shared/src/test/scala/trail/RoutingTableTests.scala index e41cb7d..fc31858 100644 --- a/shared/src/test/scala/trail/RoutingTableTests.scala +++ b/shared/src/test/scala/trail/RoutingTableTests.scala @@ -40,8 +40,8 @@ class RoutingTableTests extends FunSpec with Matchers { val devRoutes = new Routes(isProduction = false) val prodRoutes = new Routes(isProduction = true) - assert(devRoutes.users.parse("/users/test").contains("test")) - assert(prodRoutes.users.parse("/api/v2.0/users/test").contains("test")) + assert(devRoutes.users.parseArgs("/users/test").contains("test")) + assert(prodRoutes.users.parseArgs("/api/v2.0/users/test").contains("test")) } it("Parsing multiple route with pattern matching") { diff --git a/shared/src/test/scala/trail/UrlTests.scala b/shared/src/test/scala/trail/UrlTests.scala index bd50856..fbf844b 100644 --- a/shared/src/test/scala/trail/UrlTests.scala +++ b/shared/src/test/scala/trail/UrlTests.scala @@ -139,4 +139,15 @@ class UrlTests2 extends FunSpec with Matchers { assert(url == "/disk/view?disk=48") } + + it("url() should work with additional parameters") { + val route = Root & Params + assert(route.url(() -> List("test" -> "value")) == "/?test=value") + + val route2 = Root & Param[String]("test") & Params + assert(route2.url("value" -> List("test2" -> "value2")) == "/?test=value&test2=value2") + + val route3 = Root & Params $ Fragment[Int] + assert(route3.url(() -> List("test" -> "value") -> 42) == "/?test=value#42") + } } diff --git a/version.sbt b/version.sbt index 9904311..87c01ed 100644 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -version in ThisBuild := "0.2.2-SNAPSHOT" +version in ThisBuild := "0.3.0-SNAPSHOT"