Permalink
Browse files

Updated Path support to utilize PathParts to make up a path along wit…

…h argument extraction support

Updated PathFilter and RestfulHandler to support path argument extraction
  • Loading branch information...
darkfrog26 committed Nov 5, 2018
1 parent b42d4f6 commit d9832f6b3717cdd08fcfb287f7b3a935ef0f090a
@@ -2,32 +2,55 @@ package io.youi.net
import scala.reflect.macros.blackbox
case class Path(parts: List[String]) {
lazy val encoded: String = absolute.parts.map(URL.encode).mkString("/", "/", "")
lazy val decoded: String = absolute.parts.mkString("/", "/", "")
case class Path(parts: List[PathPart]) {
lazy val absolute: Path = {
var entries = Vector.empty[String]
var entries = Vector.empty[PathPart]
parts.foreach {
case ".." => entries = entries.dropRight(1)
case "." => // Ignore
case UpLevelPathPart => entries = entries.dropRight(1)
case SameLevelPathPart => // Ignore
case part => entries = entries :+ part
}
Path(entries.toList)
}
lazy val encoded: String = absolute.parts.map(_.value).map(URL.encode).mkString("/", "/", "")
lazy val decoded: String = absolute.parts.map(_.value).mkString("/", "/", "")
lazy val arguments: List[String] = parts.collect {
case part: ArgumentPathPart => part.name
}
def withArguments(arguments: Map[String, String]): Path = copy(parts = parts.map {
case part: ArgumentPathPart if arguments.contains(part.name) => new LiteralPathPart(arguments(part.name))
case part => part
})
def extractArguments(literal: Path): Map[String, String] = {
assert(parts.length == literal.parts.length, s"Literal path must have the same number of parts as the one being extracted for")
parts.zip(literal.parts).flatMap {
case (p1, p2) => p1 match {
case ap: ArgumentPathPart => Some(ap.name -> p2.value)
case _ => None
}
}.toMap
}
def append(path: String): Path = if (path.startsWith("/")) {
Path.parse(path)
} else {
val left = if (parts.last != "") {
parts.dropRight(1)
} else {
parts
}
val left = parts.dropRight(1)
val right = Path.parse(path, absolutize = false)
Path(left ::: right.parts)
}
override def equals(obj: Any): Boolean = obj match {
case that: Path if this.parts.length == that.parts.length => {
this.parts.zip(that.parts).forall {
case (thisPart, thatPart) => PathPart.equals(thisPart, thatPart)
}
}
case _ => false
}
override def toString: String = encoded
}
@@ -40,7 +63,8 @@ object Path {
} else {
path
}
Path(updated.split('/').toList.map(URL.decode)) match {
val parts = updated.split('/').toList.map(URL.decode).flatMap(PathPart.apply)
Path(parts) match {
case p if absolutize => p.absolute
case p => p
}
@@ -49,6 +73,12 @@ object Path {
def interpolate(c: blackbox.Context)(args: c.Expr[Any]*): c.Expr[Path] = {
import c.universe._
implicit val liftablePathPart: Liftable[PathPart] = new Liftable[PathPart] {
override def apply(value: PathPart): c.universe.Tree = {
q"""io.youi.net.PathPart(${value.value}).getOrElse(throw new RuntimeException("Invalid PathPart value"))"""
}
}
c.prefix.tree match {
case Apply(_, List(Apply(_, rawParts))) => {
val parts = rawParts map { case t @ Literal(Constant(const: String)) => (const, t.pos) }
@@ -62,7 +92,7 @@ object Path {
b.append(raw)
}
}
val path = Path.parse(b.toString(), absolutize = true)
val path = Path.parse(b.toString())
c.Expr[Path](q"Path(List(..${path.parts}))")
}
case _ => c.abort(c.enclosingPosition, "Bad usage of Path interpolation.")
@@ -0,0 +1,47 @@
package io.youi.net
sealed trait PathPart extends Any {
def value: String
}
object PathPart {
private val ArgumentPartRegex = """[:](.+)""".r
def apply(value: String): Option[PathPart] = value match {
case null | "" => None
case ".." => Some(UpLevelPathPart)
case "." => Some(SameLevelPathPart)
case ArgumentPartRegex(name) => Some(new ArgumentPathPart(name))
case s => Some(new LiteralPathPart(s))
}
def equals(p1: PathPart, p2: PathPart): Boolean = if (p1 == p2) {
true
} else {
p1 match {
case _: LiteralPathPart => p2 match {
case _: ArgumentPathPart => true
case _ => false
}
case _: ArgumentPathPart => p2 match {
case _: LiteralPathPart => true
case _ => false
}
case _ => false
}
}
}
object UpLevelPathPart extends PathPart {
override def value: String = ".."
}
object SameLevelPathPart extends PathPart {
override def value: String = "."
}
class LiteralPathPart(val value: String) extends AnyVal with PathPart
class ArgumentPathPart(val name: String) extends AnyVal with PathPart {
override def value: String = s":$name"
}
@@ -0,0 +1,55 @@
package specs
import io.youi.net._
import org.scalatest.{Matchers, WordSpec}
class PathSpec extends WordSpec with Matchers {
"Path" should {
"validate simple" in {
Path.parse("/one/two/three").parts should be(List(
new LiteralPathPart("one"),
new LiteralPathPart("two"),
new LiteralPathPart("three")
))
}
"absolutize properly" in {
Path.parse("/one/two/../three").parts should be(List(
new LiteralPathPart("one"),
new LiteralPathPart("three")
))
}
"detect argument" in {
val path = Path.parse("/one/two/:three")
path.parts should be(List(
new LiteralPathPart("one"),
new LiteralPathPart("two"),
new ArgumentPathPart("three")
))
path.arguments should be(List("three"))
}
"detect argument via Macro" in {
val path = path"/one/two/:three"
path.parts should be(List(
new LiteralPathPart("one"),
new LiteralPathPart("two"),
new ArgumentPathPart("three")
))
path.arguments should be(List("three"))
}
"apply arguments" in {
val p1 = Path.parse("/one/two/:three")
val p2 = p1.withArguments(Map("three" -> "Testing"))
p2.parts should be(List(
new LiteralPathPart("one"),
new LiteralPathPart("two"),
new LiteralPathPart("Testing")
))
}
"extract arguments" in {
val p1 = Path.parse("/one/two/:three")
val p2 = Path.parse("/one/two/Testing")
p1 should be(p2)
p1.extractArguments(p2) should be(Map("three" -> "Testing"))
}
}
}
@@ -5,10 +5,22 @@ import io.youi.net.Path
case class PathFilter(path: Path) extends ConnectionFilter {
override def filter(connection: HttpConnection): Option[HttpConnection] = {
if (path.decoded == connection.request.url.path.decoded) {
if (path == connection.request.url.path) {
val args = path.extractArguments(connection.request.url.path)
if (args.nonEmpty) {
connection.store(PathFilter.Key) = args
}
Some(connection)
} else {
None
}
}
}
object PathFilter {
val Key: String = "pathArguments"
def argumentsFromConnection(connection: HttpConnection): Map[String, String] = {
connection.store.getOrElse[Map[String, String]](Key, Map.empty)
}
}
@@ -9,7 +9,7 @@ object PathPart {
def take(connection: HttpConnection, part: String): Option[HttpConnection] = {
val parts = connection.store.getOrElse(Key, connection.request.url.path.parts)
if (parts.nonEmpty && parts.head == part) {
if (parts.nonEmpty && parts.head.value == part) {
connection.store(Key) = parts.tail
Some(connection)
} else {
@@ -6,7 +6,9 @@ import io.circe.{Decoder, Encoder, Json, Printer}
import io.youi.ValidationError
import io.youi.http.{Content, HttpConnection, HttpRequest, StringContent}
import io.youi.net.{ContentType, URL}
import io.youi.server.dsl.PathFilter
import io.youi.server.handler.HttpHandler
import profig.JsonUtil
import scala.collection.mutable.ListBuffer
import scala.concurrent.Await
@@ -15,7 +17,7 @@ class RestfulHandler[Request, Response](restful: Restful[Request, Response])
(implicit decoder: Decoder[Request], encoder: Encoder[Response]) extends HttpHandler {
override def handle(connection: HttpConnection): Unit = {
// Build JSON
val result: RestfulResponse[Response] = RestfulHandler.jsonFromRequest(connection.request) match {
val result: RestfulResponse[Response] = RestfulHandler.jsonFromConnection(connection) match {
case Left(err) => {
restful.error(List(err), err.status)
}
@@ -84,12 +86,14 @@ object RestfulHandler {
}
}
def jsonFromRequest(request: HttpRequest): Either[ValidationError, Json] = {
def jsonFromConnection(connection: HttpConnection): Either[ValidationError, Json] = {
val request = connection.request
val contentJson = request.content.map(jsonFromContent).getOrElse(Right(Json.obj()))
val urlJson = jsonFromURL(request.url)
val pathJson = JsonUtil.toJson(PathFilter.argumentsFromConnection(connection))
contentJson match {
case Left(err) => Left(err)
case Right(json) => Right(json.deepMerge(urlJson))
case Right(json) => Right(json.deepMerge(urlJson).deepMerge(pathJson))
}
}
@@ -1,10 +1,17 @@
package spec
import io.youi.ValidationError
import io.youi.http._
import io.youi.net.{ContentType, URL}
import io.youi.net._
import io.youi.server.Server
import io.youi.server.dsl._
import io.youi.server.handler.HttpHandler
import io.youi.server.rest.{Restful, RestfulResponse}
import org.scalatest.{Matchers, WordSpec}
import io.circe.generic.auto._
import profig.JsonUtil
import scala.concurrent.Future
class ServerSpec extends WordSpec with Matchers {
Server.config("implementation").store("io.youi.server.test.TestServerImplementation")
@@ -19,6 +26,14 @@ class ServerSpec extends WordSpec with Matchers {
}
})
}
"configure Restful endpoint" in {
server.handler(
filters(
path"/test/reverse" / TestService,
path"/test/reverse/:value" / TestService
)
)
}
"receive OK for test.html" in {
val connection = new HttpConnection(server, HttpRequest(url = URL("http://localhost/test.html")))
server.handle(connection)
@@ -29,8 +44,60 @@ class ServerSpec extends WordSpec with Matchers {
server.handle(connection)
connection.response.status should equal(HttpStatus.NotFound)
}
"reverse a String with the Restful endpoint via POST" in {
val content = Content.string(JsonUtil.toJsonString(TestRequest("Testing")), ContentType.`application/json`)
val connection = new HttpConnection(server, HttpRequest(
method = Method.Post,
url = URL("http://localhost/test/reverse"),
content = Some(content)
))
server.handle(connection)
connection.response.status should equal(HttpStatus.OK)
connection.response.content shouldNot equal(None)
val jsonString = connection.response.content.get.asInstanceOf[StringContent].value
val response = JsonUtil.fromJsonString[TestResponse](jsonString)
response.errors should be(Nil)
response.reversed should be(Some("gnitseT"))
}
"reverse a String with the Restful endpoint via GET" in {
val connection = new HttpConnection(server, HttpRequest(
method = Method.Get,
url = URL("http://localhost/test/reverse?value=Testing")
))
server.handle(connection)
connection.response.status should equal(HttpStatus.OK)
connection.response.content shouldNot equal(None)
val jsonString = connection.response.content.get.asInstanceOf[StringContent].value
val response = JsonUtil.fromJsonString[TestResponse](jsonString)
response.errors should be(Nil)
response.reversed should be(Some("gnitseT"))
}
"reverse a String with the Restful endpoint via GET with path-based arg" in {
val connection = new HttpConnection(server, HttpRequest(
method = Method.Get,
url = URL("http://localhost/test/reverse/Testing")
))
server.handle(connection)
connection.response.status should equal(HttpStatus.OK)
connection.response.content shouldNot equal(None)
val jsonString = connection.response.content.get.asInstanceOf[StringContent].value
val response = JsonUtil.fromJsonString[TestResponse](jsonString)
response.errors should be(Nil)
response.reversed should be(Some("gnitseT"))
}
}
}
case class TestRequest(value: String)
case class TestResponse(reversed: Option[String], errors: List[ValidationError])
object TestService extends Restful[TestRequest, TestResponse] {
override def apply(connection: HttpConnection, request: TestRequest): Future[RestfulResponse[TestResponse]] = {
Future.successful(RestfulResponse(TestResponse(Some(request.value.reverse), Nil), HttpStatus.OK))
}
override def error(errors: List[ValidationError], status: HttpStatus): RestfulResponse[TestResponse] = {
RestfulResponse(TestResponse(None, errors), status)
}
}
}

0 comments on commit d9832f6

Please sign in to comment.