diff options
Diffstat (limited to 'cask/src/cask/router')
-rw-r--r-- | cask/src/cask/router/Decorators.scala | 136 | ||||
-rw-r--r-- | cask/src/cask/router/EntryPoint.scala | 51 | ||||
-rw-r--r-- | cask/src/cask/router/Macros.scala | 178 | ||||
-rw-r--r-- | cask/src/cask/router/Misc.scala | 23 | ||||
-rw-r--r-- | cask/src/cask/router/Result.scala | 66 | ||||
-rw-r--r-- | cask/src/cask/router/RoutesEndpointMetadata.scala | 68 | ||||
-rw-r--r-- | cask/src/cask/router/Runtime.scala | 39 |
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 |