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)
+ }
+}