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](https://github.com/sangria-graphql/sangria)の導入。 [公式ドキュメント](http://sangria-graphql.org)および[Larning Sangria](http://sangria-graphql.org/learn/)を読むのが一番速い。 さらに公式がサンプルも用意しているので、そちらを参照するのが良い。 [sangria-graphql/sangria-akka-http-example](https://

    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.