summaryrefslogtreecommitdiff
path: root/cask/src/cask/router
diff options
context:
space:
mode:
Diffstat (limited to 'cask/src/cask/router')
-rw-r--r--cask/src/cask/router/Decorators.scala136
-rw-r--r--cask/src/cask/router/EntryPoint.scala51
-rw-r--r--cask/src/cask/router/Macros.scala178
-rw-r--r--cask/src/cask/router/Misc.scala23
-rw-r--r--cask/src/cask/router/Result.scala66
-rw-r--r--cask/src/cask/router/RoutesEndpointMetadata.scala68
-rw-r--r--cask/src/cask/router/Runtime.scala39
7 files changed, 561 insertions, 0 deletions
diff --git a/cask/src/cask/router/Decorators.scala b/cask/src/cask/router/Decorators.scala
new file mode 100644
index 0000000..cd2c1a3
--- /dev/null
+++ b/cask/src/cask/router/Decorators.scala
@@ -0,0 +1,136 @@
+package cask.router
+
+import cask.internal.Conversion
+import cask.model.{Request, Response}
+import cask.router.{ArgReader, EntryPoint, Result}
+
+/**
+ * A [[Decorator]] allows you to annotate a function to wrap it, via
+ * `wrapFunction`. You can use this to perform additional validation before or
+ * after the function runs, provide an additional parameter list of params,
+ * open/commit/rollback database transactions before/after the function runs,
+ * or even retrying the wrapped function if it fails.
+ *
+ * Calls to the wrapped function are done on the `delegate` parameter passed
+ * to `wrapFunction`, which takes a `Map` representing any additional argument
+ * lists (if any).
+ */
+trait Decorator[InnerReturned, Input]{
+ final type InputTypeAlias = Input
+ type InputParser[T] <: ArgReader[Input, T, Request]
+ final type Delegate = Map[String, Input] => Result[InnerReturned]
+ type OuterReturned <: Result[Any]
+ def wrapFunction(ctx: Request, delegate: Delegate): OuterReturned
+ def getParamParser[T](implicit p: InputParser[T]) = p
+}
+object Decorator{
+ /**
+ * A stack of [[Decorator]]s is invoked recursively: each decorator's `wrapFunction`
+ * is invoked around the invocation of all inner decorators, with the inner-most
+ * decorator finally invoking the route's [[EntryPoint.invoke]] function.
+ *
+ * Each decorator (and the final `Endpoint`) contributes a dictionary of name-value
+ * bindings, which are eventually all passed to [[EntryPoint.invoke]]. Each decorator's
+ * dictionary corresponds to a different argument list on [[EntryPoint.invoke]]. The
+ * bindings passed from the router are aggregated with those from the `EndPoint` and
+ * used as the first argument list.
+ */
+ def invoke[T](ctx: Request,
+ endpoint: Endpoint[_, _],
+ entryPoint: EntryPoint[T, _],
+ routes: T,
+ routeBindings: Map[String, String],
+ remainingDecorators: List[RawDecorator],
+ bindings: List[Map[String, Any]]): Result[Any] = try {
+ remainingDecorators match {
+ case head :: rest =>
+ head.wrapFunction(
+ ctx,
+ args => invoke(ctx, endpoint, entryPoint, routes, routeBindings, rest, args :: bindings)
+ .asInstanceOf[Result[cask.model.Response.Raw]]
+ )
+
+ case Nil =>
+ endpoint.wrapFunction(ctx, { (endpointBindings: Map[String, Any]) =>
+ val mergedEndpointBindings = endpointBindings ++ routeBindings.mapValues(endpoint.wrapPathSegment)
+ val finalBindings = mergedEndpointBindings :: bindings
+
+ entryPoint
+ .asInstanceOf[EntryPoint[T, cask.model.Request]]
+ .invoke(routes, ctx, finalBindings)
+ .asInstanceOf[Result[Nothing]]
+ })
+ }
+ // Make sure we wrap any exceptions that bubble up from decorator
+ // bodies, so outer decorators do not need to worry about their
+ // delegate throwing on them
+ }catch{case e: Throwable => Result.Error.Exception(e) }
+}
+
+/**
+ * A [[RawDecorator]] is a decorator that operates on the raw request and
+ * response stream, before and after the primary [[Endpoint]] does it's job.
+ */
+trait RawDecorator extends Decorator[Response.Raw, Any]{
+ type OuterReturned = Result[Response.Raw]
+ type InputParser[T] = NoOpParser[Any, T]
+}
+
+
+/**
+ * An [[HttpEndpoint]] that may return something else than a HTTP response, e.g.
+ * a websocket endpoint which may instead return a websocket event handler
+ */
+trait Endpoint[InnerReturned, Input] extends Decorator[InnerReturned, Input]{
+ /**
+ * What is the path that this particular endpoint matches?
+ */
+ val path: String
+ /**
+ * Which HTTP methods does this endpoint support? POST? GET? PUT? Or some
+ * combination of those?
+ */
+ val methods: Seq[String]
+
+ /**
+ * Whether or not this endpoint allows matching on sub-paths: does
+ * `@endpoint("/foo")` capture the path "/foo/bar/baz"? Useful to e.g. have
+ * an endpoint match URLs with paths in a filesystem (real or virtual) to
+ * serve files
+ */
+ def subpath: Boolean = false
+
+ def convertToResultType[T](t: T)
+ (implicit f: Conversion[T, InnerReturned]): InnerReturned = {
+ f.f(t)
+ }
+
+ /**
+ * [[HttpEndpoint]]s are unique among decorators in that they alone can bind
+ * path segments to parameters, e.g. binding `/hello/:world` to `(world: Int)`.
+ * In order to do so, we need to box up the path segment strings into an
+ * [[Input]] so they can later be parsed by [[getParamParser]] into an
+ * instance of the appropriate type.
+ */
+ def wrapPathSegment(s: String): Input
+
+}
+
+/**
+ * Annotates a Cask endpoint that returns a HTTP [[Response]]; similar to a
+ * [[RawDecorator]] but with additional metadata and capabilities.
+ */
+trait HttpEndpoint[InnerReturned, Input] extends Endpoint[InnerReturned, Input] {
+ type OuterReturned = Result[Response.Raw]
+}
+
+
+class NoOpParser[Input, T] extends ArgReader[Input, T, Request] {
+ def arity = 1
+
+ def read(ctx: Request, label: String, input: Input) = input.asInstanceOf[T]
+}
+object NoOpParser{
+ implicit def instance[Input, T] = new NoOpParser[Input, T]
+ implicit def instanceAny[T] = new NoOpParser[Any, T]
+} \ No newline at end of file
diff --git a/cask/src/cask/router/EntryPoint.scala b/cask/src/cask/router/EntryPoint.scala
new file mode 100644
index 0000000..6fe44fc
--- /dev/null
+++ b/cask/src/cask/router/EntryPoint.scala
@@ -0,0 +1,51 @@
+package cask.router
+
+
+import scala.collection.mutable
+
+
+/**
+ * What is known about a single endpoint for our routes. It has a [[name]],
+ * [[argSignatures]] for each argument, and a macro-generated [[invoke0]]
+ * that performs all the necessary argument parsing and de-serialization.
+ *
+ * Realistically, you will probably spend most of your time calling [[invoke]]
+ * instead, which provides a nicer API to call it that mimmicks the API of
+ * calling a Scala method.
+ */
+case class EntryPoint[T, C](name: String,
+ argSignatures: Seq[Seq[ArgSig[_, T, _, C]]],
+ doc: Option[String],
+ invoke0: (T, C, Seq[Map[String, Any]], Seq[Seq[ArgSig[Any, _, _, C]]]) => Result[Any]){
+
+ val firstArgs = argSignatures.head
+ .map(x => x.name -> x)
+ .toMap[String, ArgSig[_, T, _, C]]
+
+ def invoke(target: T,
+ ctx: C,
+ paramLists: Seq[Map[String, Any]]): Result[Any] = {
+
+ val missing = mutable.Buffer.empty[ArgSig[_, T, _, C]]
+
+ val unknown = paramLists.head.keys.filter(!firstArgs.contains(_))
+
+ for(k <- firstArgs.keys) {
+ if (!paramLists.head.contains(k)) {
+ val as = firstArgs(k)
+ if (as.reads.arity != 0 && as.default.isEmpty) missing.append(as)
+ }
+ }
+
+ if (missing.nonEmpty || unknown.nonEmpty) Result.Error.MismatchedArguments(missing.toSeq, unknown.toSeq)
+ else {
+ try invoke0(
+ target,
+ ctx,
+ paramLists,
+ argSignatures.asInstanceOf[Seq[Seq[ArgSig[Any, _, _, C]]]]
+ )
+ catch{case e: Throwable => Result.Error.Exception(e)}
+ }
+ }
+}
diff --git a/cask/src/cask/router/Macros.scala b/cask/src/cask/router/Macros.scala
new file mode 100644
index 0000000..de27e5c
--- /dev/null
+++ b/cask/src/cask/router/Macros.scala
@@ -0,0 +1,178 @@
+package cask.router
+
+import scala.reflect.macros.blackbox
+
+
+class Macros[C <: blackbox.Context](val c: C) {
+ import c.universe._
+ def getValsOrMeths(curCls: Type): Iterable[MethodSymbol] = {
+ def isAMemberOfAnyRef(member: Symbol) = {
+ // AnyRef is an alias symbol, we go to the real "owner" of these methods
+ val anyRefSym = c.mirror.universe.definitions.ObjectClass
+ member.owner == anyRefSym
+ }
+ val extractableMembers = for {
+ member <- curCls.members.toList.reverse
+ if !isAMemberOfAnyRef(member)
+ if !member.isSynthetic
+ if member.isPublic
+ if member.isTerm
+ memTerm = member.asTerm
+ if memTerm.isMethod
+ if !memTerm.isModule
+ } yield memTerm.asMethod
+
+ extractableMembers flatMap { case memTerm =>
+ if (memTerm.isSetter || memTerm.isConstructor || memTerm.isGetter) Nil
+ else Seq(memTerm)
+
+ }
+ }
+
+
+
+ def unwrapVarargType(arg: Symbol) = {
+ val vararg = arg.typeSignature.typeSymbol == definitions.RepeatedParamClass
+ val unwrappedType =
+ if (!vararg) arg.typeSignature
+ else arg.typeSignature.asInstanceOf[TypeRef].args(0)
+
+ (vararg, unwrappedType)
+ }
+
+ def extractMethod(method: MethodSymbol,
+ curCls: c.universe.Type,
+ convertToResultType: c.Tree,
+ ctx: c.Tree,
+ argReaders: Seq[c.Tree],
+ annotDeserializeTypes: Seq[c.Tree]): c.universe.Tree = {
+ val baseArgSym = TermName(c.freshName())
+
+ def getDocAnnotation(annotations: List[Annotation]) = {
+ val (docTrees, remaining) = annotations.partition(_.tpe =:= typeOf[doc])
+ val docValues = for {
+ doc <- docTrees
+ if doc.scalaArgs.head.isInstanceOf[Literal]
+ l = doc.scalaArgs.head.asInstanceOf[Literal]
+ if l.value.value.isInstanceOf[String]
+ } yield l.value.value.asInstanceOf[String]
+ (remaining, docValues.headOption)
+ }
+ val (_, methodDoc) = getDocAnnotation(method.annotations)
+ val argValuesSymbol = q"${c.fresh[TermName]("argValues")}"
+ val argSigsSymbol = q"${c.fresh[TermName]("argSigs")}"
+ val ctxSymbol = q"${c.fresh[TermName]("ctx")}"
+ val argData = for(argListIndex <- method.paramLists.indices) yield{
+ val annotDeserializeType = annotDeserializeTypes.lift(argListIndex).getOrElse(tq"scala.Any")
+ val argReader = argReaders.lift(argListIndex).getOrElse(q"cask.router.NoOpParser.instanceAny")
+ val flattenedArgLists = method.paramss(argListIndex)
+ def hasDefault(i: Int) = {
+ val defaultName = s"${method.name}$$default$$${i + 1}"
+ if (curCls.members.exists(_.name.toString == defaultName)) Some(defaultName)
+ else None
+ }
+
+ val defaults = for (i <- flattenedArgLists.indices) yield {
+ val arg = TermName(c.freshName())
+ hasDefault(i).map(defaultName => q"($arg: $curCls) => $arg.${newTermName(defaultName)}")
+ }
+
+ val readArgSigs = for (
+ ((arg, defaultOpt), i) <- flattenedArgLists.zip(defaults).zipWithIndex
+ ) yield {
+
+ if (arg.typeSignature.typeSymbol == definitions.RepeatedParamClass) c.abort(method.pos, "Varargs are not supported in cask routes")
+
+ val default = defaultOpt match {
+ case Some(defaultExpr) => q"scala.Some($defaultExpr($baseArgSym))"
+ case None => q"scala.None"
+ }
+
+ val (docUnwrappedType, docOpt) = arg.typeSignature match {
+ case t: AnnotatedType =>
+ import compat._
+ val (remaining, docValue) = getDocAnnotation(t.annotations)
+ if (remaining.isEmpty) (t.underlying, docValue)
+ else (c.universe.AnnotatedType(remaining, t.underlying), docValue)
+
+ case t => (t, None)
+ }
+
+ val docTree = docOpt match {
+ case None => q"scala.None"
+ case Some(s) => q"scala.Some($s)"
+ }
+
+ val argSig =
+ q"""
+ cask.router.ArgSig[$annotDeserializeType, $curCls, $docUnwrappedType, $ctx](
+ ${arg.name.toString},
+ ${docUnwrappedType.toString},
+ $docTree,
+ $defaultOpt
+ )($argReader[$docUnwrappedType])
+ """
+
+ val reader = q"""
+ cask.router.Runtime.makeReadCall(
+ $argValuesSymbol($argListIndex),
+ $ctxSymbol,
+ $default,
+ $argSigsSymbol($argListIndex)($i)
+ )
+ """
+
+ c.internal.setPos(reader, method.pos)
+ (reader, argSig)
+ }
+
+ val (readArgs, argSigs) = readArgSigs.unzip
+ val (argNames, argNameCasts) = flattenedArgLists.map { arg =>
+ val (vararg, unwrappedType) = unwrapVarargType(arg)
+ (
+ pq"${arg.name.toTermName}",
+ if (!vararg) q"${arg.name.toTermName}.asInstanceOf[$unwrappedType]"
+ else q"${arg.name.toTermName}.asInstanceOf[Seq[$unwrappedType]]: _*"
+
+ )
+ }.unzip
+
+ (argNameCasts, argSigs, argNames, readArgs)
+ }
+
+ val argNameCasts = argData.map(_._1)
+ val argSigs = argData.map(_._2)
+ val argNames = argData.map(_._3)
+ val readArgs = argData.map(_._4)
+ var methodCall: c.Tree = q"$baseArgSym.${method.name.toTermName}"
+ for(argNameCast <- argNameCasts) methodCall = q"$methodCall(..$argNameCast)"
+
+ val res = q"""
+ cask.router.EntryPoint[$curCls, $ctx](
+ ${method.name.toString},
+ ${argSigs.toList},
+ ${methodDoc match{
+ case None => q"scala.None"
+ case Some(s) => q"scala.Some($s)"
+ }},
+ (
+ $baseArgSym: $curCls,
+ $ctxSymbol: $ctx,
+ $argValuesSymbol: Seq[Map[String, Any]],
+ $argSigsSymbol: scala.Seq[scala.Seq[cask.router.ArgSig[Any, _, _, $ctx]]]
+ ) =>
+ cask.router.Runtime.validate(Seq(..${readArgs.flatten.toList})).map{
+ case Seq(..${argNames.flatten.toList}) => $convertToResultType($methodCall)
+ }
+ )
+ """
+
+ c.internal.transform(res){(t, a) =>
+ c.internal.setPos(t, method.pos)
+ a.default(t)
+ }
+
+ res
+ }
+
+}
diff --git a/cask/src/cask/router/Misc.scala b/cask/src/cask/router/Misc.scala
new file mode 100644
index 0000000..438ec43
--- /dev/null
+++ b/cask/src/cask/router/Misc.scala
@@ -0,0 +1,23 @@
+package cask.router
+
+import scala.annotation.StaticAnnotation
+
+
+class doc(s: String) extends StaticAnnotation
+
+/**
+ * Models what is known by the router about a single argument: that it has
+ * a [[name]], a human-readable [[typeString]] describing what the type is
+ * (just for logging and reading, not a replacement for a `TypeTag`) and
+ * possible a function that can compute its default value
+ */
+case class ArgSig[I, -T, +V, -C](name: String,
+ typeString: String,
+ doc: Option[String],
+ default: Option[T => V])
+ (implicit val reads: ArgReader[I, V, C])
+
+trait ArgReader[I, +T, -C]{
+ def arity: Int
+ def read(ctx: C, label: String, input: I): T
+}
diff --git a/cask/src/cask/router/Result.scala b/cask/src/cask/router/Result.scala
new file mode 100644
index 0000000..52ef0f8
--- /dev/null
+++ b/cask/src/cask/router/Result.scala
@@ -0,0 +1,66 @@
+package cask.router
+
+
+
+
+/**
+ * Represents what comes out of an attempt to invoke an [[EntryPoint]].
+ * Could succeed with a value, but could fail in many different ways.
+ */
+sealed trait Result[+T]{
+ def map[V](f: T => V): Result[V]
+}
+object Result{
+
+ /**
+ * Invoking the [[EntryPoint]] was totally successful, and returned a
+ * result
+ */
+ case class Success[T](value: T) extends Result[T]{
+ def map[V](f: T => V) = Success(f(value))
+ }
+
+ /**
+ * Invoking the [[EntryPoint]] was not successful
+ */
+ sealed trait Error extends Result[Nothing]{
+ def map[V](f: Nothing => V) = this
+ }
+
+
+ object Error{
+
+
+ /**
+ * Invoking the [[EntryPoint]] failed with an exception while executing
+ * code within it.
+ */
+ case class Exception(t: Throwable) extends Error
+
+ /**
+ * Invoking the [[EntryPoint]] failed because the arguments provided
+ * did not line up with the arguments expected
+ */
+ case class MismatchedArguments(missing: Seq[ArgSig[_, _, _, _]],
+ unknown: Seq[String]) extends Error
+ /**
+ * Invoking the [[EntryPoint]] failed because there were problems
+ * deserializing/parsing individual arguments
+ */
+ case class InvalidArguments(values: Seq[ParamError]) extends Error
+ }
+
+ sealed trait ParamError
+ object ParamError{
+ /**
+ * Something went wrong trying to de-serialize the input parameter;
+ * the thrown exception is stored in [[ex]]
+ */
+ case class Invalid(arg: ArgSig[_, _, _, _], value: String, ex: Throwable) extends ParamError
+ /**
+ * Something went wrong trying to evaluate the default value
+ * for this input parameter
+ */
+ case class DefaultFailed(arg: ArgSig[_, _, _, _], ex: Throwable) extends ParamError
+ }
+} \ No newline at end of file
diff --git a/cask/src/cask/router/RoutesEndpointMetadata.scala b/cask/src/cask/router/RoutesEndpointMetadata.scala
new file mode 100644
index 0000000..7940641
--- /dev/null
+++ b/cask/src/cask/router/RoutesEndpointMetadata.scala
@@ -0,0 +1,68 @@
+package cask.router
+
+import cask.router.EntryPoint
+
+import language.experimental.macros
+import scala.reflect.macros.blackbox
+case class EndpointMetadata[T](decorators: Seq[RawDecorator],
+ endpoint: Endpoint[_, _],
+ entryPoint: EntryPoint[T, _])
+case class RoutesEndpointsMetadata[T](value: EndpointMetadata[T]*)
+object RoutesEndpointsMetadata{
+ implicit def initialize[T]: RoutesEndpointsMetadata[T] = macro initializeImpl[T]
+ implicit def initializeImpl[T: c.WeakTypeTag](c: blackbox.Context): c.Expr[RoutesEndpointsMetadata[T]] = {
+ import c.universe._
+ val router = new cask.router.Macros[c.type](c)
+
+ val routeParts = for{
+ m <- c.weakTypeOf[T].members
+ annotations = m.annotations.filter(_.tree.tpe <:< c.weakTypeOf[Decorator[_, _]])
+ if annotations.nonEmpty
+ } yield {
+ if(!(annotations.last.tree.tpe <:< weakTypeOf[Endpoint[_, _]])) c.abort(
+ annotations.head.tree.pos,
+ s"Last annotation applied to a function must be an instance of Endpoint, " +
+ s"not ${annotations.last.tree.tpe}"
+ )
+ val allEndpoints = annotations.filter(_.tree.tpe <:< weakTypeOf[Endpoint[_, _]])
+ if(allEndpoints.length > 1) c.abort(
+ annotations.last.tree.pos,
+ s"You can only apply one Endpoint annotation to a function, not " +
+ s"${allEndpoints.length} in ${allEndpoints.map(_.tree.tpe).mkString(", ")}"
+ )
+
+ val annotObjects =
+ for(annot <- annotations)
+ yield q"new ${annot.tree.tpe}(..${annot.tree.children.tail})"
+
+ val annotObjectSyms =
+ for(_ <- annotations.indices)
+ yield c.universe.TermName(c.freshName("annotObject"))
+
+ val route = router.extractMethod(
+ m.asInstanceOf[MethodSymbol],
+ weakTypeOf[T],
+ q"${annotObjectSyms.last}.convertToResultType",
+ tq"cask.Request",
+ annotObjectSyms.reverse.map(annotObjectSym => q"$annotObjectSym.getParamParser"),
+ annotObjectSyms.reverse.map(annotObjectSym => tq"$annotObjectSym.InputTypeAlias")
+ )
+
+ val declarations =
+ for((sym, obj) <- annotObjectSyms.zip(annotObjects))
+ yield q"val $sym = $obj"
+
+ val res = q"""{
+ ..$declarations
+ cask.router.EndpointMetadata(
+ Seq(..${annotObjectSyms.dropRight(1)}),
+ ${annotObjectSyms.last},
+ $route
+ )
+ }"""
+ res
+ }
+
+ c.Expr[RoutesEndpointsMetadata[T]](q"""cask.router.RoutesEndpointsMetadata(..$routeParts)""")
+ }
+} \ No newline at end of file
diff --git a/cask/src/cask/router/Runtime.scala b/cask/src/cask/router/Runtime.scala
new file mode 100644
index 0000000..4fbbb48
--- /dev/null
+++ b/cask/src/cask/router/Runtime.scala
@@ -0,0 +1,39 @@
+package cask.router
+
+object Runtime{
+
+ def tryEither[T](t: => T, error: Throwable => Result.ParamError) = {
+ try Right(t)
+ catch{ case e: Throwable => Left(error(e))}
+ }
+
+
+ def validate(args: Seq[Either[Seq[Result.ParamError], Any]]): Result[Seq[Any]] = {
+ val lefts = args.collect{case Left(x) => x}.flatten
+
+ if (lefts.nonEmpty) Result.Error.InvalidArguments(lefts)
+ else {
+ val rights = args.collect{case Right(x) => x}
+ Result.Success(rights)
+ }
+ }
+
+ def makeReadCall[I, C](dict: Map[String, I],
+ ctx: C,
+ default: => Option[Any],
+ arg: ArgSig[I, _, _, C]) = {
+ arg.reads.arity match{
+ case 0 =>
+ tryEither(
+ arg.reads.read(ctx, arg.name, null.asInstanceOf[I]), Result.ParamError.DefaultFailed(arg, _)).left.map(Seq(_))
+ case 1 =>
+ dict.get(arg.name) match{
+ case None =>
+ tryEither(default.get, Result.ParamError.DefaultFailed(arg, _)).left.map(Seq(_))
+
+ case Some(x) =>
+ tryEither(arg.reads.read(ctx, arg.name, x), Result.ParamError.Invalid(arg, x.toString, _)).left.map(Seq(_))
+ }
+ }
+ }
+} \ No newline at end of file