
org.coursera.naptime.router2.NaptimePlayRouter.scala Maven / Gradle / Ivy
/*
* Copyright 2016 Coursera Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.coursera.naptime.router2
import javax.inject.Inject
import javax.inject.Singleton
import com.google.inject.Injector
import com.netflix.governator.annotations.WarmUp
import com.typesafe.scalalogging.StrictLogging
import org.coursera.naptime.schema.HandlerKind
import org.coursera.naptime.schema.ResourceKind
import org.coursera.naptime.schema
import play.api.mvc.Handler
import play.api.mvc.RequestHeader
import play.api.routing
import play.api.routing.Router.Routes
import scala.annotation.tailrec
import scala.collection.immutable
/**
* Handles routing for Naptime resources in an idiomatic fashion for Play projects.
*
* To use this router, include in your routes file something like:
* {{{
* # Include Naptime resources
* -> /api org.coursera.naptime.router2.PlayNaptimeRouter
* }}}
*
* Requests matching the prefix for naptime resources will then be routed appropriately.
*
* @param injector The Injector from which to instantiate all the naptime resources.
* @param routerBuilders The set of naptime macro resource router builders corresponding to the
* collection of resources to serve. These are typically bound via Guice
* multi-bindings.
* @param prefix The prefix path under which the resources should be served (in the example above:
* `/api`).
*/
@Singleton
case class NaptimePlayRouter(
private[naptime] val injector: Injector,
private[naptime] val routerBuilders: immutable.Set[ResourceRouterBuilder],
private[naptime] val prefix: String) extends play.api.routing.Router with StrictLogging {
/**
* Helper function to filter out those resource router builders that don't provide schemas.
*/
private[this] def hasDefinedSchema(routerBuilder: ResourceRouterBuilder): Boolean = {
try {
routerBuilder.schema
true
} catch {
case e: scala.NotImplementedError =>
false
}
}
private[this] lazy val schemaMap = routerBuilders.filter(hasDefinedSchema)
.toList.map { routerBuilder =>
// Scala nests classes sometimes using a `$` instead of a `.`, but the JVM does not. :-)
routerBuilder.resourceClass().getName.replace("$", ".") -> routerBuilder.schema
}.toMap
// TODO(saeta): Check to ensure there are not 2 resources with the same name / version.
private[this] lazy val routers = routerBuilders.map { routerBuilder =>
val resourceClass = routerBuilder.resourceClass()
routerBuilder.build(injector.getInstance(resourceClass))
}.toList
@Inject
def this(injector: Injector, routerBuilders: immutable.Set[ResourceRouterBuilder]) =
this(injector, routerBuilders, "")
/**
* Defer to [[handlerFor]] instead of the other way around for performance reasons.
*
* It is better to have the true implementation in `handlerFor` where we can route a request once
* than to implement the partial function here, and have handlerFor call [[isDefinedAt]] and then
* [[apply]], which would result in request routing and URL parsing occuring twice for a single
* request when it wouldn't need to.
*/
override lazy val routes: Routes = Function.unlift(handlerFor)
override def withPrefix(prefix: String): routing.Router = copy(prefix = prefix)
/**
* Includes the Naptime resources into Play's dev mode not-found handler that lists all routes.
*/
override lazy val documentation: Seq[(String, String, String)] = {
def handlerKindToHttpMethod(kind: HandlerKind): String = {
kind match {
case HandlerKind.GET | HandlerKind.MULTI_GET | HandlerKind.GET_ALL | HandlerKind.FINDER =>
"GET"
case HandlerKind.CREATE | HandlerKind.ACTION =>
"POST"
case HandlerKind.UPSERT =>
"PUT"
case HandlerKind.DELETE =>
"DELETE"
case HandlerKind.PATCH =>
"PATCH"
case HandlerKind.$UNKNOWN =>
kind.toString
}
}
def shouldUsePathWithKey(kind: HandlerKind): Boolean = {
kind match {
case HandlerKind.GET | HandlerKind.UPSERT | HandlerKind.DELETE | HandlerKind.PATCH =>
true
case HandlerKind.CREATE | HandlerKind.MULTI_GET | HandlerKind.GET_ALL | HandlerKind.FINDER |
HandlerKind.ACTION =>
false
}
}
/**
* Computes the URL path to access the resource.
*
* @param resourceSchema The resource to compute.
* @return (PathWithoutKeys, PathWithKeySuffix)
*/
def computeFullPath(resourceSchema: schema.Resource): (String, String) = {
val previous = if (resourceSchema.parentClass.isDefined) {
try {
computeFullPath(schemaMap(resourceSchema.parentClass.get))._2
} catch {
case e: NoSuchElementException =>
logger.error(s"Problem computing schema for resource ${resourceSchema.className}. " +
s"Parent class ${resourceSchema.parentClass} not found in schema map keys: " +
s"${schemaMap.keys}", e)
prefix
}
} else {
prefix
}
val resourceName = if (resourceSchema.parentClass.isDefined) {
s"${resourceSchema.name}"
} else {
s"${resourceSchema.name}.v${resourceSchema.version.getOrElse("???")}"
}
val path = s"$previous/$resourceName"
resourceSchema.kind match {
case ResourceKind.SINGLETON =>
path -> path
case ResourceKind.COLLECTION =>
path -> s"$path/$$id" // TODO: add key type information
}
}
for {
routerBuilder <- routerBuilders.toList
if hasDefinedSchema(routerBuilder)
(path, pathWithKey) = computeFullPath(routerBuilder.schema)
handler <- routerBuilder.schema.handlers.sortBy(h => handlerOrder(h.kind))
} yield {
val method = s"${handlerKindToHttpMethod(handler.kind)} --- ${handler.kind}"
val documentationPath = if (shouldUsePathWithKey(handler.kind)) {
pathWithKey
} else {
if (handler.kind == HandlerKind.FINDER) {
s"$path?q=${handler.name}"
} else if (handler.kind == HandlerKind.ACTION) {
s"$path?action=${handler.name}"
} else {
path
}
}
val queryParamInfo = for {
param <- handler.parameters
} yield {
val default = Option(param.data().get("default"))
default.map { default =>
s"${param.name}: ${param.`type`} = $default"
}.getOrElse {
s"${param.name}: ${param.`type`}"
}
}
val baseMethod = s"[NAPTIME] ${routerBuilder.schema.className}.${handler.name}"
val scalaMethod = if (queryParamInfo.isEmpty) {
baseMethod
} else {
s"$baseMethod${queryParamInfo.mkString("(", ", ", ")")}"
}
(method, documentationPath, scalaMethod)
}
}
private[this] val handlerOrder = Seq[HandlerKind](
HandlerKind.GET,
HandlerKind.MULTI_GET,
HandlerKind.GET_ALL,
HandlerKind.CREATE,
HandlerKind.UPSERT,
HandlerKind.DELETE,
HandlerKind.PATCH,
HandlerKind.FINDER,
HandlerKind.ACTION).zipWithIndex.toMap
/**
* Route the request to one of the naptime resources, invoking the (macro-generated) router.
*
* @param request The request to route.
* @return If this is a naptime request for one of the routers, return the handler, otherwise None
*/
override def handlerFor(request: RequestHeader): Option[Handler] = {
if (request.path.startsWith(prefix)) {
val path = request.path.substring(prefix.length)
/**
* Helper method to find the first resource that matches the request and route appropriately.
*
* @param resourceRouters The registered resource routers.
* @param requestHeader The request to route. (It's a parameter to avoid closure allocation.)
* @return Some(RouteAction) if the request corresponds to a registered resource, else None
*/
@tailrec
def routeRequestHelper(
resourceRouters: immutable.Seq[ResourceRouter],
requestHeader: RequestHeader,
path: String): Option[RouteAction] = {
if (resourceRouters.isEmpty) {
None
} else {
val first = resourceRouters.head
val firstResult = first.routeRequest(path, requestHeader)
if (firstResult.isDefined) {
firstResult
} else {
routeRequestHelper(resourceRouters.tail, requestHeader, path)
}
}
}
routeRequestHelper(routers, request, path)
} else {
None
}
}
/**
* Forces the initialization of the internals of the router.
*
* Note: in order to support Naptime's use without governator, everything must work without
* relying on governator calling this function. If used without governator, everything must be
* initialized upon object construction or on the first request.
*/
@WarmUp
private[this] def warmUp(): Unit = {
routers
schemaMap
documentation
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy