GraphQL on Scala3 with Sangria
2021-10-25
ScalaGraphQLSangriaAlmost two and a half years ago, I posted a post about how to build a GraphQL HTTP server using sangria-graphql library in Scala2.
I've spent some time to refresh my knowledge and learn Scala3 features like contextural function, extension, type lambda, and so on.
This post is to dump my understandings and leave what I did.
If you're seeking official one, see sangria-graphql/sangria-akka-http-example instead.
build.sbt
Let's see build.sbt to grab how to install libraries with enabling Scala3 cross compilation.
val scala3Version = "3.0.2"
val javaVersion = "11"
val projectName = "scala3-sandbox"
val projectVersion = "0.1.0"
lazy val `scala3-sandbox` = project
.in(file("."))
.aggregate(`webapp-akka`)
val akkaVersion = "2.6.16"
val akkaHttpVersion = "10.2.6"
val circeVersion = "0.14.1"
val sangriaVersion = "2.1.3"
lazy val `webapp-akka` = project.in(file("webapp_akka"))
.settings(
name := "webapp-akka",
version := projectVersion,
scalaVersion := scala3Version,
scalacOptions ++= Seq("-deprecation", "-feature", s"-Xtarget:${javaVersion}"),
javacOptions ++= Seq("-source", javaVersion, "-target", javaVersion)
run / fork := true,
libraryDependencies ++= Seq(
"io.circe" %% "circe-core" % circeVersion,
"io.circe" %% "circe-parser" % circeVersion,
"io.circe" %% "circe-generic" % circeVersion,
"org.sangria-graphql" %% "sangria-circe" % "1.3.2",
"ch.qos.logback" % "logback-classic" % "1.2.6" % Runtime,
) ++ Seq(
"org.sangria-graphql" %% "sangria" % sangriaVersion,
"com.typesafe.akka" %% "akka-actor-typed" % akkaVersion,
"com.typesafe.akka" %% "akka-stream" % akkaVersion,
"com.typesafe.akka" %% "akka-http" % akkaHttpVersion,
"com.typesafe.akka" %% "akka-slf4j" % akkaVersion,
).map(_.cross(CrossVersion.for3Use2_13))
)
In short;
- Scala 3.0.2, which is the latest at the moment
- akka-http to interact with HTTP layer
- [sangria-graphql]((https://github.com/sangria-graphql/sangria) to build GraphQL
- circe to de-/serialize JSON
run / fork := true
to be able to kill launched application properly without being a zombie
Defining GraphQL schema
Define GraphQL schema as whatever you want.
As an example, going to implement database, table, and column in GraphQL schema with using Scala 3 features like opaque type, extension.
case class Database(
id: DatabaseId,
name: DatabaseName,
)
opaque type DatabaseId = String
object DatabaseId:
def apply(id: String): DatabaseId = id
def generate = apply(UUID.randomUUID().toString)
extension (a: DatabaseId) def value: String = a
opaque type DatabaseName = String
object DatabaseName:
def apply(name: String): DatabaseName = name
extension (a: DatabaseName) def value: String = a
object Database:
def create(name: DatabaseName): Database =
apply(DatabaseId.generate, name, ZonedDateTime.now())
case class Table(
id: TableId,
databaseId: DatabaseId,
name: TableName,
)
// implement TableId, TableName, and Table as Database related definitions.
// type lambda and contextual function
type Async = [T] =>> (ExecutionContext ?=> Future[T])
// types for accessing storage
trait DatabaseStore:
def findAll(): Async[Seq[Database]]
def findById(databaseId: DatabaseId): Async[Option[Database]]
trait TableStore:
def findById(tableId: TableId): Async[Option[Table]]
def findAllByDatabaseId(databaseId: DatabaseId): Async[Seq[Table]]
I wish Scala 3 provides a nicer way to define opaque type
with apply
as well as unapply
.
See related discussion if you're interested https://contributors.scala-lang.org/t/improve-opaque-types/4786/12
Next step is to define GraphQL schema types. Unfortunately, Sangria doesn't support Scala 3 macro, so we should define schema like ObjectType
by hand.
It means that every fields should be declared one by one, which is burdensome.
import sangria.schema.{Context => SangriaContext, _}
case class Context(executionContext: ExecutionContext)
extension [T](ctx: SangriaContext[Context, T])
def ec: ExecutionContext = ctx.ctx.executionContext // shorthand
// compile error
// sangria.macros.derive.deriveObjectType[Context, Database]()
given DatabaseType: ObjectType[Context, Database] = ObjectType(
"Database",
"database",
() =>
fields[Context, Database](
Field("id", StringType, resolve = _.value.id.value),
Field("name", StringType, resolve = _.value.name.value),
Field(
"tables",
ListType(TableType),
resolve = { ctx =>
tableStore.findAllByDatabaseId(ctx.value.id)(using ctx.ec)
},
),
),
)
given TableType: ObjectType[Context, Table] = ObjectType(
"Table",
"table",
() =>
fields[Context, Table](
Field("id", StringType, resolve = _.value.id.value),
Field("name", StringType, resolve = _.value.name.value),
Field(
"database",
OptionType(DatabaseType),
resolve = { ctx =>
databaseStore.findById(ctx.value.databaseId)(using ctx.ec)
},
),
),
)
val QuerySchema: ObjectType[Context, Unit] = ObjectType(
"Query",
fields[Context, Unit](
Field(
"databases",
ListType(DatabaseType),
resolve = { ctx =>
databaseStore.findAll()(using ctx.ec)
},
),
),
)
In Scala3, thanks to that given
is equals to implicit lazy val
, no need to take order of ObjectType
definitions into account.
Besides, note that if ObjectType
s have recursive reference like DatabaseType
and TableType
, the third argument of ObjectType.apply
must be a function as the above snippet shows.
Without making fields
a function, it'd cause dead-lock at runtime.
In case you hit an error that Web server doesn't respond to HTTP requests as expected, you can use jstack
to see Thread dump, which is helpful in investigating a cause of dead-lock.
De-/Serialization with Circe
circe/circe is one of the most popular JSON library that provides JSON serialization as well as deserialization capabilities nicely. In terms of using circe along with sangria and akka-http, you should consider using these following libraries.
But, for my learning, I implemented a bridge between circe and sangria in Scala 3.
import akka.http.scaladsl.marshalling.{Marshaller, ToEntityMarshaller}
import akka.http.scaladsl.model.{ContentTypes, HttpEntity, MediaTypes}
import akka.http.scaladsl.unmarshalling.{FromByteStringUnmarshaller, FromEntityUnmarshaller, Unmarshaller}
import akka.util.ByteString
import io.circe._
import scala.concurrent.Future
import scala.deriving.Mirror
trait CirceSupport {
given [A: Decoder]: Decoder[Option[A]] = Decoder.decodeOption[A]
given Decoder[Json] = Decoder.decodeJson
type JsonUnmarshaller = [T] =>> (Decoder[T] ?=> FromEntityUnmarshaller[T])
given jsonUnmarshaller[T]: JsonUnmarshaller[T] =
Unmarshaller.byteStringUnmarshaller.map { (bs: ByteString) =>
io.circe.jawn
.parseByteBuffer(bs.asByteBuffer)
.flatMap { summon[Decoder[T]].decodeJson }
.fold(throw _, identity)
}
inline given [T](using inline A: Mirror.Of[T]): Decoder[T] =
io.circe.generic.semiauto.deriveDecoder(using A)
type JsonMarshaller = [T] =>> (Encoder[T] ?=> ToEntityMarshaller[T])
given [T]: JsonMarshaller[T] = {
val contentType = ContentTypes.`application/json`
Marshaller.withFixedContentType(contentType) { t =>
HttpEntity(
contentType,
ByteString(
Printer.noSpaces.printToByteBuffer(
summon[Encoder[T]].apply(t),
contentType.charset.nioCharset(),
),
),
)
}
}
inline given [T](using inline A: Mirror.Of[T]): Encoder[T] =
io.circe.generic.semiauto.deriveEncoder(using A)
}
When I missed Decoder[Option[A]]
, circe raises deserialization errors like Option: DownField(query)
(query
is Option[String]
field), which was hard to identify the cause.
Launch Web server using Akka-HTTP
See sangria-graphql/sangria-akka-http/SangriaAkkaHttp.scala, which is super helpful for understanding what we should do to build HTTP server that speaks GraphQL on Sangria.
My implementation to handle POST request looks like:
case class GraphQLHttpRequest(
query: Option[String],
variables: Option[io.circe.Json],
operationName: Option[String],
)
def graphqlPost: Route = {
(post & entity(as[GraphQLHttpRequest])) { body =>
val GraphQLHttpRequest(queryOpt, varOpt, operationNameOpt) = body
queryOpt.map { q => QueryParser.parse(q) } match {
case Some(Success(document)) =>
val context = Context(graphqlExecutionContext)
val result = Executor.execute(
schema, document, context,
root = (), // without explicit `()`, it throws; java.lang.NoSuchMethodError: 'scala.runtime.BoxedUnit sangria.execution.Executor$.execute$default$4()'
operationName = operationName,
variables = varOpt.fold(Json.obj()) { identity },
).map { json =>
StatusCodes.OK -> json
}.recover {
case error: QueryAnalysisError =>
StatusCodes.BadRequest -> error.resolveError
case error: ErrorWithResolver =>
StatusCodes.InternalServerError -> error.resolveError
}
complete(result)
case Some(Failure(error)) =>
complete(
StatusCodes.BadRequest,
GraphQLErrorResponse("failed to parse query", Some(error)),
)
case None =>
complete(
StatusCodes.BadRequest,
InvalidRequest("query must be given"),
)
}
}
}
Given these implementations (though some parts are omitted), we can launch Web server using Akka-HTTP as below snippet.
object Service extends App with CirceSupport:
val config = ConfigFactory.load()
given actorSystem: ActorSystem = ActorSystem("AkkaHttpWebApp", config)
given executionContext: ExecutionContext = actorSystem.dispatcher
run(config)
def run(config: Config): ActorSystem ?=> ExecutionContext ?=> Unit = {
val interface = config.getString("http.interface")
val host = config.getInt("http.port")
val binding = Http()
.newServerAt(interface, host)
.bindFlow(routes)
.map(_.addToCoordinatedShutdown(hardTerminationDeadline = 10.seconds))(
ExecutionContext.global,
)
binding.onComplete {
case Success(b) =>
logger.info(s"Server started at ${interface}:${host}!")
case Failure(e) =>
logger.error(s"failed to bind", e)
summon[ActorSystem].terminate()
}
}
def routes: Route = path("graphql") { graphiQLGet ~ graphqlPost }
end Service
That's it!
Running HTTP server
It seems Akka-HTTP server doesn't run on SBT very well. Sometimes even after killing an application by pressing Ctrl-C, the application remains running and becomes zombie.
For addressing this inconvenience, recommend to install sbt-revolver plugin which offers reStart
SBT command that runs applications in forked JVM process.
addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1")
I've already mentioned that a configuration run / fork := true
in build.sbt would help by running application on forked JVM as reStart
does, however, the latter has been working better based on my experience so far.
After succeeding launching application, it should be able handle HTTP requests.
$ curl -H "Content-Type: application/json" \
'http://localhost:8080/graphql' \
-XPOST -d '{"operationName":null,"variables":{},"query":"{ databases { id name } }"}' \
| jq -S '.'
{
"data": {
"databases": [
{
"id": "6fd1d7ef-2f01-4413-be92-3012f6b182e4",
"name": "db-1"
},
{
"id": "d934c117-5d96-434c-aa75-7c99a011d6a9",
"name": "db-2"
},
{
"id": "3208b66f-cd17-4600-9621-89b2721116c0",
"name": "db-3"
}
]
}
}
Misc
With Scala 3, I feel like writing code a bit more intuitive and readable than in Scala 2, but largely I've been able to write in the same manner thanks to backward compability.
However, on my laptop's IntelliJ, it doesn't work super well in processing Scala 3 code, like it often says "Cannot find declaration to go to" error message when attempting going to definition of classes, values, etc., which is very annoying and I feel disturbed so much.
In terms of macro, some of libraries that depend on Scala 2 macro don't run on Scala 3 yet.
https://scalacenter.github.io/scala-3-migration-guide/docs/macros/macro-libraries.html
In addition, since macro annotation is not supported in Scala 3, we can't do code generation as we did in Scala 2.
If macro annotation or something like that gets supported in Scala 3, we'll be able to utilize opaque type
more, I think.
See the entire of example code at petitviolet/scala3_sandbox if you're interested.