Last active
December 1, 2023 03:22
-
-
Save dashared/474dc77beb67e00ed9da82ec653a6b05 to your computer and use it in GitHub Desktop.
Custom action which works with file upload, PlayFramework scala, GraphQL
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
mutation CreateProject($projectForm: ProjectForm!, $file: Upload) { | |
createProject(projectForm: $projectForm, file: $file) { | |
projectId | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** 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) | |
} | |
) | |
) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** 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)) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]] | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
CURL to check if it works: