blog.petitviolet.net

GraphQL on Scala3 with Sangria

2021-10-25

ScalaGraphQLSangria

Almost two and a half years ago, I posted a post about how to build a GraphQL HTTP server using sangria-graphql library in Scala2.

Scala - Sangriaを使ってGraphQL APIを実装する この記事はなに?ScalaでGraphQLサーバを実装するためのsangria/ この記事はなに? Scala で GraphQL サーバを実装するためのsangria/sangriaの導入。 公式ドキュメントおよびLarning Sangriaを読むのが一番速い。 さらに公式がサンプルも用意しているので、そちらを参照するのが良い。 sangria-graphql/sangria-akka-http…

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.

build.sbt
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;

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.

models.scala
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.

Schema.scala
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 ObjectTypes 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.

CirceSupport.scala
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:

GraphQLRouting.scala
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.

Service.scala
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.

project/plugins.sbt
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.