Skip to content

Instantly share code, notes, and snippets.

@dashared
Last active December 1, 2023 03:22
Show Gist options
  • Save dashared/474dc77beb67e00ed9da82ec653a6b05 to your computer and use it in GitHub Desktop.
Save dashared/474dc77beb67e00ed9da82ec653a6b05 to your computer and use it in GitHub Desktop.
Custom action which works with file upload, PlayFramework scala, GraphQL
package controllers.graphql
import com.google.inject.{Inject, Singleton}
import keycloak.{GraphQLAction, Upload, VerifiedToken}
import models.graphql.GraphQL
import models.graphql.error.{AuthenticationException, ContentException, NotFoundByIDException, PermissionException}
import models.graphql.schemas.context.SangriaContext
import play.api.libs.json.{JsObject, JsString, Json}
import play.api.mvc.{AbstractController, Action, AnyContent, ControllerComponents, Request, Result}
import sangria.marshalling.playJson._
import sangria.execution._
import sangria.parser.QueryParser
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success}
@Singleton
class Controller @Inject()(cc: ControllerComponents,
graphQl: GraphQL,
graphQLAction: GraphQLAction,
implicit val executionContext: ExecutionContext) extends AbstractController(cc) {
val errorHandler = ExceptionHandler {
case (_, AuthenticationException(message)) => HandledException(message)
case (_, PermissionException(message)) => HandledException(message)
case (_, NotFoundByIDException(message)) => HandledException(message)
case (_, ContentException(message)) => HandledException(message)
}
/**
* Renders an page with an in-browser IDE for exploring GraphQL.
*/
def graphql: Action[AnyContent] = Action(Ok(views.html.graphiql()))
/**
* Parses graphql body of incoming request.
*
* @return an 'Action' to handles a request and generates a result to be sent to the client
*/
def graphqlBody: Action[AnyContent] = graphQLAction.async(parse.anyContent) { implicit request ⇒
val variables = request.gql.variables.flatMap {
case JsString(vars) ⇒ Some(parseVariables(vars))
case obj: JsObject ⇒ Some(obj)
case _ ⇒ None
}
executeQuery(request.gql.query, variables, request.gql.operationName, request.upload, request.maybeUser)
}
/**
* Analyzes and executes incoming graphql query, and returns execution result.
*
* @param query graphql body of request
* @param variables incoming variables passed in the request
* @param operationName name of the operation (queries or mutations)
* @return simple result, which defines the response header and a body ready to send to the client
*/
def executeQuery(query: String,
variables: Option[JsObject] = None,
operationName: Option[String] = None,
upload: Upload,
maybeUser: Option[VerifiedToken] = None): Future[Result] = QueryParser.parse(query) match {
case Success(queryAst) =>
Executor.execute(
schema = graphQl.Schema,
queryAst = queryAst,
userContext = SangriaContext(upload, maybeUser),
variables = variables.getOrElse(Json.obj()),
operationName = operationName,
exceptionHandler = errorHandler
).map(Ok(_))
.recover {
case error: QueryAnalysisError => BadRequest(error.resolveError)
case error: ErrorWithResolver => InternalServerError(error.resolveError)
}
case Failure(ex) => Future(BadRequest(s"${ex.getMessage}"))
}
/**
* Parses variables of incoming query.
*
* @param variables variables from incoming query
* @return JsObject with variables
*/
def parseVariables(variables: String): JsObject = if (variables.trim.isEmpty || variables.trim == "null") Json.obj()
else Json.parse(variables).as[JsObject]
}
mutation CreateProject($projectForm: ProjectForm!, $file: Upload) {
createProject(projectForm: $projectForm, file: $file) {
projectId
}
}
/** Just my simple schema to show you how it all works */
class ProjectSchema @Inject()(projectsResolver: ProjectService)(implicit val executionContext: ExecutionContext) {
val Mutations: List[Field[SangriaContext, Any]] = List(
Field(
name = "createProject",
fieldType = DeployedProjectType,
arguments = List(ProjectFormArg, SingleUploadArg),
resolve = sangriaContext => sangriaContext.ctx.secured { user =>
val form = sangriaContext.arg(ProjectFormArg)
// Instead of val upload = sangriaContext.arg(SingleUploadArg) I do:
val maybeEggFile = sangriaContext.ctx.maybeUpload.file.map(file => Files.readAllBytes(file.ref))
projectsResolver.createAndDeploy(ProjectForm.fromGql(form), user, maybeEggFile)
}
)
)
}
/** your imports here */
package object graphql {
/** Custom scalar type for Upload. Won't be using it much, but it needs to be here */
implicit val UploadType: ScalarType[Upload] = ScalarType[Upload](
"Upload",
coerceOutput = (_, _) => "",
coerceInput = {
case NullValue(_,_) => Right(Upload())
case _ => Left(UploadViolation)
},
coerceUserInput = {
case s: String => Right(Upload())
case _ => Left(UploadViolation)
}
)
/** will be using this arg in schemas */
val SingleUploadArg: Argument[Option[Upload]] = Argument("file", OptionInputType(UploadType))
}
package keycloak
import com.google.inject.Inject
import play.api.libs.Files
import play.api.libs.json.{JsObject, JsValue, Json, OFormat}
import play.api.mvc.Results.BadRequest
import play.api.mvc.{ActionBuilder, AnyContent, AnyContentAsJson, AnyContentAsMultipartFormData, BodyParser, BodyParsers, MultipartFormData, Request, Result, WrappedRequest}
import org.keycloak.adapters.KeycloakDeploymentBuilder
import scala.concurrent.{ExecutionContext, Future}
case class Upload(file: Option[MultipartFormData.FilePart[Files.TemporaryFile]] = None)
object GraphQLData {
implicit val format: OFormat[GraphQLData] = Json.format[GraphQLData]
}
case class GraphQLData(query: String,
variables: Option[JsValue] = None,
operationName: Option[String] = None)
case class GraphQLRequest[B](gql: GraphQLData,
upload: Upload = Upload(),
maybeUser: Option[VerifiedToken] = None,
request: Request[B]) extends WrappedRequest(request)
class GraphQLAction @Inject()(val parsers: BodyParsers.Default,
implicit val executionContext: ExecutionContext) extends ActionBuilder[GraphQLRequest, AnyContent] {
override def invokeBlock[A](request: Request[A], block: (GraphQLRequest[A]) => Future[Result]): Future[Result] = {
/** For authentication purposes */
val oauth = new OAuth2Authorization(
new KeycloakTokenVerifier(KeycloakDeploymentBuilder.build(
getClass.getResourceAsStream("/keycloak.json"))
), request
)
/** Parse query/variables/operation */
def parseGqlData(json: JsValue): Either[String, GraphQLData] = {
json.asOpt[GraphQLData] match {
case Some(value) => Right(value)
case None => Left("Couldn't parse json as graphql data obj")
}
}
/** Single upload */
def parseFiles(operations: String, map: String, mfd: MultipartFormData[Files.TemporaryFile]): Either[String, GraphQLRequest[A]] = {
for {
gqlData <- parseGqlData(Json.parse(operations))
mappedFile <- Json.parse(map).asOpt[Map[String, Seq[String]]].fold[Either[String, (String, Seq[String])]](Left("Couldn't parse as Map"))(c => c.headOption.fold[Either[String, (String, Seq[String])]](Left("Map was empty"))(Right(_)))
} yield (GraphQLRequest(gql = gqlData, upload = Upload(mfd.file(mappedFile._1)), request = request))
}
val maybeRequest: Either[String, GraphQLRequest[A]] = request.body match {
case AnyContentAsJson(json) => parseGqlData(json).flatMap(gqlData => Right(GraphQLRequest(gql = gqlData, request = request)))
case AnyContentAsMultipartFormData(mfd) => (mfd.dataParts("operations").headOption, mfd.dataParts("map").headOption) match {
case (Some(operations), Some(map)) => parseFiles(operations, map, mfd)
case (_, _) => Left("No operations or map were provided")
}
case _ => Left("content-type didn't match with MFD and Json")
}
oauth.authorizeToken().flatMap { eitherUser =>
val mbUser = eitherUser.toOption
maybeRequest.fold[Future[Result]]({ c => Future(BadRequest(c))}, { d => block(GraphQLRequest(d.gql, d.upload, mbUser, d.request)) })
}
}
override def parser: BodyParser[AnyContent] = parsers.asInstanceOf[BodyParser[AnyContent]]
}
package models.graphql.schemas.context
import keycloak.{Upload, VerifiedToken}
import models.graphql.error.{AuthenticationException, ContentException}
import play.api.libs.Files
import play.api.mvc.MultipartFormData
import scala.concurrent.{ExecutionContext, Future}
/** Context with play.api.mvc.Request implicit to use it to authenticate */
case class SangriaContext(maybeUpload: Upload,
maybeUser: Option[VerifiedToken])(implicit ex: ExecutionContext) {
/** Retrieve token or throw exception */
def secured[T](fn: VerifiedToken => Future[T]): Future[T] = {
maybeUser.fold(throw AuthenticationException("No auth header provided")) { user =>
fn(user)
}
}
/** Check if file was provided */
def singleUpload[T](fn: MultipartFormData.FilePart[Files.TemporaryFile] => Future[T]): Future[T] = {
maybeUpload.file.fold(throw ContentException("no file was provided")) { upload =>
fn(upload)
}
}
}
@dashared
Copy link
Author

CURL to check if it works:

curl localhost:9000/graphql \
  -F operations='{ "query": "mutation CreateProject($projectForm: ProjectForm!, $file: Upload) { createProject(projectForm: $projectForm, file: $file) { projectId } }", "variables": { "file": null, "projectForm": {"name": "Project"} } }' \
  -F map='{ "0": ["variables.file"] }' \
  -F 0=@egg1.egg

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment