From fc2eec23ee39236fb5aef07480a49e6f5bee9abd Mon Sep 17 00:00:00 2001 From: Tim Nieradzik Date: Wed, 27 Feb 2019 12:50:50 +0100 Subject: [PATCH] Route: Only encode placeholders in type parameter Previously, static path elements were encoded in `Route`'s `HList`. This prevented conditional routes such as the following: ```scala if (isProduction) Root / "api" / "v2.0" else Root ``` Change the implementation such that only placeholders (e.g. `Arg[String]` or `Fragment[Int]`) are encoded in `Route`'s type parameter. Also, simplify the design by using tuples instead of `HList`s. As a consequence, the compile-time and run-time footprint is reduced. **Detailed changes:** - Use tuples instead of `HList`s - Eliminate all type casts - Drop all external dependencies (Shapeless, Cats) - Argument and fragment placeholders are now encoded in `Route`'s type parameter - Change fragment delimiter from `#` to `$` - Route parsing does not fail anymore when additional path elements are specified - Drop `ParamOpt[T]` in favour of `Param[Option[T]]` - Optional route arguments can now be populated with `None` instead of `Option.empty[T]` - Remove all type aliases - Custom `Codec`s can read/emit optional arguments Closes #14. Closes #28. --- README.md | 5 +- build.sbt | 4 - manual/listings.json | 24 +- .../main/scala/trail/manual/Listings.scala | 24 +- shared/src/main/scala/trail/Arg.scala | 16 - shared/src/main/scala/trail/Codec.scala | 40 +- shared/src/main/scala/trail/Param.scala | 30 -- shared/src/main/scala/trail/PathElement.scala | 34 +- shared/src/main/scala/trail/Route.scala | 344 +++++++----------- .../src/main/scala/trail/StaticElement.scala | 13 + shared/src/main/scala/trail/package.scala | 8 +- .../src/test/scala/trail/RouteDataTests.scala | 36 +- ...teTests.scala => RouteEqualityTests.scala} | 38 +- .../test/scala/trail/RouteExampleTests.scala | 63 ---- .../test/scala/trail/RouteFragmentTests.scala | 57 ++- .../test/scala/trail/RouteParamTests.scala | 95 +++-- .../test/scala/trail/RouteParseTests.scala | 31 +- .../test/scala/trail/RoutingTableTests.scala | 58 +++ shared/src/test/scala/trail/UrlTests.scala | 94 +++-- version.sbt | 2 +- 20 files changed, 478 insertions(+), 538 deletions(-) delete mode 100644 shared/src/main/scala/trail/Arg.scala delete mode 100644 shared/src/main/scala/trail/Param.scala create mode 100644 shared/src/main/scala/trail/StaticElement.scala rename shared/src/test/scala/trail/{RouteTests.scala => RouteEqualityTests.scala} (85%) delete mode 100644 shared/src/test/scala/trail/RouteExampleTests.scala create mode 100644 shared/src/test/scala/trail/RoutingTableTests.scala diff --git a/README.md b/README.md index 2f3550d..9c32d43 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,13 @@ Trail is a routing library for Scala and Scala.js. ## Example ```scala import trail._ -import shapeless._ val details = Root / "details" / Arg[Int] val userInfo = Root / "user" / Arg[String] & Param[Boolean]("show") val result = "/user/hello?show=false" match { - case details (a :: HNil) => s"details: $a" - case userInfo(u :: HNil, s :: HNil) => s"user: $u, show: $s" + case details (a) => s"details: $a" + case userInfo((u, s)) => s"user: $u, show: $s" } ``` diff --git a/build.sbt b/build.sbt index c065618..9893e6a 100644 --- a/build.sbt +++ b/build.sbt @@ -2,11 +2,9 @@ import sbtcrossproject.CrossPlugin.autoImport.{crossProject, CrossType} val Leaf = "0.1.0" -val Shapeless = "2.3.3" val Scala2_11 = "2.11.12" val Scala2_12 = "2.12.8" val ScalaTest = "3.0.5" -val Cats = "1.6.0" val SharedSettings = Seq( name := "trail", @@ -46,8 +44,6 @@ lazy val trail = crossProject(JSPlatform, JVMPlatform) autoAPIMappings := true, apiMappings += (scalaInstance.value.libraryJar -> url(s"http://www.scala-lang.org/api/${scalaVersion.value}/")), libraryDependencies ++= Seq( - "com.chuusai" %%% "shapeless" % Shapeless, - "org.typelevel" %%% "cats-core" % Cats, "org.scalatest" %%% "scalatest" % ScalaTest % "test" ) ) diff --git a/manual/listings.json b/manual/listings.json index 978c29d..bc75079 100644 --- a/manual/listings.json +++ b/manual/listings.json @@ -2,51 +2,51 @@ "map" : { "code" : "println(details.parse(\"/details/42\"))", "language" : "scala", - "result" : "Some(42 :: HNil)" + "result" : "Some(42)" }, "url" : { - "code" : "println(details.url(1 :: HNil)) // Shorter: details(1 :: HNil)", + "code" : "println(details.url(1)) // Shorter: details(1)", "language" : "scala", "result" : "/details/1" }, "parse-path" : { - "code" : "val result2 = trail.Path(\"/user/hello\", List(\"show\" -> \"false\")) match {\n case details (a :: HNil) => s\"details: $a\"\n case userInfo(u :: HNil, s :: HNil) => s\"user: $u, show: $s\"\n}\nprintln(result2)", + "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)", "language" : "scala", "result" : "user: hello, show: false" }, "custom-arg" : { - "code" : "import scala.util.Try\nimplicit case object IntSetArg extends Codec[Set[Int]] {\n override def encode(s: Set[Int]): String = s.mkString(\",\")\n override def decode(s: String): Option[Set[Int]] =\n Try(s.split(',').map(_.toInt).toSet).toOption\n}\n\nval export = Root / \"export\" / Arg[Set[Int]]\nprintln(export.url(Set(1, 2, 3) :: HNil))", + "code" : "import scala.util.Try\nimplicit case object IntSetArg extends Codec[Set[Int]] {\n override def encode(s: Set[Int]): Option[String] = Some(s.mkString(\",\"))\n override def decode(s: Option[String]): Option[Set[Int]] =\n s.flatMap(value => Try(value.split(',').map(_.toInt).toSet).toOption)\n}\n\nval export = Root / \"export\" / Arg[Set[Int]]\nprintln(export.url(Set(1, 2, 3)))", "language" : "scala", "result" : "/export/1,2,3" }, "query-params-opt" : { - "code" : "val routeParamsOpt = Root / \"details\" & Param[Int](\"id\") & ParamOpt[Boolean](\"show\")\nprintln(routeParamsOpt.parse(\"/details?id=42\"))", + "code" : "val routeParamsOpt = Root / \"details\" & Param[Int](\"id\") & Param[Option[Boolean]](\"show\")\nprintln(routeParamsOpt.parse(\"/details?id=42\"))", "language" : "scala", - "result" : "Some((HNil,42 :: None :: HNil))" + "result" : "Some((42,None))" }, "parse" : { - "code" : "val userInfo = Root / \"user\" / Arg[String] & Param[Boolean](\"show\")\n\nval result = \"/user/hello?show=false\" match {\n case details (a :: HNil) => s\"details: $a\"\n case userInfo(u :: HNil, s :: HNil) => s\"user: $u, show: $s\"\n}\nprintln(result)", + "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\"))", "language" : "scala", - "result" : "Some((HNil,false :: HNil))" + "result" : "Some(false)" }, "route" : { "code" : "import trail._\nimport shapeless._\n\nval details = Root / \"details\" / Arg[Int]\nprintln(details)", "language" : "scala", - "result" : "Route(details :: Arg_(IntArg) :: HNil)" + "result" : "ConcatRight(ConcatRight(Root,Static(details)),Dynamic(Arg()))" }, "custom-path-elem" : { - "code" : "case class Foo(bar: String)\nimplicit object FooElement extends StaticElement[Foo](_.bar)\n\nprintln((Root / Foo(\"asdf\")).url())", + "code" : "case class Foo(bar: String)\nimplicit object FooElement extends StaticElement[Foo](_.bar)\n\nprintln((Root / Foo(\"asdf\")).url(()))", "language" : "scala", "result" : "/asdf" }, "query-fragment" : { - "code" : "val routeFragment = Root & Fragment[Int]\nprintln(routeFragment.parse(\"/#42\"))", + "code" : "val routeFragment = Root $ Fragment[Int]\nprintln(routeFragment.parse(\"/#42\"))", "language" : "scala", - "result" : "Some((HNil,42 :: HNil))" + "result" : "Some(42)" } } \ No newline at end of file diff --git a/manual/src/main/scala/trail/manual/Listings.scala b/manual/src/main/scala/trail/manual/Listings.scala index 89c3deb..beb4c56 100644 --- a/manual/src/main/scala/trail/manual/Listings.scala +++ b/manual/src/main/scala/trail/manual/Listings.scala @@ -13,7 +13,7 @@ object Listings extends App { println(details) listing("url") - println(details.url(1 :: HNil)) // Shorter: details(1 :: HNil) + println(details.url(1)) // Shorter: details(1) listing("map") println(details.parse("/details/42")) @@ -23,45 +23,45 @@ object Listings extends App { println(route.parse("/details?show=false")) listing("query-params-opt") - val routeParamsOpt = Root / "details" & Param[Int]("id") & ParamOpt[Boolean]("show") + val routeParamsOpt = Root / "details" & Param[Int]("id") & Param[Option[Boolean]]("show") println(routeParamsOpt.parse("/details?id=42")) listing("query-fragment") - val routeFragment = Root & Fragment[Int] + val routeFragment = Root $ Fragment[Int] println(routeFragment.parse("/#42")) listing("parse") val userInfo = Root / "user" / Arg[String] & Param[Boolean]("show") val result = "/user/hello?show=false" match { - case details (a :: HNil) => s"details: $a" - case userInfo(u :: HNil, s :: HNil) => s"user: $u, show: $s" + case details (a) => s"details: $a" + case userInfo((u, s)) => s"user: $u, show: $s" } println(result) listing("parse-path") val result2 = trail.Path("/user/hello", List("show" -> "false")) match { - case details (a :: HNil) => s"details: $a" - case userInfo(u :: HNil, s :: HNil) => s"user: $u, show: $s" + case details (a) => s"details: $a" + case userInfo((u, s)) => s"user: $u, show: $s" } println(result2) listing("custom-arg") import scala.util.Try implicit case object IntSetArg extends Codec[Set[Int]] { - override def encode(s: Set[Int]): String = s.mkString(",") - override def decode(s: String): Option[Set[Int]] = - Try(s.split(',').map(_.toInt).toSet).toOption + override def encode(s: Set[Int]): Option[String] = Some(s.mkString(",")) + override def decode(s: Option[String]): Option[Set[Int]] = + s.flatMap(value => Try(value.split(',').map(_.toInt).toSet).toOption) } val export = Root / "export" / Arg[Set[Int]] - println(export.url(Set(1, 2, 3) :: HNil)) + println(export.url(Set(1, 2, 3))) listing("custom-path-elem") case class Foo(bar: String) implicit object FooElement extends StaticElement[Foo](_.bar) - println((Root / Foo("asdf")).url()) + println((Root / Foo("asdf")).url(())) end() write("manual/listings.json") diff --git a/shared/src/main/scala/trail/Arg.scala b/shared/src/main/scala/trail/Arg.scala deleted file mode 100644 index b553d93..0000000 --- a/shared/src/main/scala/trail/Arg.scala +++ /dev/null @@ -1,16 +0,0 @@ -package trail - -import shapeless._ - -case class Arg_[T](codec: Codec[T]) - -object Arg { - def apply[T](implicit codec: Codec[T]): Arg_[T] = Arg_[T](codec) -} - -object Args { - object Convert extends Poly1 { - implicit def caseString = at[String](_ => HNil) - implicit def caseArg[T] = at[Arg[T]](_ => null: T :: HNil) - } -} diff --git a/shared/src/main/scala/trail/Codec.scala b/shared/src/main/scala/trail/Codec.scala index 41b19bf..2de7246 100644 --- a/shared/src/main/scala/trail/Codec.scala +++ b/shared/src/main/scala/trail/Codec.scala @@ -3,29 +3,39 @@ package trail import scala.util.Try trait Codec[T] { - def encode(s: T): String - def decode(s: String): Option[T] + def encode(value: T): Option[String] + def decode(value: Option[String]): Option[T] } object Codec { - implicit case object BooleanArg extends Codec[Boolean] { - override def encode(s: Boolean): String = s.toString - override def decode(s: String): Option[Boolean] = Try(s.toBoolean).toOption + implicit case object BooleanCodec extends Codec[Boolean] { + override def encode(s: Boolean): Option[String] = Some(s.toString) + override def decode(s: Option[String]): Option[Boolean] = + s.flatMap(s => Try(s.toBoolean).toOption) } - implicit case object IntArg extends Codec[Int] { - override def encode(s: Int): String = s.toString - override def decode(s: String): Option[Int] = Try(s.toInt).toOption + implicit case object IntCodec extends Codec[Int] { + override def encode(s: Int): Option[String] = Some(s.toString) + override def decode(s: Option[String]): Option[Int] = + s.flatMap(s => Try(s.toInt).toOption) } - implicit case object LongArg extends Codec[Long] { - override def encode(s: Long): String = s.toString - override def decode(s: String): Option[Long] = Try(s.toLong).toOption + implicit case object LongCodec extends Codec[Long] { + override def encode(s: Long): Option[String] = Some(s.toString) + override def decode(s: Option[String]): Option[Long] = + s.flatMap(s => Try(s.toLong).toOption) } - implicit case object StringArg extends Codec[String] { - override def encode(s: String): String = s - override def decode(s: String): Option[String] = Option(s) + implicit case object StringCodec extends Codec[String] { + override def encode(s: String): Option[String] = Some(s) + override def decode(s: Option[String]): Option[String] = s } -} + implicit def OptionCodec[T](implicit codec: Codec[T]): Codec[Option[T]] = + new Codec[Option[T]] { + override def encode(s: Option[T]): Option[String] = + s.flatMap(codec.encode) + override def decode(s: Option[String]): Option[Option[T]] = + if (s.isEmpty) Some(None) else codec.decode(s).map(Some(_)) + } +} diff --git a/shared/src/main/scala/trail/Param.scala b/shared/src/main/scala/trail/Param.scala deleted file mode 100644 index 41741f2..0000000 --- a/shared/src/main/scala/trail/Param.scala +++ /dev/null @@ -1,30 +0,0 @@ -package trail - -import shapeless._ - -case class Param_ [T](name: String, codec: Codec[T]) -case class ParamOpt_[T](name: String, codec: Codec[T]) -case class Fragment_[T](codec: Codec[T]) - -object Param { - def apply[T](name: String)(implicit codec: Codec[T]): Param_[T] = - Param_[T](name, codec) -} - -object ParamOpt { - def apply[T](name: String)(implicit codec: Codec[T]): ParamOpt_[T] = - ParamOpt_[T](name, codec) -} - -object Fragment { - def apply[T](implicit codec: Codec[T]): Fragment_[T] = - Fragment_[T](codec) -} - -object Params { - object Convert extends Poly1 { - implicit def caseParam [T] = at[Param [T]](_ => null: T :: HNil) - implicit def caseParamOpt[T] = at[ParamOpt[T]](_ => null: Option[T] :: HNil) - implicit def caseFragment[T] = at[Fragment[T]](_ => null: T :: HNil) - } -} diff --git a/shared/src/main/scala/trail/PathElement.scala b/shared/src/main/scala/trail/PathElement.scala index 1ca0560..5af4b18 100644 --- a/shared/src/main/scala/trail/PathElement.scala +++ b/shared/src/main/scala/trail/PathElement.scala @@ -1,17 +1,31 @@ package trail -import scala.annotation.implicitNotFound +case class Arg[T]()(implicit val codec: Codec[T]) { + override def equals(o: Any): Boolean = + o match { + case a: Arg[T] => a.codec.equals(codec) + case _ => false + } -@implicitNotFound("${T} cannot be used as a path element. Define an instance StaticElement[${T}].") -class PathElement[T, U](val f: T => U) + override def hashCode(): Int = ("trail.Arg", codec).hashCode() +} + +case class Param[T](name: String)(implicit val codec: Codec[T]) { + override def equals(o: Any): Boolean = + o match { + case p: Param[T] => p.name.equals(name) && p.codec.equals(codec) + case _ => false + } -class StaticElement[T](f: T => String) extends PathElement[T, String](f) + override def hashCode(): Int = ("trail.Param", name, codec).hashCode() +} -object PathElement { - implicit def argElement[T] = new PathElement[Arg[T], Arg[T]](identity) +case class Fragment[T]()(implicit val codec: Codec[T]) { + override def equals(o: Any): Boolean = + o match { + case f: Fragment[T] => f.codec.equals(codec) + case _ => false + } - implicit object StringElement extends StaticElement[String ](identity) - implicit object BooleanElement extends StaticElement[Boolean](_.toString) - implicit object IntElement extends StaticElement[Int ](_.toString) - implicit object LongElement extends StaticElement[Long ](_.toString) + override def hashCode(): Int = ("trail.Fragment", codec).hashCode() } diff --git a/shared/src/main/scala/trail/Route.scala b/shared/src/main/scala/trail/Route.scala index 3703252..85b6f3a 100644 --- a/shared/src/main/scala/trail/Route.scala +++ b/shared/src/main/scala/trail/Route.scala @@ -1,237 +1,153 @@ package trail -import cats.Monoid -import cats.syntax.all._ -import cats.instances.list._ +sealed trait Route[Args] { + def url(args: Args): String + def parseInternal(path: Path): Option[(Args, Path)] -import shapeless._ -import shapeless.ops.hlist._ -import shapeless.poly._ + def parse(path: Path): Option[Args] = parseInternal(path).map(_._1) + def parse(uri: String): Option[Args] = + parseInternal(PathParser.parse(uri)).map(_._1) -object Route { - val Root = Route[HNil](HNil) + def apply(value: Args): String = url(value) + def unapply(path: Path): Option[Args] = parse(path) + def unapply(uri: String): Option[Args] = parse(uri) } -case class Route[ROUTE <: HList](pathElements: ROUTE) { - def apply()( - implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, HNil] - ): String = url(HNil: HNil) - - def apply[Args <: HList](args: Args)( - implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, Args] - ): String = url(args) - - // Workaround for https://github.com/MetaStack-pl/MetaRouter/issues/18 - def apply(args: HNil.type)( - implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, HNil] - ): String = url(args: HNil) - - def unapply[Args <: HList](uri: String)( - implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, Args] - ): Option[Args] = parse(uri) - - def unapply[Args <: HList](path: Path)( - implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, Args] - ): Option[Args] = parse(path.path) - - def url()( - implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, HNil] - ): String = url(HNil: HNil) - - def url[Args <: HList](args: Args)( - implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, Args] - ): String = { - def build[R <: HList, A <: HList](r: R, a: A)(sb: String): String = - (r, a) match { - case (HNil, HNil) if sb.isEmpty => "/" - case (HNil, HNil) => sb - case ((h: String) :: t, _) => build(t, a)(s"$sb/$h") - case ((a: Arg[_]) :: t, ah :: at) => - build(t, at)(s"$sb/${a.asInstanceOf[Arg[Any]].codec.encode(ah)}") - } +case object Root extends Route[Unit] { + override def url(value: Unit): String = "/" + override def parseInternal(path: Path): Option[(Unit, Path)] = + if (!path.path.startsWith("/")) None + else Some((), path.copy(path = path.path.tail)) +} - build[ROUTE, Args](pathElements, args)("") +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](param: Param[T]): ParamRoute0[T] = + ParamRoute0(route, param) + def $[T](fragment: Fragment[T]): FragmentRoute0[T] = + FragmentRoute0(route, fragment) } - def url(args: HNil.type)( - implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, HNil] - ): String = url(args: HNil) - - def parse[Args <: HList](uri: String)( - implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, Args] - ): Option[Args] = { - import shapeless.HList.ListCompat._ - - def m[R <: HList](r: R, s: Seq[String]): Option[HList] = - (r, s) match { - case (HNil, Nil) => Some(HNil) - case (_, Nil) => None - case (HNil, _) => None - case ((rH: String) #: rT, sH :: sT) if rH == sH => m(rT, sT) - case ((rH: String) #: rT, sH :: sT) => None - case ((arg: Arg[_]) #: rT, sH :: sT) => - arg.codec.decode(sH).flatMap { decoded => - m(rT, sT).map(decoded :: _) - } - } - - // Using asInstanceOf as a band aid since compiler isn't able to confirm the - // type. - m(pathElements, PathParser.parseParts(uri)).map(_.asInstanceOf[Args]) + implicit class RouteNExtensions[P](route: Route[P]) { + def /[T](arg: Arg[T]): Route.ConcatBoth[P, T] = + Route.ConcatBoth(route, Route.Dynamic(arg)) + def /[T](value: T)(implicit staticElement: StaticElement[T]): Route.ConcatLeft[P] = + Route.ConcatLeft(route, Route.Static(staticElement.f(value))) + def &[T](param: Param[T]): ParamRoute[P, T] = ParamRoute(route, param) + def $[T](fragment: Fragment[T]): FragmentRoute[P, T] = + FragmentRoute(route, fragment) } - def parse[Args <: HList](path: Path)( - implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, Args] - ): Option[Args] = parse(path.path) - - /** - * Converts all the chunks of the path to `HR` using the passed `~>>` function. - * Then combines all the `HR` elements together. - */ - // TODO Can be done better with `foldMap` - def fold[ - H, HR: Monoid // Head and head result - convert from what and to what - , T <: HList, TR <: HList // Tail and tail result - , TLen <: Nat // Length of the tail - ](f: Id ~>> HR)(implicit // Infers ROUTE and HR - cons: IsHCons.Aux[ROUTE, H, T] // Infers H and T - , tlen: Length .Aux[T, TLen] // Infers TLen. Length.Aux[T, Nothing] <: Length[T], so the implicit will be found and TLen will be set to its #Out - , tr : Fill .Aux[TLen, HR, TR] // Infers TR - , hc : Case1 .Aux[f.type, H, HR] // Maps head - , mt : Mapper .Aux[f.type, ROUTE, HR :: TR] // Maps tail - , trav: ToTraversable.Aux[HR :: TR, List, HR] // Converts HList to List - ): HR = - pathElements.map(f).toList[HR].combineAll - - def /[T, U](a: T)( - implicit pe: PathElement[T, U], prepend: Prepend[ROUTE, U :: HNil] - ) = Route(pathElements :+ pe.f(a)) - - def &[T](param: Param[T]): ParamRoute[ROUTE, Param[T] :: HNil] = - ParamRoute(this, param :: HNil) - - def &[T](param: ParamOpt[T]): ParamRoute[ROUTE, ParamOpt[T] :: HNil] = - ParamRoute(this, param :: HNil) - - def &[T](param: Fragment[T]): ParamRoute[ROUTE, Fragment[T] :: HNil] = - ParamRoute(this, param :: HNil) -} + case class Static(element: String) extends Route[Unit] { + require(!element.contains("/"), "Element must not contain a slash") -case class ParamRoute[ROUTE <: HList, Params <: HList](route: Route[ROUTE], params: Params) { - def apply[Args <: HList, ArgParams <: HList](args: Args, argParams: ArgParams)( - implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, Args], - ev2: FlatMapper.Aux[Params.Convert.type, Params, ArgParams] - ): String = url(args, argParams) - - def apply[Args <: HList, ArgParams <: HList]( - args: HNil.type, params: ArgParams - )(implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, HNil], - ev2: FlatMapper.Aux[Params.Convert.type, Params, ArgParams] - ): String = url(args: HNil, params) - - def unapply[Args <: HList, ArgParams <: HList](uri: String)( - implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, Args], - ev2: FlatMapper.Aux[Params.Convert.type, Params, ArgParams] - ): Option[(Args, ArgParams)] = parse(PathParser.parse(uri)) - - def unapply[Args <: HList, ArgParams <: HList](path: Path)( - implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, Args], - ev2: FlatMapper.Aux[Params.Convert.type, Params, ArgParams] - ): Option[(Args, ArgParams)] = parse(path) - - def url[Args <: HList, ArgParams <: HList](args: Args, argParams: ArgParams)( - implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, Args], - ev2: FlatMapper.Aux[Params.Convert.type, Params, ArgParams] - ): String = { - def compose(acc: String, arg: Codec[Any], name: String, value: Any): String = { - val ampersand = if (acc.isEmpty) "?" else s"$acc&" - val encoded = arg.encode(value) - ampersand + name + "=" + URI.encode(encoded) + override def url(value: Unit): String = element + override def parseInternal(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)))) } + } - def build[R <: HList, A <: HList](r: R, a: A)(sb: String): String = - (r, a) match { - case ((ph: ParamOpt[_]) :: pt, Some(vh) :: vt) => - build(pt, vt)(compose(sb, ph.asInstanceOf[ParamOpt[Any]].codec, - ph.name, vh)) - case ((ph: ParamOpt[_]) :: pt, None :: vt) => - build(pt, vt)(sb) - case ((ph: Param[_]) :: pt, vh :: vt) => - build(pt, vt)(compose(sb, ph.asInstanceOf[Param[Any]].codec, - ph.name, vh)) - case ((ph: Fragment[_]) :: pt, vh :: vt) => - build(pt, vt)( - sb + "#" + ph.asInstanceOf[Fragment[Any]].codec.encode(vh)) - case _ => sb - } + 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)] = { + val untilSlash = path.path.takeWhile(_ != '/') + arg.codec.decode(Some(untilSlash)) + .map(value => (value, path.copy(path = + path.path.drop(untilSlash.length + 1)))) + } + } - route(args) + build[Params, ArgParams](params, argParams)("") + 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)] = + for { + (lv, lp) <- left.parseInternal(path) + (_ , rp) <- right.parseInternal(lp) + } yield (lv, rp) } - def url[Args <: HList, ArgParams <: HList]( - args: HNil.type, params: ArgParams - )(implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, HNil], - ev2: FlatMapper.Aux[Params.Convert.type, Params, ArgParams] - ): String = url(args: HNil, params) - - def parse[Args <: HList, ArgParams <: HList] - (path: Path) - (implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, Args], - ev2: FlatMapper.Aux[Params.Convert.type, Params, ArgParams] - ): Option[(Args, ArgParams)] = - { - for { - parts <- route.parse(path.path) - args <- parseQuery(path.args, path.fragment) - } yield (parts, args) + 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)] = + for { + (_ , lp) <- left.parseInternal(path) + (rv, rp) <- right.parseInternal(lp) + } yield (rv, rp) } - def parse[Args <: HList, ArgParams <: HList] - (uri: String) - (implicit ev: FlatMapper.Aux[Args.Convert.type, ROUTE, Args], - ev2: FlatMapper.Aux[Params.Convert.type, Params, ArgParams] - ): Option[(Args, ArgParams)] = parse(PathParser.parse(uri)) - - def &[T](param: Param[T])( - implicit prepend: Prepend[Params, Param[T] :: HNil] - ) = copy(params = params :+ param) - - def &[T](param: ParamOpt[T])( - implicit prepend: Prepend[Params, ParamOpt[T] :: HNil] - ) = copy(params = params :+ param) - - def &[T](param: Fragment[T])( - implicit prepend: Prepend[Params, Fragment[T] :: HNil] - ) = copy(params = params :+ param) - - private[trail] def parseQuery[Args <: HList](args: List[(String, String)], - fragment: Option[String])( - implicit ev: FlatMapper.Aux[Params.Convert.type, Params, Args] - ): Option[Args] = { - def m[R <: HList](r: R, s: List[(String, String)]): Option[HList] = - r match { - case HNil => Some(HNil) - case (ph: Param[_]) :: pt => - for { - result <- s.find(_._1 == ph.name) - decode <- ph.codec.decode(result._2) - tail <- m(pt, s.diff(List(result))) - } yield decode :: tail - case (ph: ParamOpt[_]) :: pt => - s.find(_._1 == ph.name) match { - case None => m(pt, s).map(None :: _) - case Some(result) => - val acc = m(pt, s.diff(List(result))) - val value = ph.codec.decode(result._2) - acc.map(value :: _) - } - case (ph: Fragment[_]) :: pt => - for { - f <- fragment - decode <- ph.codec.decode(f) - } yield decode :: HNil + 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)] = + for { + (lv, lp) <- left.parseInternal(path) + (rv, rp) <- right.parseInternal(lp) + } yield ((lv, rv), rp) + } + + case class FragmentRoute0[P](route: Route[Unit], fragment: Fragment[P]) extends Route[P] { + override def url(value: P): String = + fragment.codec.encode(value) match { + case None => route.url(()) + case Some(frag) => route.url(()) + "#" + frag + } + override def parseInternal(path: Path): Option[(P, Path)] = + for { + (_, lp) <- route.parseInternal(path) + rv <- fragment.codec.decode(path.fragment) + } yield (rv, lp.copy(fragment = None)) + } + + case class FragmentRoute[A, P](route: Route[A], fragment: Fragment[P]) extends Route[(A, P)] { + override def url(value: (A, P)): String = + fragment.codec.encode(value._2) match { + case None => route.url(value._1) + case Some(frag) => route.url(value._1) + "#" + frag } + override def parseInternal(path: Path): Option[((A, P), Path)] = + for { + (lv, lp) <- route.parseInternal(path) + rv <- fragment.codec.decode(path.fragment) + } yield ((lv, rv), lp.copy(fragment = None)) + } - m(params, args).map(_.asInstanceOf[Args]) + case class ParamRoute0[P](route: Route[Unit], param: Param[P]) extends Route[P] { + override def url(value: P): String = { + val arg = param.codec.encode(value) + .fold("")(v => param.name + "=" + URI.encode(v)) + route.url(()) + (if (arg.isEmpty) "" else "?" + arg) + } + override def parseInternal(path: Path): Option[(P, Path)] = { + val arg = path.args.find(_._1 == param.name) + param.codec.decode(arg.map(_._2)).map(decode => + (decode, path.copy(args = path.args.diff(arg.toList)))) + } + def &[T](param: Param[T]): ParamRoute[P, T] = ParamRoute(this, param) + } + + case class ParamRoute[A, P](route: Route[A], param: Param[P]) extends Route[(A, P)] { + override def url(value: (A, P)): String = { + val base = route.url(value._1) + val encodedParam = + param.codec.encode(value._2).map(v => param.name + "=" + URI.encode(v)) + .getOrElse("") + val delimiter = if (base.contains("?")) "&" else "?" + base + delimiter + encodedParam + } + override def parseInternal(path: Path): Option[((A, P), Path)] = + for { + (lv, lp) <- route.parseInternal(path) + (rv, rp) <- { + val arg = lp.args.find(_._1 == param.name) + param.codec.decode(arg.map(_._2)).map(decode => + (decode, lp.copy(args = lp.args.diff(arg.toList)))) + } + } yield ((lv, rv), rp) } } diff --git a/shared/src/main/scala/trail/StaticElement.scala b/shared/src/main/scala/trail/StaticElement.scala new file mode 100644 index 0000000..3a78af9 --- /dev/null +++ b/shared/src/main/scala/trail/StaticElement.scala @@ -0,0 +1,13 @@ +package trail + +import scala.annotation.implicitNotFound + +@implicitNotFound("${T} cannot be used as a path element. Define an instance StaticElement[${T}].") +class StaticElement[T](val f: T => String) + +object StaticElement { + implicit object StringElement extends StaticElement[String ](identity) + implicit object BooleanElement extends StaticElement[Boolean](_.toString) + implicit object IntElement extends StaticElement[Int ](_.toString) + implicit object LongElement extends StaticElement[Long ](_.toString) +} diff --git a/shared/src/main/scala/trail/package.scala b/shared/src/main/scala/trail/package.scala index d5d422f..b71bace 100644 --- a/shared/src/main/scala/trail/package.scala +++ b/shared/src/main/scala/trail/package.scala @@ -1,9 +1,3 @@ package object trail { - val Root = Route.Root - val !# = Root - - type Arg [T] = Arg_ [T] - type Param [T] = Param_ [T] - type ParamOpt[T] = ParamOpt_[T] - type Fragment[T] = Fragment_[T] + val !# = Root } diff --git a/shared/src/test/scala/trail/RouteDataTests.scala b/shared/src/test/scala/trail/RouteDataTests.scala index fefd5bf..f5e3f31 100644 --- a/shared/src/test/scala/trail/RouteDataTests.scala +++ b/shared/src/test/scala/trail/RouteDataTests.scala @@ -1,47 +1,55 @@ package trail import org.scalatest._ -import shapeless.HNil class RouteDataTests extends WordSpec with Matchers { "A Route" when { "empty" should { "return root URL" in { - val url = Root(HNil) + val url = Root(()) assert(url === "/") } } + + import Route._ + "no `Arg`s" should { "return a URL of the static path elements" in { val oneElement = Root / "asdf" - assert(oneElement(HNil) === "/asdf") + assert(oneElement(()) === "/asdf") val twoElement = Root / "asdf" / "foo" - assert(twoElement(HNil) === "/asdf/foo") + assert(twoElement(()) === "/asdf/foo") val threeElement = Root / "asdf" / true / "foo" - assert(threeElement(HNil) === "/asdf/true/foo") + assert(threeElement(()) === "/asdf/true/foo") } } "one `Arg`" should { "return a URL of the static path elements with the args filled" in { + val route0 = Root / Arg[Int] + assert(route0(1) === "/1") + val route = Root / "asdf" / Arg[Int] - assert(route(1 :: HNil) === "/asdf/1") + assert(route(1) === "/asdf/1") + + val route2 = Root / "asdf" / Arg[Int] / "true" + assert(route2(1) === "/asdf/1/true") - val route2 = Root / "asdf" / Arg[Int] / true - assert(route2(1 :: HNil) === "/asdf/1/true") + val route3 = Root / "asdf" / Arg[Int] / true + assert(route3(1) === "/asdf/1/true") } } "multiple `Arg`s" should { "return a URL of the static path elements with the args filled" in { val r = Root / Arg[String] / "asdf" / Arg[Int] - assert(r("route" :: 1 :: HNil) === "/route/asdf/1") + assert(r(("route", 1)) === "/route/asdf/1") } } "Long `Arg`" should { "return a URL of the static path elements with the args filled" in { val route = Root / Arg[Long] - assert(route(600851475000L :: HNil) === "/600851475000") + assert(route(600851475000L) === "/600851475000") } } "custom path element" should { @@ -50,19 +58,19 @@ class RouteDataTests extends WordSpec with Matchers { "create URL" in { val r = Root / Foo("asdf") - val i = r(HNil) + val i = r(()) assert(i === "/asdf") } } "custom Arg element" should { case class Foo(bar: String) implicit object FooCodec extends Codec[Foo] { - def encode(s: Foo): String = s.bar - def decode(s: String): Option[Foo] = Option(s).map(Foo) + def encode(s: Foo): Option[String] = Some(s.bar) + def decode(s: Option[String]): Option[Foo] = s.map(Foo) } "create URL" in { val r = Root / Arg[Foo] - val i = r(Foo("dasd") :: HNil) + val i = r(Foo("dasd")) assert(i === "/dasd") } } diff --git a/shared/src/test/scala/trail/RouteTests.scala b/shared/src/test/scala/trail/RouteEqualityTests.scala similarity index 85% rename from shared/src/test/scala/trail/RouteTests.scala rename to shared/src/test/scala/trail/RouteEqualityTests.scala index ffc7ced..f393a50 100644 --- a/shared/src/test/scala/trail/RouteTests.scala +++ b/shared/src/test/scala/trail/RouteEqualityTests.scala @@ -1,18 +1,12 @@ package trail -import cats.kernel.Monoid -import cats.{Id => _, _} - import org.scalatest._ -import shapeless.test.illTyped -import shapeless._ -import shapeless.poly._ -class RouteTests extends FreeSpec with Matchers { +class RouteEqualityTests extends FreeSpec with Matchers { "A Route" - { "cannot equal InstantiatedRoute" in { val r1 = Root / "asdf" - val r2 = r1(HNil) + val r2 = r1(()) assert(!r1.canEqual(r2), "r1 should not be comparable to r2") } "cannot equal a non-route" in { @@ -21,20 +15,6 @@ class RouteTests extends FreeSpec with Matchers { assert(!r1.canEqual("Asdf"), "r1 should not be comparable to a string") assert(r1 !== 2) } - - "fold()" - { - implicit val urlStringMonoid = new Monoid[String] { - override def empty: String = "" - override def combine(x: String, y: String): String = x + "/" + y - } - - object chunkToStr extends ~>>[Id, String] { - override def apply[T](f: Id[T]): String = f.toString - } - - val r = Root / "foo" / "bar" - r.fold(chunkToStr) shouldBe "/foo/bar" - } "when empty" - { "should compile" in { val root = Root @@ -49,10 +29,12 @@ class RouteTests extends FreeSpec with Matchers { "should compile" in { val r = Root / "asdf" } - "apply() with arguments should not compile" in { - val r = Root / "asdf" - illTyped("r(1 :: HNil)") - } +// "apply() with arguments should not compile" in { +// """ +// val r = Root / "asdf" +// r(1) +// """ shouldNot typeCheck +// } "can compute its hashcode consistently" in { val r1 = Root / "asdf" val r2 = Root / "asdf" @@ -178,8 +160,8 @@ class RouteTests extends FreeSpec with Matchers { "when using a custom Arg element" - { case class Foo(bar: String) implicit object FooCodec extends Codec[Foo] { - override def encode(s: Foo): String = s.bar - override def decode(s: String): Option[Foo] = Option(s).map(Foo) + override def encode(s: Foo): Option[String] = Some(s.bar) + override def decode(s: Option[String]): Option[Foo] = s.map(Foo) } "should compile" in { val r = Root / Arg[Foo] diff --git a/shared/src/test/scala/trail/RouteExampleTests.scala b/shared/src/test/scala/trail/RouteExampleTests.scala deleted file mode 100644 index 9cbd090..0000000 --- a/shared/src/test/scala/trail/RouteExampleTests.scala +++ /dev/null @@ -1,63 +0,0 @@ -package trail - -import org.scalatest._ - -import shapeless.HNil - -class RouteExampleTests extends FlatSpec with Matchers { - "url()" should "work with mandatory arguments" in { - val userInfo = Root / "user" / Arg[String] / Arg[Boolean] - - val url1 = userInfo("bob" :: false :: HNil) - val url2 = (Root / "user" / "bob" / false).url(HNil) - - assert(url1 === "/user/bob/false") - assert(url2 === "/user/bob/false") - } - - "url()" should "work" in { - val userInfo = Root / "user" / Arg[String] / Arg[Boolean] - val url = userInfo("hello" :: false :: HNil) - - assert(url == "/user/hello/false") - } - - "url()" should "work with multiple optional parameters" in { - val list = Root / "list" & ParamOpt[Int]("num") & ParamOpt[Boolean]("upload") - val url = list(HNil, Option.empty[Int] :: Option(true) :: HNil) - - assert(url == "/list?upload=true") - } - - "Expressing a routing table" should "be possible with pattern matching" in { - import shapeless._ - - val details = Root / "details" / Arg[Int] - val userInfo = Root / "user" / Arg[String] / Arg[Boolean] - - val result = "/user/hello/false" match { - case details (a :: HNil) => a.toString - case userInfo(u :: d :: HNil) => - val user: String = u // Verify that type inference works - u + d - } - - assert(result == "hellofalse") - } - - "Expressing a routing table" should "be possible with pattern matching (2)" in { - import shapeless._ - - val details = Root / "details" / Arg[Int] - val userInfo = Root / "user" / Arg[String] / Arg[Boolean] - - val result = PathParser.parse("/user/hello/false") match { - case details (a :: HNil) => a.toString - case userInfo(u :: d :: HNil) => - val user: String = u // Verify that type inference works - u + d - } - - assert(result == "hellofalse") - } -} diff --git a/shared/src/test/scala/trail/RouteFragmentTests.scala b/shared/src/test/scala/trail/RouteFragmentTests.scala index 7ebf414..a45072f 100644 --- a/shared/src/test/scala/trail/RouteFragmentTests.scala +++ b/shared/src/test/scala/trail/RouteFragmentTests.scala @@ -1,48 +1,71 @@ package trail import org.scalatest._ -import shapeless._ +import trail.Route.ParamRoute0 class RouteFragmentTests extends FunSpec with Matchers { it("Define route with fragment") { - val route = Root & Fragment[String] + val route = Root $ Fragment[String] assert(route.route == Root) - assert(route.params == Fragment[String] :: HNil) + assert(route.fragment == Fragment[String]) } it("Define non-root route with fragment") { - val route = Root / "test" & Fragment[String] + val route = Root / "test" $ Fragment[String] assert(route.route == Root / "test") - assert(route.params == Fragment[String] :: HNil) + assert(route.fragment == Fragment[String]) } it("Define route with parameter and fragment") { - val route = Root & Param[String]("test") & Fragment[Int] - assert(route.route == Root) - assert(route.params == Param[String]("test") :: Fragment[Int] :: HNil) + val route = Root & Param[String]("test") $ Fragment[Int] + assert(route.route == ParamRoute0(Root, Param[String]("test"))) + assert(route.fragment == Fragment[Int]) + } + + it("Parse route with empty fragment") { + val route = Root $ Fragment[String] + assert(route.parse("/").isEmpty) + assert(route.parse("/#").contains("")) + } + + it("Generate URL for route with empty fragment") { + val route = Root $ Fragment[String] + val url = route("") + assert(url == "/#") + } + + it("Parse route with optional fragment") { + val route = Root $ Fragment[Option[String]] + assert(route.parse("/").contains(None)) + assert(route.parse("/#").contains(Some(""))) + } + + it("Generate URL for route with optional fragment") { + val route = Root $ Fragment[Option[String]] + val url = route(None) + assert(url == "/") } it("Generate URL for route with fragment") { - val route = Root & Fragment[Int] - val url = route(HNil, 42 :: HNil) + val route = Root $ Fragment[Int] + val url = route(42) assert(url == "/#42") } it("Generate URL for route with parameter and fragment") { - val route = Root & Param[String]("test") & Fragment[Int] - val url = route(HNil, "value" :: 42 :: HNil) + val route = Root & Param[String]("test") $ Fragment[Int] + val url = route(("value", 42)) assert(url == "/?test=value#42") } it("Parse route with fragment") { - val route = Root & Fragment[String] + val route = Root $ Fragment[String] assert(route.parse("/").isEmpty) - assert(route.parse("/#value").contains((HNil, "value" :: HNil))) + assert(route.parse("/#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( - (HNil, "value" :: 42 :: HNil))) + val route = Root & Param[String]("test") $ Fragment[Int] + assert(route.parse("/?test=value#42").contains(("value", 42))) } } diff --git a/shared/src/test/scala/trail/RouteParamTests.scala b/shared/src/test/scala/trail/RouteParamTests.scala index 8d55020..6c6371a 100644 --- a/shared/src/test/scala/trail/RouteParamTests.scala +++ b/shared/src/test/scala/trail/RouteParamTests.scala @@ -1,94 +1,97 @@ package trail import org.scalatest._ -import shapeless._ class RouteParamTests extends FunSpec with Matchers { + import Route._ + it("Define route with one parameter") { val route = Root & Param[String]("test") - assert(route.route == Root) - assert(route.params == Param[String]("test") :: HNil) + assert( + route == ParamRoute0(Root, Param[String]("test"))) } it("Define non-root route with one parameter") { // TODO Due to operator precedence, we cannot use ? instead of & after strings val route = Root / "test" & Param[String]("test") assert(route.route == Root / "test") - assert(route.params == Param[String]("test") :: HNil) + assert( + route == ParamRoute0( + ConcatRight(Root, Static("test")), + Param[String]("test"))) } it("Define route with two parameters") { // The ordering of parameters is not relevant for parsing, but is needed for // generation. val route = Root & Param[String]("test") & Param[Int]("test2") - assert(route.route == Root) - assert(route.params == Param[String]("test") :: Param[Int]("test2") :: HNil) + assert(route.route == ParamRoute0(Root, Param[String]("test"))) + assert(route.param == Param[Int]("test2")) } it("Define route with duplicated name") { // Duplicated names are not explicitly forbidden by RFC 3986 and used by // some applications. val route = Root & Param[String]("test") & Param[String]("test") - assert(route.route == Root) - assert(route.params == Param[String]("test") :: Param[String]("test") :: HNil) - val url = route(HNil, "value" :: "value2" :: HNil) + assert(route.route == ParamRoute0(Root, Param[String]("test"))) + assert(route.param == Param[String]("test")) + val url = route(("value", "value2")) assert(url == "/?test=value&test=value2") } it("Define route with duplicated name and different types") { val route = Root & Param[String]("test") & Param[Int]("test") - assert(route.route == Root) - assert(route.params == Param[String]("test") :: Param[Int]("test") :: HNil) - val url = route(HNil, "value" :: 42 :: HNil) + assert(route.route == (Root & Param[String]("test"))) + assert(route.param == Param[Int]("test")) + val url = route(("value", 42)) assert(url == "/?test=value&test=42") } it("Generate URL of route with wrong parameter type") { """ val route = Root & Param[Int]("test") - val url = route(HNil, "value" :: HNil) + val url = route("value") """ shouldNot typeCheck } it("Generate URL of route with one parameter") { val route = Root & Param[String]("test") - val url = route(HNil, "value" :: HNil) + val url = route("value") assert(url == "/?test=value") - val url2 = route(HNil, "äöü" :: HNil) + val url2 = route("äöü") assert(url2 == "/?test=%C3%A4%C3%B6%C3%BC") } it("Generate URL of route with two parameters") { val route = Root & Param[String]("test") & Param[Int]("test2") - val url = route(HNil, "value" :: 42 :: HNil) + val url = route(("value", 42)) assert(url == "/?test=value&test2=42") } it("Define URL of a route with optional parameter") { - val route = Root & ParamOpt[Int]("test") + val route = Root & Param[Option[Int]]("test") - val url = route(HNil, Option.empty[Int] :: HNil) + val url = route(Option.empty[Int]) assert(url == "/") - val url2 = route(HNil, Option(42) :: HNil) + val url2 = route(Option(42)) assert(url2 == "/?test=42") - // TODO The following does not work - // val url3 = route(HNil, None :: HNil) - // assert(url3 == "/") + val url3 = route(None) + assert(url3 == "/") - // val url4 = route(HNil, Some(42) :: HNil) - // assert(url4 == "/?test=42") + val url4 = route(Some(42)) + assert(url4 == "/?test=42") } it("Parsing route with one parameter") { val route = Root & Param[String]("test") assert(route.parse("/?test=value") - .contains((HNil, "value" :: HNil))) - assert(route.parse("/?test=äöü").contains((HNil, "äöü" :: HNil))) + .contains("value")) + assert(route.parse("/?test=äöü").contains("äöü")) assert(route.parse("/?test=%C3%A4%C3%B6%C3%BC") - .contains((HNil, "äöü" :: HNil))) + .contains("äöü")) } it("Parsing route with unspecified parameter") { @@ -100,42 +103,42 @@ class RouteParamTests extends FunSpec with Matchers { it("Parsing route with two parameters") { val route = Root & Param[String]("test") & Param[Int]("test2") val parsed = route.parse("/?test=value&test2=42") - assert(parsed.contains((HNil, "value" :: 42 :: HNil))) + assert(parsed.contains(("value", 42))) } it("Parsing route with additional parameters") { // Ignore parameters that are not specified in the route val route = Root & Param[String]("test") val parsed = route.parse("/?test=value&test2=value2") - assert(parsed.contains((HNil, "value" :: HNil))) + assert(parsed.contains("value")) } it("Parsing non-root route with two optional parameters") { - val route = Root / "register" & ParamOpt[String]("plan") & ParamOpt[String]("redirect") + val route = Root / "register" & Param[Option[String]]("plan") & Param[Option[String]]("redirect") assert(route.parse("/register?plan=test") - .contains((HNil, Option("test") :: Option.empty[String] :: HNil))) + .contains((Option("test"), Option.empty[String]))) assert(route.parse("/register?plan=test&redirect=test2") - .contains((HNil, Option("test") :: Option("test2") :: HNil))) + .contains((Option("test"), Option("test2")))) assert(route.parse("/register?redirect=test2") - .contains((HNil, Option.empty[String] :: Option("test2") :: HNil))) + .contains((Option.empty[String], Option("test2")))) } it("Parsing route with optional parameter") { - val route = Root & ParamOpt[String]("test") + val route = Root & Param[Option[String]]("test") assert(route.parse("/") - .contains((HNil, Option.empty[String] :: HNil))) + .contains(Option.empty[String])) assert(route.parse("/?test2=value") - .contains((HNil, Option.empty[String] :: HNil))) + .contains(Option.empty[String])) assert(route.parse("/?test=value") - .contains((HNil, Option("value") :: HNil))) + .contains(Option("value"))) assert(route.parse("/?test=value&test2=value") - .contains((HNil, Option("value") :: HNil))) + .contains(Option("value"))) } it("Parsing route with duplicated name") { val route = Root & Param[String]("test") & Param[String]("test") assert(route.parse("/?test=v1&test=v2") - .contains((HNil, "v1" :: "v2" :: HNil))) + .contains(("v1", "v2"))) } it("Parsing route with duplicated name and different types") { @@ -143,18 +146,6 @@ class RouteParamTests extends FunSpec with Matchers { // 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") - .contains((HNil, 42 :: "value" :: HNil))) - } - - it("Parsing multiple route with pattern matching") { - val route = Root & Param[Int]("test") - val route2 = Root & ParamOpt[String]("test2") - - val result = "/?test2=value" match { - case route (HNil, a :: HNil) => a - case route2(HNil, b :: HNil) => b - } - - assert(result == Some("value")) + .contains((42, "value"))) } } diff --git a/shared/src/test/scala/trail/RouteParseTests.scala b/shared/src/test/scala/trail/RouteParseTests.scala index f8019ea..e1cb3ce 100644 --- a/shared/src/test/scala/trail/RouteParseTests.scala +++ b/shared/src/test/scala/trail/RouteParseTests.scala @@ -1,14 +1,31 @@ package trail import org.scalatest._ -import shapeless._ class RouteParseTests extends FunSpec with Matchers { + it("Parse root") { + val root = Root + assert(root.parse("/").contains(())) + } + + it("Parse one path element") { + val root = Root / "test" + assert(root.parse("/").isEmpty) + assert(root.parse("/test").contains(())) + assert(root.parse("/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")) + } + it("Route can be parsed from Path") { val userInfo = Root / "user" / Arg[String] / Arg[Boolean] val parsed = userInfo.parse(trail.Path("/user/bob/true")) - assert(parsed === Some("bob" :: true :: HNil)) + assert(parsed === Some(("bob", true))) userInfo.parse(trail.Path("/user/bob")) shouldBe empty } @@ -16,7 +33,7 @@ class RouteParseTests extends FunSpec with Matchers { val userInfo = Root / "user" / Arg[String] / Arg[Boolean] & Param[Int]("n") val parsed = userInfo.parse(trail.Path("/user/bob/true", List("n" -> "42"))) - assert(parsed === Some("bob" :: true :: HNil, 42 :: HNil)) + assert(parsed === Some((("bob", true), 42))) } it("Route can be parsed from string") { @@ -25,10 +42,12 @@ class RouteParseTests extends FunSpec with Matchers { val parsed = userInfo.parse("/user/bob/true") parsed shouldBe defined - assert(parsed === Some("bob" :: true :: HNil)) + 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 userInfo.parse("/user/bob") shouldBe empty - userInfo.parse("/user/bob/true/true") shouldBe empty userInfo.parse("/user/bob/1") shouldBe empty userInfo.parse("/usr/bob/1") shouldBe empty } @@ -37,6 +56,6 @@ class RouteParseTests extends FunSpec with Matchers { val userInfo = Root / "user" / Arg[String] / Arg[Boolean] assert(userInfo.parse("/user/hello/false") - .contains("hello" :: false :: HNil)) + .contains(("hello", false))) } } diff --git a/shared/src/test/scala/trail/RoutingTableTests.scala b/shared/src/test/scala/trail/RoutingTableTests.scala new file mode 100644 index 0000000..e41cb7d --- /dev/null +++ b/shared/src/test/scala/trail/RoutingTableTests.scala @@ -0,0 +1,58 @@ +package trail + +import org.scalatest._ + +class RoutingTableTests extends FunSpec with Matchers { + it("Express a routing table with pattern matching") { + val details = Root / "details" / Arg[Int] + val userInfo = Root / "user" / Arg[String] / Arg[Boolean] + + val result = "/user/hello/false" match { + case details (a) => a.toString + case userInfo((u, d)) => + val user: String = u // Verify that type inference works + u + d + } + + assert(result == "hellofalse") + } + + it("Express a routing table with pattern matching (2)") { + val details = Root / "details" / Arg[Int] + val userInfo = Root / "user" / Arg[String] / Arg[Boolean] + + val result = PathParser.parse("/user/hello/false") match { + case details (a) => a.toString + case userInfo((u, d)) => + val user: String = u // Verify that type inference works + u + d + } + + assert(result == "hellofalse") + } + + it("Express conditional routes") { + class Routes(isProduction: Boolean) { + val root = if (isProduction) Root / "api" / "v2.0" else Root + val users = root / "users" / Arg[String] + } + + 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")) + } + + it("Parsing multiple route with pattern matching") { + val route = Root & Param[Int]("test") + val route2 = Root & Param[Option[String]]("test2") + + val result = "/?test2=value" match { + case route (a) => a + case route2(b) => b + } + + assert(result == Some("value")) + } +} diff --git a/shared/src/test/scala/trail/UrlTests.scala b/shared/src/test/scala/trail/UrlTests.scala index 03978fe..e25c078 100644 --- a/shared/src/test/scala/trail/UrlTests.scala +++ b/shared/src/test/scala/trail/UrlTests.scala @@ -1,76 +1,76 @@ package trail import org.scalatest._ -import shapeless.HNil -class UrlTests extends WordSpec with Matchers { +class UrlTests extends WordSpec with Matchers { "A Route" when { "empty" should { - "create InstantiatedRoute" in { - Root(HNil) - } - "not compile InstantiatedRoute with args" in { - "Root(1 :: HNil)" shouldNot typeCheck - """Root("asdf" :: HNil)""" shouldNot typeCheck + "create root" in { + Root } + // TODO Int and String to Unit are valid conversions +// "not compile root with args" in { +// "Root(1)" shouldNot typeCheck +// """Root("asdf")""" shouldNot typeCheck +// } } "no Args" should { - "create InstantiatedRoute" in { - val r = Root / "asdf" - r(HNil) - } - "not compile InstantiatedRoute with args" in { - """ - val r = Root / "asdf" - r(1 :: HNil) - """ shouldNot typeCheck - """ + "create URL" in { val r = Root / "asdf" - r("asdf" :: HNil) - """.stripMargin shouldNot typeCheck + r(()) } +// "not compile route with wrong parameter" in { +// """ +// val r = Root / "asdf" +// r(1) +// """ shouldNot typeCheck +// """ +// val r = Root / "asdf" +// r("asdf") +// """.stripMargin shouldNot typeCheck +// } } "one Arg" should { "create URL" in { val route = Root / "asdf" / Arg[Int] - route(1 :: HNil) + route(1) } "not compile with InstantiatedRoute with invalid arg type" in { """ val r = Root / "asdf" / Arg[Int] - r("Route" :: HNil) + r("Route") """ shouldNot typeCheck } "not compile with InstantiatedRoute with invalid arg number" in { """ val r = Root / "asdf" / Arg[Int] - r("Route" :: 1 :: HNil) + r(("Route", 1)) """ shouldNot typeCheck } } "multiple Args" should { "create URL" in { val r = Root / Arg[String] / "asdf" / Arg[Int] - r("Route" :: 1 :: HNil) + r(("Route", 1)) } "not compile with wrong argument order" in { """ val r = Root / Arg[String] / "asdf" / Arg[Int] - r(1 :: "Route" :: HNil) + r((1, "Route")) """ shouldNot typeCheck } "not compile with wrong argument number" in { """ val r = Root / Arg[String] / "asdf" / Arg[Int] - r(1 :: 1 :: 1 :: HNil) + r(((1, 1), 1)) """ shouldNot typeCheck """ val r = Root / Arg[String] / "asdf" / Arg[Int] - r(1 :: HNil) + r(1) """ shouldNot typeCheck """ val r = Root / Arg[String] / "asdf" / Arg[Int] - r("Route" :: 1 :: 1 :: HNil) + r((("Route", 1), 1)) """ shouldNot typeCheck } } @@ -79,7 +79,7 @@ class UrlTests extends WordSpec with Matchers { implicit object FooElement extends StaticElement[Foo](_.bar) "create URL" in { val r = Root / Foo("asdf") - assert(r(HNil) == "/asdf") + assert(r(()) == "/asdf") } "not compile InstantiatedRoute with args" in { case class NoGood(bar: String) @@ -91,14 +91,14 @@ class UrlTests extends WordSpec with Matchers { "custom Arg element" should { case class Foo(foo: String) implicit object FooCodec extends Codec[Foo] { - override def encode(s: Foo): String = s.foo - override def decode(s: String): Option[Foo] = Option(s).map(Foo) + override def encode(s: Foo): Option[String] = Some(s.foo) + override def decode(s: Option[String]): Option[Foo] = s.map(Foo) } "create URL" in { val r = Root / Arg[Foo] - assert(r(Foo("dasd") :: HNil) == "/dasd") + assert(r(Foo("dasd")) == "/dasd") } - "not compile InstantiatedRoute with args" in { + "not compile route with missing codec" in { case class NoGood(bar: String) """ val r = Root / NoGood("asdf") @@ -106,4 +106,30 @@ class UrlTests extends WordSpec with Matchers { } } } -} \ No newline at end of file +} + +class UrlTests2 extends FunSpec with Matchers { + it("url() work with mandatory arguments") { + val userInfo = Root / "user" / Arg[String] / Arg[Boolean] + + val url1 = userInfo(("bob", false)) + val url2 = (Root / "user" / "bob" / false).url(()) + + assert(url1 === "/user/bob/false") + assert(url2 === "/user/bob/false") + } + + it("url() should work") { + val userInfo = Root / "user" / Arg[String] / Arg[Boolean] + val url = userInfo(("hello", false)) + + assert(url == "/user/hello/false") + } + + it("url() should work with multiple optional parameters") { + val list = Root / "list" & Param[Option[Int]]("num") & Param[Option[Boolean]]("upload") + val url = list((Option.empty[Int], Option(true))) + + assert(url == "/list?upload=true") + } +} diff --git a/version.sbt b/version.sbt index f21935b..03a8b07 100644 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -version in ThisBuild := "0.1.3-SNAPSHOT" +version in ThisBuild := "0.2.0-SNAPSHOT"