app/controllers/GenericDatabaseObjectController.scala (177 lines of code) (raw):
package controllers
import java.util.UUID
import akka.actor.ActorRef
import play.api.libs.json._
import play.api.mvc._
import scala.concurrent.Future
import scala.util.{Failure, Success, Try}
import scala.concurrent.ExecutionContext.Implicits.global
import auth.Security
import exceptions.{AlreadyExistsException, BadDataException}
import models.{PlutoCommission, PlutoModel, PlutoWorkingGroup, ProjectEntry}
import services.ChangeOperation
import services.RabbitMqPropagator.ChangeEvent
/**
* Simplified form of [[GenericDatabaseObjectControllerWithFilter]] which does not support filtering. This provides
* stub implementations of @selectFiltered and @validateFilterParams which raise RuntimeExceptions when called.
* @tparam M - type of the case class which represents the objects that ultimately get returned by Slick
*/
trait GenericDatabaseObjectController[M<:PlutoModel] extends GenericDatabaseObjectControllerWithFilter[M,Nothing]
{
def selectFiltered(startAt: Int, limit:Int, terms:Nothing) = Future(Failure(new RuntimeException("Not implemented")))
def validateFilterParams(request:Request[JsValue]) = throw new RuntimeException("Not implemented")
}
/**
* This trait provides the JSON view code for all of the CRUD endpoints for database objects. You need to specify two
* type parameters, but if you don't need to support filtering it's better to extend [[GenericDatabaseObjectController]]
* which is a subset of this trait.
* To use it, extend this trait in your Controller and implement the indicated methods. Then tie the routes config
* to the indicated methods provided by this trait et voilà
* @tparam M - type of the case class which represents the objects that ultimately get returned by Slick
* @tparam F - type of the case class which represents supported search filter terms (the provided json is marshalled to this)
*/
trait GenericDatabaseObjectControllerWithFilter[M<:PlutoModel,F] extends BaseController with Security {
/**
* Implement this method in your subclass to validate that the incoming record (passed in request) does indeed match
* your case class.
* Normally this can be done by simply returning: request.body.validate[YourCaseClass]. apparently this can't be done in the trait
* because a concrete serializer implementation must be available at compile time, which would be for [YourCaseClass] but not for [M]
* @param request Play request object
* @return JsResult representing a validation success or failure.
*/
def validate(request:Request[JsValue]):JsResult[M]
/**
* Implement this method in your subclass to validate that the incoming record (passed in request) does indeed match
* your filter parameters case class
* @param request Play request object
* @return JsResult representing validation success or failure
*/
def validateFilterParams(request:Request[JsValue]):JsResult[F]
/**
* Implement this method in your subclass to return a Future of all matching records
* @param startAt start database retrieval at this record
* @param limit limit number of returned items to this
* @return Future of Try of Sequence of record type [[M]]
*/
def selectall(startAt:Int, limit:Int):Future[Try[(Int, Seq[M])]]
/**
* Implement this method in your subclass to return a Future of all records that match the given filter terms.
* Errors should be returned as a Failure of the provided Try
* @param startAt start database retrieval at this record
* @param limit limit number of returned items to this
* @param terms case class of type [[F]] representing the filter terms
* @return Future of Try of Sequence of record type [[M]]
*/
def selectFiltered(startAt:Int, limit:Int, terms:F):Future[Try[(Int, Seq[M])]]
def selectid(requestedId: Int):Future[Try[Seq[M]]]
/**
* optionally implement this method in your subclass to get a notification when something requests the object.
* this is for tying into auditing
* @param requestedId the id of the thing that was requested
* @param username name of the user doing the requesting
* @param request full Play! Request object, so you can access headers etc.
* @tparam T type of the Request payload, you can safely ignore this
*/
def notifyRequested[T](requestedId:Int, username:String, request:Request[T]) = {}
def deleteid(requestedId: Int):Future[Try[Int]]
def insert(entry: M,uid:String):Future[Try[Int]]
def dbupdate(itemId: Int, entry:M):Future[Try[Int]]
def jstranslate(result:Seq[M]):Json.JsValueWrapper
def jstranslate(result:M):Json.JsValueWrapper
/**
* Generic error handler that will return a 409 Conflict on a database conflict error or a 500 Internal Server Error
* otherwise
* @param error throwable representing the error
* @param thing string describing what is being created or deleted, for error message output
* @param isInsert boolean - true if the failed operation is an insert or create, or false if the failed operation is a delete
* @return Play response indicating the relevant error code
*/
def handleConflictErrors(error:Throwable, thing: String, isInsert:Boolean) = {
val verb = if(isInsert) "create" else "delete"
logger.error(s"Could not $verb $thing:", error)
val errorString = error.toString
if (errorString.contains("violates foreign key constraint") ||
errorString.contains("Referential integrity constraint violation") ||
errorString.contains("violates unique constraint")) {
val errmsg = if (isInsert)
s"This $thing either already exists or refers to objects which do not exist"
else
s"This $thing is still referred to by sub-objects"
Conflict(Json.obj("status" -> "error", "detail" -> errmsg))
} else
InternalServerError(Json.obj("status" -> "error", "detail" -> error.toString))
}
/**
* calls the callback block if the given error is a conflict error and return what it returns, or return an internalservererror
* if the given error is not a conflict error
* @param error
* @param callback
* @return
*/
def handleConflictErrorsAdvanced(error:Throwable)(callback: =>Result):Result = {
val errorString = error.toString
if (errorString.contains("violates foreign key constraint") || errorString.contains("Referential integrity constraint violation")) {
callback
} else
InternalServerError(Json.obj("status" -> "error", "detail" -> error.toString))
}
/**
* Endpoint implementation for list, requiring auth. Link this up in route config as a GET.
* Internally calls your [[selectall()]] implementation.
* @param startAt query parameter representing number of record to start at
* @param limit query parameter representing maximum number of records to return
* @return Future of a Play Response object containing json data
*/
def list(startAt:Int, limit: Int) = IsAuthenticatedAsync {uid=>{request=>
selectall(startAt, limit).map({
case Success((count, result))=>Ok(Json.obj("status"->"ok","count"->count, "result"->this.jstranslate(result)))
case Failure(error)=>
logger.error(error.toString)
InternalServerError(Json.obj("status"->"error","detail"->error.toString))
})
}}
/**
* Endpoint implementation for listFiltered, i.e. return all records matching given filter terms
* Filter terms are provided as a JSON request body, so link this up as a POST or PUT.
* @param startAt query parameter representing number of record to start at
* @param limit query parameter representing maximum number of records to return
* @return Future of a Play Response object containing json data
*/
def listFiltered(startAt:Int, limit:Int) = IsAuthenticatedAsync(parse.json) {uid=>{request=>
this.validateFilterParams(request).fold(
errors => {
logger.error(s"errors parsing content: $errors")
Future(BadRequest(Json.obj("status"->"error","detail"->JsError.toJson(errors))))
},
filterTerms => {
this.selectFiltered(startAt, limit, filterTerms).map({
case Success((count,result))=>Ok(Json.obj("status" -> "ok","count"->count,"result"->this.jstranslate(result)))
case Failure(error)=>
logger.error(error.toString)
InternalServerError(Json.obj("status"->"error", "detail"->error.toString))
}
)
}
)
}}
/**
* Override this method in the subclass to prevent certain entries from being created
* @param newEntry - entry that the client wants to create
* @return Either[String,Boolean] indicating whether to proceed or not. If Right then the operation is carried out, if Left then the string
* is used as the error responde detail
*/
def shouldCreateEntry(newEntry:M):Either[String,Boolean] = Right(true)
/**
* perform the actual create operation. this is because some classes of objects you must be an admin to create,
* but others need to be creatable by any user
* @param uid user id
* @param request full request object
* @return an async play response
*/
private def internalCreate(uid:String, request:Request[JsValue]) = {
this.validate(request).fold(
errors => {
logger.error(s"errors parsing content: $errors")
Future(BadRequest(Json.obj("status"->"error","detail"->JsError.toJson(errors))))
},
newEntry => {
shouldCreateEntry(newEntry) match {
case Right(_)=>
this.insert(newEntry, uid).map({
case Success(result) => Ok(Json.obj("status" -> "ok", "detail" -> "added", "id" -> result.asInstanceOf[Int]))
case Failure(error) =>
logger.error(error.toString)
error match {
case e: BadDataException =>
Conflict(Json.obj("status" -> "error", "detail" -> e.toString))
case e: AlreadyExistsException =>
Conflict(Json.obj("status" -> "error", "detail" -> e.toString, "nextAvailableVersion"->e.getNextAvailableVersion))
case _ =>
handleConflictErrors(error, "object", isInsert = true)
}
})
case Left(errDetail)=>
Future(BadRequest(Json.obj("status"->"error", "detail"->errDetail)))
}
}
)
}
def create = IsAdminAsync(parse.json) {uid=>{request =>
internalCreate(uid, request)
}}
def createByAnyone = IsAuthenticatedAsync(parse.json) {uid=> request=>
internalCreate(uid, request)
}
def getitem(requestedId: Int) = IsAuthenticatedAsync {uid=>{request=>
selectid(requestedId).map({
case Success(result)=>
if(result.isEmpty)
NotFound("")
else {
notifyRequested(requestedId, uid, request)
Ok(Json.obj("status"->"ok","result"->this.jstranslate(result.head)))
}
case Failure(error)=>
logger.error(error.toString)
InternalServerError(Json.obj("status"->"error","detail"->error.toString))
})
}}
private def internalUpdate(id:Int, request: Request[JsValue]) =
this.validate(request).fold(
errors=>Future(BadRequest(Json.obj("status"->"error","detail"->JsError.toJson(errors)))),
validRecord=>
this.dbupdate(id,validRecord) map {
case Success(rowsUpdated)=>Ok(Json.obj("status"->"ok","detail"->"Record updated", "id"->id))
case Failure(error)=>InternalServerError(Json.obj("status"->"error", "detail"->error.toString))
}
)
def update(id: Int) = IsAdminAsync(parse.json) { uid=>{request =>
internalUpdate(id, request)
}}
def updateByAnyone(id: Int) = IsAuthenticatedAsync(parse.json) { uid=> request=>
internalUpdate(id, request)
}
def deleteAction(requestedId: Int) = {
deleteid(requestedId).map({
case Success(result)=>
if(result==0)
NotFound(Json.obj("status" -> "notfound", "id"->requestedId))
else
Ok(Json.obj("status" -> "ok", "detail" -> "deleted", "id" -> requestedId))
case Failure(error)=>handleConflictErrors(error,"object",isInsert=false)
})
}
def delete(requestedId: Int) = IsAdminAsync {uid=>{ request =>
if(requestedId<0)
Future(Conflict(Json.obj("status"->"error","detail"->"This is still referenced by sub-objects")))
else
deleteAction(requestedId)
}}
protected def getItemType(m:PlutoModel) = m match {
case _:PlutoWorkingGroup=>Some("workinggroup")
case _:PlutoCommission=>Some("commission")
case _:ProjectEntry=>Some("project")
case _=>None
}
def sendToRabbitMq(operation: ChangeOperation, tryId: Try[Int], rabbitMqPropagator: ActorRef): Future[Try[Int]] = {
tryId.map(id => sendToRabbitMq(operation, id, rabbitMqPropagator))
Future(tryId)
}
def sendToRabbitMq(operation: ChangeOperation, id: Int, rabbitMqPropagator: ActorRef): Future[Unit] = {
logger.debug(s"sendToRabbitMq looking up id $id")
selectid(id).map({
case Success(modelList) => rabbitMqPropagator ! ChangeEvent(modelList.map(jstranslate), modelList.headOption.flatMap(getItemType), operation)
case _ => logger.error("Failed to propagate changes")
})
}
def sendToRabbitMq(operation: ChangeOperation, model: M, rabbitMqPropagator: ActorRef): Unit =
rabbitMqPropagator ! ChangeEvent(Seq(jstranslate(model)),getItemType(model), operation)
}