diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e79245 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# macOS +.DS_Store + +# sbt specific +dist/* +target/ +lib_managed/ +src_managed/ +project/boot/ +project/plugins/project/ +project/local-plugins.sbt +.history +.ensime +.ensime_cache/ +.sbt-scripted/ +local.sbt + +# Bloop +.bsp + +# VS Code +.vscode/ + +# Metals +.bloop/ +.metals/ +metals.sbt + +# IDEA +.idea +.idea_modules +/.worksheet/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..49b992a --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +## Miracle-TV backend implementation using Scala, Pekko and Sangria + +### Developing +1. Open sbt +2. ~reStart +3. Change that code +4. GraphQL GUI is located on GET to [http://localhost:8080/graphql](http://localhost:8080/graphql) +5. GraphQL API endpoint is located on POST to [http://localhost:8080/graphql](http://localhost:8080/graphql) diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..1dcd390 --- /dev/null +++ b/build.sbt @@ -0,0 +1,55 @@ +val scala3Version = "3.4.2" + +resolvers += "Akka library repository" at "https://repo.akka.io/maven" +resolvers += "Maven Repo" at "https://repo1.maven.org/maven2/" +val PekkoVersion = "1.1.0-M1" +val PekkoHttpVersion = "1.1.0-M1" +val CirceVersion = "0.14.1" +val SangriaAkkaVersion = "0.0.4" +val PekkoHttpJsonVersion = "2.6.0" + +val PekkoDeps = Seq( + "org.apache.pekko" %% "pekko-actor-typed", + "org.apache.pekko" %% "pekko-stream", +).map(_ % PekkoVersion) + +val PekkoHttp = Seq( + "org.apache.pekko" %% "pekko-http", +).map(_ % PekkoHttpVersion) + +val PekkoHttpJson = Seq( + "com.github.pjfanning" %% "pekko-http-circe", +).map(_ % PekkoHttpJsonVersion) + +val CirceDeps = Seq( + "io.circe" %% "circe-core", + "io.circe" %% "circe-generic", + "io.circe" %% "circe-parser", +).map(_ % CirceVersion) + +val SangriaDeps = Seq( + "org.sangria-graphql" %% "sangria" % "4.1.0", + "org.sangria-graphql" %% "sangria-slowlog" % "3.0.0", + "org.sangria-graphql" %% "sangria-circe" % "1.3.2", +) + +val SangriaAkkaDeps = Seq( + "org.sangria-graphql" %% "sangria-akka-http-core", + "org.sangria-graphql" %% "sangria-akka-http-circe", +).map(_ % SangriaAkkaVersion) + + +lazy val root = project + .in(file(".")) + .settings( + ss = name := "miracle-tv-backend", + version := "0.1.0-SNAPSHOT", + + scalaVersion := scala3Version, + + libraryDependencies ++= Seq( + "ch.qos.logback" % "logback-classic" % "1.5.6", + "io.getquill" %% "quill-jdbc-zio" % "4.8.5", + "org.postgresql" % "postgresql" % "42.3.1" + ) ++ PekkoDeps ++ PekkoHttp ++ CirceDeps ++ SangriaDeps ++ PekkoHttpJson + ) diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..ee4c672 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.10.1 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..295ffc8 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,2 @@ +addSbtPlugin("org.scalariform" % "sbt-scalariform" % "1.8.3") +addSbtPlugin("io.spray" % "sbt-revolver" % "0.10.0") diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf new file mode 100644 index 0000000..eebc54c --- /dev/null +++ b/src/main/resources/application.conf @@ -0,0 +1,7 @@ +myDatabaseConfig { + dataSourceClassName = org.postgresql.ds.PGSimpleDataSource + dataSource.user = postgres + dataSource.portNumber = 5432 + dataSource.password = password + connectionTimeout = 30000 +} diff --git a/src/main/resources/assets/playground.html b/src/main/resources/assets/playground.html new file mode 100644 index 0000000..0cd7efd --- /dev/null +++ b/src/main/resources/assets/playground.html @@ -0,0 +1,495 @@ + + + + + + + + GraphQL Playground + + + + + + + + + + +
+ +
Loading + GraphQL Playground +
+
+ +
+ + + diff --git a/src/main/scala/Main.scala b/src/main/scala/Main.scala new file mode 100644 index 0000000..760f3f6 --- /dev/null +++ b/src/main/scala/Main.scala @@ -0,0 +1,35 @@ +import org.apache.pekko.actor.typed.ActorSystem +import org.apache.pekko.actor.typed.scaladsl.Behaviors +import org.apache.pekko.http.scaladsl.Http +import org.apache.pekko.http.scaladsl.model._ +import org.apache.pekko.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.server.Route.seal +import io.circe.generic.auto._, io.circe.syntax._ +import io.getquill._ +import java.sql.SQLException +import io.getquill.jdbczio.Quill.DataSource + +import tv.miracle.backend.graphql.GraphQL + +import io.circe.Json +import tv.miracle.backend.db.DB + +case class Ping(ping: String) +case class Test(name: String, sexy: Option[Boolean]) + +object Main { + def main(args: Array[String]): Unit = { + implicit val ctx = DB.ctx() + + implicit val system = ActorSystem(Behaviors.empty, "my-system") + + implicit val executionContext = system.executionContext + + val route = + path("graphql") { GraphQL.handleGraphQL() } + + val bindingFuture = Http().newServerAt("localhost", 8080).bind(route) + + println(s"Server now online. Please navigate to http://localhost:8080/hello\n") + } +} diff --git a/src/main/scala/db/DB.scala b/src/main/scala/db/DB.scala new file mode 100644 index 0000000..6f5c40c --- /dev/null +++ b/src/main/scala/db/DB.scala @@ -0,0 +1,11 @@ +package tv.miracle.backend.db + +import io.getquill._ + +abstract class DB { +} +object DB { + def ctx = () => { + PostgresJdbcContext(SnakeCase, "myDatabaseConfig") + } +} diff --git a/src/main/scala/graphql/Circe.scala b/src/main/scala/graphql/Circe.scala new file mode 100644 index 0000000..05b84a8 --- /dev/null +++ b/src/main/scala/graphql/Circe.scala @@ -0,0 +1,78 @@ +package sangria.http.circe + +import org.apache.pekko.http.scaladsl.marshalling._ +import org.apache.pekko.http.scaladsl.model.StatusCodes._ +import org.apache.pekko.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.server.Route +import org.apache.pekko.http.scaladsl.unmarshalling.{ FromEntityUnmarshaller, _ } +import com.github.pjfanning.pekkohttpcirce.FailFastCirceSupport +import io.circe._ +import io.circe.generic.semiauto._ +import sangria.execution.Executor +import sangria.parser.SyntaxError +import sangria.schema.Schema + +import sangria.http.pekko._ + +import scala.concurrent.ExecutionContext +import scala.util.{ Failure, Success } + +trait CirceHttpSupport extends SangriaPekkoHttp[Json] with FailFastCirceSupport { + import SangriaPekkoHttp._ + + implicit val locationEncoder: Encoder[Location] = deriveEncoder[Location] + implicit val graphQLErrorEncoder: Encoder[GraphQLError] = deriveEncoder[GraphQLError] + implicit val graphQLErrorResponseEncoder: Encoder[GraphQLErrorResponse] = + deriveEncoder[GraphQLErrorResponse] + + implicit val graphQLRequestDecoder: Decoder[GraphQLHttpRequest[Json]] = + deriveDecoder[GraphQLHttpRequest[Json]] + + implicit object JsonVariables extends Variables[Json] { + override def empty: Json = Json.obj() + } + + override implicit def errorMarshaller: ToEntityMarshaller[GraphQLErrorResponse] = marshaller + override implicit def requestUnmarshaller: FromEntityUnmarshaller[GraphQLHttpRequest[Json]] = + unmarshaller + + // TODO: This seems... awkward? + import PredefinedFromStringUnmarshallers.{ + _fromStringUnmarshallerFromByteStringUnmarshaller => stringFromByteStringUm + } + override implicit def variablesUnmarshaller: FromStringUnmarshaller[Json] = + stringFromByteStringUm(fromByteStringUnmarshaller[Json]) + + // 🎉 Tada! + def graphql(maybePath: String = "graphql")(schema: Schema[Any, Any])(implicit ec: ExecutionContext): Route = { + import sangria.marshalling.circe._ + + path(maybePath) { + prepareGraphQLRequest { + case Success(req) => + val resp = Executor + .execute( + schema = schema, + queryAst = req.query, + variables = req.variables, + operationName = req.operationName + // TODO: Accept Middleware, Context, and all other execute options? + ) + .map(OK -> _) + complete(resp) + case Failure(e) => + e match { + case err: SyntaxError => + val errResp = GraphQLErrorResponse( + formatError(err) :: Nil) + complete(UnprocessableEntity, errResp) + case err: MalformedRequest => + val errResp = GraphQLErrorResponse( + formatError(err) :: Nil) + complete(UnprocessableEntity, errResp) + } + + } + } + } +} diff --git a/src/main/scala/graphql/CorsSupport.scala b/src/main/scala/graphql/CorsSupport.scala new file mode 100644 index 0000000..14d054d --- /dev/null +++ b/src/main/scala/graphql/CorsSupport.scala @@ -0,0 +1,25 @@ +package tv.miracle.backend.graphql + +import org.apache.pekko.http.scaladsl.model.HttpMethods._ +import org.apache.pekko.http.scaladsl.model.headers._ +import org.apache.pekko.http.scaladsl.model.{ HttpResponse, StatusCodes } +import org.apache.pekko.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.server.{ Directive0, Route } + +trait CorsSupport { + private def addAccessControlHeaders: Directive0 = + respondWithHeaders( + `Access-Control-Allow-Origin`.*, + `Access-Control-Allow-Credentials`(true), + `Access-Control-Allow-Headers`("Authorization", "Content-Type", "X-Requested-With")) + + private def preflightRequestHandler: Route = options { + complete(HttpResponse(StatusCodes.OK) + .withHeaders( + `Access-Control-Allow-Methods`(OPTIONS, POST, GET))) + } + + def corsHandler(r: Route): Route = addAccessControlHeaders { + preflightRequestHandler ~ r + } +} diff --git a/src/main/scala/graphql/Data.scala b/src/main/scala/graphql/Data.scala new file mode 100644 index 0000000..06cd6e7 --- /dev/null +++ b/src/main/scala/graphql/Data.scala @@ -0,0 +1,89 @@ +package tv.miracle.backend.graphql + +object Episode extends Enumeration { + val NEWHOPE, EMPIRE, JEDI = Value +} + +trait Character { + def id: String + def name: Option[String] + def friends: List[String] + def appearsIn: List[Episode.Value] +} + +case class Human( + id: String, + name: Option[String], + friends: List[String], + appearsIn: List[Episode.Value], + homePlanet: Option[String]) extends Character + +case class Droid( + id: String, + name: Option[String], + friends: List[String], + appearsIn: List[Episode.Value], + primaryFunction: Option[String]) extends Character + +class CharacterRepo { + import CharacterRepo._ + + def getHero(episode: Option[Episode.Value]): Character = + episode flatMap (_ => getHuman("1000")) getOrElse droids.last + + def getHuman(id: String): Option[Human] = humans.find(c => c.id == id) + + def getDroid(id: String): Option[Droid] = droids.find(c => c.id == id) + + def getHumans(limit: Int, offset: Int): List[Human] = humans.slice(offset, offset + limit) + + def getDroids(limit: Int, offset: Int): List[Droid] = droids.slice(offset, offset + limit) +} + +object CharacterRepo { + val humans = List( + Human( + id = "1000", + name = Some("Luke Skywalker"), + friends = List("1002", "1003", "2000", "2001"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + homePlanet = Some("Tatooine")), + Human( + id = "1001", + name = Some("Darth Vader"), + friends = List("1004"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + homePlanet = Some("Tatooine")), + Human( + id = "1002", + name = Some("Han Solo"), + friends = List("1000", "1003", "2001"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + homePlanet = None), + Human( + id = "1003", + name = Some("Leia Organa"), + friends = List("1000", "1002", "2000", "2001"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + homePlanet = Some("Alderaan")), + Human( + id = "1004", + name = Some("Wilhuff Tarkin"), + friends = List("1001"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + homePlanet = None)) + + val droids = List( + Droid( + id = "2000", + name = Some("C-3PO"), + friends = List("1000", "1002", "1003", "2001"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + primaryFunction = Some("Protocol")), + Droid( + id = "2001", + name = Some("R2-D2"), + friends = List("1000", "1002", "1003"), + appearsIn = List(Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI), + primaryFunction = Some("Astromech"))) +} diff --git a/src/main/scala/graphql/GraphQL.scala b/src/main/scala/graphql/GraphQL.scala new file mode 100644 index 0000000..2b6b9bc --- /dev/null +++ b/src/main/scala/graphql/GraphQL.scala @@ -0,0 +1,63 @@ +package tv.miracle.backend.graphql + +import scala.util.{ Failure, Success } +import sangria.execution.deferred.DeferredResolver +import sangria.execution.{ ErrorWithResolver, Executor, QueryAnalysisError } +import sangria.slowlog.SlowLog +import org.apache.pekko.actor.ActorSystem +import org.apache.pekko.http.scaladsl.Http +import org.apache.pekko.http.scaladsl.model.StatusCodes._ +import org.apache.pekko.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.server._ +import sangria.marshalling.circe._ + +// This is the trait that makes `graphQLPlayground and prepareGraphQLRequest` available +import sangria.http.circe.CirceHttpSupport + +import tv.miracle.backend.db.DB +import scala.concurrent.ExecutionContext +import org.apache.pekko.http.scaladsl.marshalling.ToResponseMarshallable +import io.getquill.PostgresJdbcContext +import io.getquill.SnakeCase +import io.getquill._ + +case class Test(name: String, sexy: Option[Boolean]) +case class RejectionError(message: String) + +object GraphQL extends App with CirceHttpSupport with CorsSupport { + def main = () => {} + def handleGraphQL()(implicit ec: ExecutionContext, ctx: PostgresJdbcContext[SnakeCase]): Route = { + optionalHeaderValueByName { "X-Apollo-Tracing" } { (tracing) => + graphQLPlayground ~ + prepareGraphQLRequest { + case Success(req) => + import ctx._ + val users = run(query[Test]) + println(users) + val middleware = if (tracing.isDefined) SlowLog.apolloTracing :: Nil else Nil + val deferredResolver = DeferredResolver.fetchers(SchemaDefinition.characters) + val res = Executor.execute( + schema = SchemaDefinition.StarWarsSchema, + queryAst = req.query, + userContext = new CharacterRepo, + variables = req.variables, + operationName = req.operationName, + middleware = middleware, + deferredResolver = deferredResolver) + .map(OK -> _) + .recover { + case error: QueryAnalysisError => BadRequest -> { + println(error.resolveError) + error.resolveError + } + case error: ErrorWithResolver => InternalServerError -> { + println(error.resolveError) + error.resolveError + } + } + complete(res) + case Failure(preparationError) => complete(BadRequest, formatError(preparationError)) + } + } + } +} diff --git a/src/main/scala/graphql/SchemaDefinition.scala b/src/main/scala/graphql/SchemaDefinition.scala new file mode 100644 index 0000000..ef0cdd8 --- /dev/null +++ b/src/main/scala/graphql/SchemaDefinition.scala @@ -0,0 +1,126 @@ +package tv.miracle.backend.graphql + +import sangria.execution.deferred.{ Fetcher, HasId } +import sangria.schema._ + +import scala.concurrent.Future + +/** + * Defines a GraphQL schema for the current project + */ +object SchemaDefinition { + /** + * Resolves the lists of characters. These resolutions are batched and + * cached for the duration of a query. + */ + val characters: Fetcher[CharacterRepo, Character, Character, String] = Fetcher.caching( + (ctx: CharacterRepo, ids: Seq[String]) => + Future.successful(ids.flatMap(id => ctx.getHuman(id) orElse ctx.getDroid(id))))(HasId(_.id)) + + val EpisodeEnum: EnumType[Episode.Value] = EnumType( + "Episode", + Some("One of the films in the Star Wars Trilogy"), + List( + EnumValue( + "NEWHOPE", + value = Episode.NEWHOPE, + description = Some("Released in 1977.")), + EnumValue( + "EMPIRE", + value = Episode.EMPIRE, + description = Some("Released in 1980.")), + EnumValue( + "JEDI", + value = Episode.JEDI, + description = Some("Released in 1983.")))) + + val Character: InterfaceType[CharacterRepo, Character] = + InterfaceType( + "Character", + "A character in the Star Wars Trilogy", + () => fields[CharacterRepo, Character]( + Field("id", StringType, + Some("The id of the character."), + resolve = _.value.id), + Field("name", OptionType(StringType), + Some("The name of the character."), + resolve = _.value.name), + Field("friends", ListType(Character), + Some("The friends of the character, or an empty list if they have none."), + resolve = ctx => characters.deferSeqOpt(ctx.value.friends)), + Field("appearsIn", OptionType(ListType(OptionType(EpisodeEnum))), + Some("Which movies they appear in."), + resolve = _.value.appearsIn map (e => Some(e))))) + + val Human: ObjectType[CharacterRepo, Human] = + ObjectType( + "Human", + "A humanoid creature in the Star Wars universe.", + interfaces[CharacterRepo, Human](Character), + fields[CharacterRepo, Human]( + Field("id", StringType, + Some("The id of the human."), + resolve = _.value.id), + Field("name", OptionType(StringType), + Some("The name of the human."), + resolve = _.value.name), + Field("friends", ListType(Character), + Some("The friends of the human, or an empty list if they have none."), + resolve = ctx => characters.deferSeqOpt(ctx.value.friends)), + Field("appearsIn", OptionType(ListType(OptionType(EpisodeEnum))), + Some("Which movies they appear in."), + resolve = _.value.appearsIn map (e => Some(e))), + Field("homePlanet", OptionType(StringType), + Some("The home planet of the human, or null if unknown."), + resolve = _.value.homePlanet))) + + val Droid: ObjectType[CharacterRepo, Droid] = ObjectType( + "Droid", + "A mechanical creature in the Star Wars universe.", + interfaces[CharacterRepo, Droid](Character), + fields[CharacterRepo, Droid]( + Field("id", StringType, + Some("The id of the droid."), + resolve = _.value.id), + Field("name", OptionType(StringType), + Some("The name of the droid."), + resolve = ctx => Future.successful(ctx.value.name)), + Field("friends", ListType(Character), + Some("The friends of the droid, or an empty list if they have none."), + resolve = ctx => characters.deferSeqOpt(ctx.value.friends)), + Field("appearsIn", OptionType(ListType(OptionType(EpisodeEnum))), + Some("Which movies they appear in."), + resolve = _.value.appearsIn map (e => Some(e))), + Field("primaryFunction", OptionType(StringType), + Some("The primary function of the droid."), + resolve = _.value.primaryFunction))) + + val ID: Argument[String] = Argument("id", StringType, description = "id of the character") + + val EpisodeArg: Argument[Option[Episode.Value]] = Argument("episode", OptionInputType(EpisodeEnum), + description = "If omitted, returns the hero of the whole saga. If provided, returns the hero of that particular episode.") + + val LimitArg: Argument[Int] = Argument("limit", OptionInputType(IntType), defaultValue = 20) + val OffsetArg: Argument[Int] = Argument("offset", OptionInputType(IntType), defaultValue = 0) + + val Query: ObjectType[CharacterRepo, Unit] = ObjectType( + "Query", fields[CharacterRepo, Unit]( + Field("hero", Character, + arguments = EpisodeArg :: Nil, + deprecationReason = Some("Use `human` or `droid` fields instead"), + resolve = ctx => ctx.ctx.getHero(ctx.arg(EpisodeArg))), + Field("human", OptionType(Human), + arguments = ID :: Nil, + resolve = ctx => ctx.ctx.getHuman(ctx arg ID)), + Field("droid", Droid, + arguments = ID :: Nil, + resolve = ctx => ctx.ctx.getDroid(ctx arg ID).get), + Field("humans", ListType(Human), + arguments = LimitArg :: OffsetArg :: Nil, + resolve = ctx => ctx.ctx.getHumans(ctx arg LimitArg, ctx arg OffsetArg)), + Field("droids", ListType(Droid), + arguments = LimitArg :: OffsetArg :: Nil, + resolve = ctx => ctx.ctx.getDroids(ctx arg LimitArg, ctx arg OffsetArg)))) + + val StarWarsSchema: Schema[CharacterRepo, Unit] = Schema(Query) +} diff --git a/src/main/scala/sangria/GraphQLHttpRequest.scala b/src/main/scala/sangria/GraphQLHttpRequest.scala new file mode 100644 index 0000000..da33a44 --- /dev/null +++ b/src/main/scala/sangria/GraphQLHttpRequest.scala @@ -0,0 +1,6 @@ +package sangria.http.pekko + +case class GraphQLHttpRequest[T]( + query: Option[String], + variables: Option[T], + operationName: Option[String]) diff --git a/src/main/scala/sangria/GraphQLRequest.scala b/src/main/scala/sangria/GraphQLRequest.scala new file mode 100644 index 0000000..dde421e --- /dev/null +++ b/src/main/scala/sangria/GraphQLRequest.scala @@ -0,0 +1,13 @@ +package sangria.http.pekko + +import sangria.ast.Document + +case class GraphQLRequest[T](query: Document, variables: T, operationName: Option[String]) + +object GraphQLRequest { + def apply[T](query: Document, variables: Option[T], operationName: Option[String])(implicit v: Variables[T]): GraphQLRequest[T] = + new GraphQLRequest( + query = query, + variables = variables.fold(v.empty)(identity), + operationName = operationName) +} diff --git a/src/main/scala/sangria/GraphQLRequestUnmarshaller.scala b/src/main/scala/sangria/GraphQLRequestUnmarshaller.scala new file mode 100644 index 0000000..455556b --- /dev/null +++ b/src/main/scala/sangria/GraphQLRequestUnmarshaller.scala @@ -0,0 +1,38 @@ +package sangria.http.pekko + +import org.apache.pekko.http.scaladsl.marshalling.{ Marshaller, ToEntityMarshaller } +import org.apache.pekko.http.scaladsl.model._ +import org.apache.pekko.http.scaladsl.unmarshalling.{ FromEntityUnmarshaller, Unmarshaller } +import org.apache.pekko.util.ByteString +import sangria.ast.Document +import sangria.parser.QueryParser +import sangria.renderer.{ QueryRenderer, QueryRendererConfig } + +import java.nio.charset.StandardCharsets + +object GraphQLRequestUnmarshaller { + val `application/graphql` = + MediaType.applicationWithFixedCharset("graphql", HttpCharsets.`UTF-8`, "graphql") + + def unmarshallerContentTypes: Seq[ContentTypeRange] = + mediaTypes.map(ContentTypeRange.apply) + + def mediaTypes: Seq[MediaType.WithFixedCharset] = + List(`application/graphql`) + + implicit final def documentMarshaller(implicit config: QueryRendererConfig = QueryRenderer.Compact): ToEntityMarshaller[Document] = + Marshaller.oneOf(mediaTypes*) { mediaType => + Marshaller.withFixedContentType(ContentType(mediaType)) { json => + HttpEntity(mediaType, QueryRenderer.render(json, config)) + } + } + + implicit final val documentUnmarshaller: FromEntityUnmarshaller[Document] = + Unmarshaller.byteStringUnmarshaller + .forContentTypes(unmarshallerContentTypes*) + .map { + case ByteString.empty => throw Unmarshaller.NoContentException + case data => + QueryParser.parse(data.decodeString(StandardCharsets.UTF_8)).fold(e => throw e, identity) + } +} diff --git a/src/main/scala/sangria/SangriaPekkoHttp.scala b/src/main/scala/sangria/SangriaPekkoHttp.scala new file mode 100644 index 0000000..e9cb74b --- /dev/null +++ b/src/main/scala/sangria/SangriaPekkoHttp.scala @@ -0,0 +1,184 @@ +package sangria.http.pekko + +import org.apache.pekko.http.scaladsl.model.MediaTypes._ +import org.apache.pekko.http.scaladsl.server.Directives._ +import org.apache.pekko.http.scaladsl.server.{ + Directive, + ExceptionHandler, + MalformedQueryParamRejection, + MalformedRequestContentRejection, + RejectionHandler, + Route, + StandardRoute +} +import org.apache.pekko.http.scaladsl.unmarshalling.{ FromEntityUnmarshaller, FromStringUnmarshaller } +import Util.explicitlyAccepts +import sangria.ast.Document +import sangria.parser.{ QueryParser, SyntaxError } +import GraphQLRequestUnmarshaller._ +import org.apache.pekko.http.javadsl.server.RequestEntityExpectedRejection +import org.apache.pekko.http.scaladsl.marshalling.ToEntityMarshaller +import org.apache.pekko.http.scaladsl.model.StatusCodes.{ + BadRequest, + InternalServerError, + OK, + UnprocessableEntity +} + +import scala.util.control.NonFatal +import scala.util.{ Failure, Success, Try } + +trait SangriaPekkoHttp[Input] { + import SangriaPekkoHttp._ + + type GQLRequestHandler = PartialFunction[Try[GraphQLRequest[Input]], StandardRoute] + implicit def errorMarshaller: ToEntityMarshaller[GraphQLErrorResponse] + implicit def requestUnmarshaller: FromEntityUnmarshaller[GraphQLHttpRequest[Input]] + implicit def variablesUnmarshaller: FromStringUnmarshaller[Input] + + private val MISSING_QUERY_MSG = + s"""Could not extract `query` from request. + |Please confirm you have included a valid GraphQL query either as a QueryString parameter, or in the body of your request.""".stripMargin + + private val HELPFUL_UNPROCESSABLE_ERR = MalformedRequest(s""" + |Check that you have provided well-formed JSON in the request. + |`variables` must also be valid JSON if you have provided this + |parameter to your request.""".stripMargin) + + def malformedRequestHandler: RejectionHandler = + RejectionHandler + .newBuilder() + .handle { + case r: MalformedQueryParamRejection => + val err = formatError(r.cause.getOrElse(MalformedRequest(r.errorMsg))) + complete( + UnprocessableEntity, + GraphQLErrorResponse( + errors = err :: formatError(HELPFUL_UNPROCESSABLE_ERR) :: Nil)) + case r: MalformedRequestContentRejection => + val err = formatError(r.cause) + complete( + UnprocessableEntity, + GraphQLErrorResponse( + errors = err :: formatError(HELPFUL_UNPROCESSABLE_ERR) :: Nil)) + case _: RequestEntityExpectedRejection => + val err = formatError(MalformedRequest(MISSING_QUERY_MSG)) + complete( + BadRequest, + GraphQLErrorResponse( + errors = err :: Nil)) + } + .result() + + def graphQLExceptionHandler: ExceptionHandler = + ExceptionHandler { + case _ => + complete( + InternalServerError, + GraphQLErrorResponse( + GraphQLError( + "Internal Server Error") :: Nil)) + } + + val graphQLPlayground: Route = get { + explicitlyAccepts(`text/html`) { + getFromResource("assets/playground.html") + } + } + + def formatError(error: Throwable): GraphQLError = error match { + case syntaxError: SyntaxError => + GraphQLError( + syntaxError.getMessage, + Some( + Location( + syntaxError.originalError.position.line, + syntaxError.originalError.position.column) :: Nil)) + case NonFatal(e) => + GraphQLError(e.getMessage) + case e => + throw e + } + + def prepareQuery(maybeQuery: Option[String]): Try[Document] = maybeQuery match { + case Some(q) => QueryParser.parse(q) + case None => Left(MalformedRequest(MISSING_QUERY_MSG)).toTry + } + + private def extractParams: Directive[(Option[String], Option[String], Option[Input])] = + parameters(Symbol("query").?, Symbol("operationName").?, Symbol("variables").as[Input].?) + + private def prepareGraphQLPost(inner: GQLRequestHandler)(implicit v: Variables[Input]): Route = + extractParams { + case (queryParam, operationNameParam, variablesParam) => + // Content-Type: application/json + entity(as[GraphQLHttpRequest[Input]]) { body => + val maybeOperationName = operationNameParam.orElse(body.operationName) + val maybeQuery = queryParam.orElse(body.query) + + // Variables may be provided in the QueryString, or possibly in the body as a String: + // If we were unable to parse the variables from the body as a string, + // we read them as JSON, and finally if no variables have been located + // in the QueryString, Body (as a String) or Body (as JSON), we provide + // an empty JSON object as the final result + val maybeVariables = variablesParam.orElse(body.variables) + + prepareQuery(maybeQuery) match { + case Success(document) => + val result = GraphQLRequest( + query = document, + variables = maybeVariables, + operationName = maybeOperationName) + inner(Success(result)) + case Failure(error) => inner(Failure(error)) + } + } ~ + // Content-Type: application/graphql + entity(as[Document]) { document => + val result = GraphQLRequest( + query = document, + variables = variablesParam, + operationName = operationNameParam) + inner(Success(result)) + } + } + + private def prepareGraphQLGet(inner: GQLRequestHandler)(implicit v: Variables[Input]): Route = + extractParams { (maybeQuery, maybeOperationName, maybeVariables) => + prepareQuery(maybeQuery) match { + case Success(document) => + val result = GraphQLRequest( + query = document, + variables = maybeVariables, + maybeOperationName) + inner(Success(result)) + case Failure(error) => inner(Failure(error)) + } + } + + def prepareGraphQLRequest(inner: GQLRequestHandler)(implicit v: Variables[Input]): Route = + handleExceptions(graphQLExceptionHandler) { + handleRejections(malformedRequestHandler) { + get { + prepareGraphQLGet(inner) + } ~ post { + prepareGraphQLPost(inner) + } + } + } + + def graphQLRoute(inner: GQLRequestHandler)(implicit v: Variables[Input]): Route = + path("graphql") { + graphQLPlayground ~ prepareGraphQLRequest(inner) + } +} + +object SangriaPekkoHttp { + final case class MalformedRequest( + private val message: String = "Your request could not be processed", + private val cause: Throwable = None.orNull) extends Exception(message, cause) + + case class Location(line: Int, column: Int) + case class GraphQLError(message: String, locations: Option[List[Location]] = None) + case class GraphQLErrorResponse(errors: List[GraphQLError]) +} diff --git a/src/main/scala/sangria/Util.scala b/src/main/scala/sangria/Util.scala new file mode 100644 index 0000000..09d4d31 --- /dev/null +++ b/src/main/scala/sangria/Util.scala @@ -0,0 +1,14 @@ +package sangria.http.pekko + +import org.apache.pekko.http.scaladsl.model.MediaType +import org.apache.pekko.http.scaladsl.model.headers.Accept +import org.apache.pekko.http.scaladsl.server.Directive0 +import org.apache.pekko.http.scaladsl.server.Directives.{ headerValuePF, pass } + +object Util { + def explicitlyAccepts(mediaType: MediaType): Directive0 = + headerValuePF { + case Accept(ranges) if ranges.exists(range => !range.isWildcard && range.matches(mediaType)) => + ranges + }.flatMap(_ => pass) +} diff --git a/src/main/scala/sangria/Variables.scala b/src/main/scala/sangria/Variables.scala new file mode 100644 index 0000000..84b4956 --- /dev/null +++ b/src/main/scala/sangria/Variables.scala @@ -0,0 +1,5 @@ +package sangria.http.pekko + +trait Variables[T] { + def empty: T +} diff --git a/src/test/scala/MySuite.scala b/src/test/scala/MySuite.scala new file mode 100644 index 0000000..621784d --- /dev/null +++ b/src/test/scala/MySuite.scala @@ -0,0 +1,9 @@ +// For more information on writing tests, see +// https://scalameta.org/munit/docs/getting-started.html +class MySuite extends munit.FunSuite { + test("example test that succeeds") { + val obtained = 42 + val expected = 42 + assertEquals(obtained, expected) + } +}