Some simple wrappers around Sangria to support its use in Finch.
It is a small layer, that is reasonably opininated, which may not be to your liking. In particular:
- We transport GraphQL queries as JSON, over HTTP. This necessitates some nasties from time to time.
- We use Twitter classes instead of the standard library, for things like
Future
andTry
. - We use
Future
s containingOption
s orEither
s instead a failingFuture
. FailingFuture
s are only used for things that we'd not reasonably expect a client to be able to handle (i.e. something catastrophic). - We handle variables in the form of a JSON encoded string (for example from GraphiQL), as well as a straight JSON object.
- We do our best to give back semi-sane HTTP status codes.
- We expect that you want strong types for things.
There are some things that need improvement, including:
- We are hard coded to Circe, it should be fairly easy to decouple it should you so wish.
- In the same vein, the executor returns
Json
, mainly because of theCirceResultMarshaller
. Ideally both of these would use some form of class that represented the variables/results, and defined anInputUnmarshaller
and aResultMarshaller
for them respectively. In particular, this leads to the unpleasantness with the re-parsing of the JSON returned from the underlying executor to find the status of the result.
If you like this, you might like other open source code from Redbubble:
- rb-scala-utils - Miscellaneous utilities (common code) for building Scala-based services, using Finch (on which this project depends).
- finch-template - A template project for Finch-based services.
- rb-graphql-template - A template for Scala HTTP GraphQL services.
- finagle-hawk - HTTP Holder-Of-Key Authentication Scheme for Finagle.
You will need to add something like the following to your build.sbt
:
resolvers += Resolver.jcenterRepo
libraryDependencies += "com.redbubble" %% "finch-sangria" % "0.3.8"
-
Configure the executor:
val schema = ... // your Sangria schema val context = ... // your root context val errorReporter = ... // a way to log errors, e.g. Rollbar val serverMetrics = ... // your stats receiver val logger = ... // a logger val executor = GraphQlQueryExecutor.executor( schema, context, maxQueryDepth = 10)(errorReporter, serverMetrics, logger)
Set the max depth to whatever suits your schema (you'll likely need >= 10 for the introspection query).
-
Write your endpoint:
import com.redbubble.graphql.GraphQlRequestDecoders.graphQlQueryDecode object GraphQlApi { val stats = StatsReceiver.stats def graphQlGet: Endpoint[Json] = get("graphql" :: graphqlQuery) { query: GraphQlQuery => executeQuery(query) } def graphQlPost: Endpoint[Json] = post("graphql" :: jsonBody[GraphQlQuery]) { query: GraphQlQuery => executeQuery(query) } private def executeQuery(query: GraphQlQuery): Future[Output[Json]] = { val operationName = query.operationName.getOrElse("unnamed_operation") stats.counter("count", operationName).incr() Stat.timeFuture(stats.stat("execution_time", operationName)) { runQuery(query) } } private def runQuery(query: GraphQlQuery): Future[Output[Json]] = { val result = executor.execute(query)(globalAsyncExecutionContext) // Do our best to map the type of error back to a HTTP status code result.map { case SuccessfulGraphQlResult(json) => Output.payload(json, Status.Ok) case ClientErrorGraphQlResult(json, _) => Output.payload(json, Status.BadRequest) case BackendErrorGraphQlResult(json, _) => Output.payload(json, Status.InternalServerError) } } }
-
Bring the response encoder into scope when you create your
Service
:import com.redbubble.graphql.GraphQlEncoders.graphQlResultEncode val api = GraphQlApi.graphQlGet :+: GraphQlApi.graphQlPost val service = api.toServiceAs[Application.Json] Http.server.serve(":8080", service)
If you want to integrate GraphiQL (you should), it's pretty easy.
-
Pull down the latest GraphiQL file.
-
You may need to adjust the paths within the GraphiQL file if you're using versioned paths, etc.
-
Stick it somewhere in your classpath.
-
Write an endpoint for it:
object ExploreApi { private val graphiQlPath = "/graphiql.html" def explore: Endpoint[Response] = get("explore") { classpathResource(graphiQlPath).map(fromStream) match { case Some(content) => asyncHtmlResponse(Status.Ok, AsyncStream.fromReader(content, chunkSize = 512.kilobytes.inBytes.toInt)) case None => textResponse(Status.InternalServerError, Buf.Utf8(s"Unable to find GraphiQL at '$graphiQlPath'")) } } private def classpathResource(name: String): Option[InputStream] = Option(getClass.getResourceAsStream(name)) }
We've added some other bits & pieces to make using Sangria easier.
There are various helpers that can help you define Scalar types. For example to add support for a tagged type:
//
// Set up a tagged type
//
import shapeless.tag
import shapeless.tag._
trait PixelWidthTag
type PixelWidth = Int @@ PixelWidthTag
def PixelWidth(w: Int): @@[Int, PixelWidthTag] = tag[PixelWidthTag](w)
//
// Define your GraphQL type for the tagged type
//
private val widthRange = 1 to MaxImageDimension
private implicit val widthInput = new ScalarToInput[PixelWidth]
private case object WidthCoercionViolation
extends ValueCoercionViolation(s"Width in pixels, between ${widthRange.start} and ${widthRange.end}")
private def parseWidth(i: Int) = intValueFromInt(i, widthRange, PixelWidth, () => WidthCoercionViolation)
val WidthType = intScalarType(
"width",
s"The width of an image, in pixels, between ${widthRange.start} and ${widthRange.end} (default $DefaultImageWidth).",
parseWidth, () => WidthCoercionViolation)
val WidthArg: Argument[PixelWidth] = Argument(
name = "width",
argumentType = OptionInputType(WidthType),
description = s"The width of an image, in pixels, between ${widthRange.start} and ${widthRange.end} (default $DefaultImageWidth).", defaultValue = DefaultImageWidth)
We've also added support for input types, in a similar way to how other types are handled, they are typesafe.
// Tagged type
trait PushNotificationTokenTag
type PushNotificationToken = String @@ PushNotificationTokenTag
def PushNotificationToken(t: String): @@[String, PushNotificationTokenTag] = tag[PushNotificationTokenTag](t)
// GraphQL type
private case object PushNotificationTokenCoercionViolation
extends ValueCoercionViolation(s"Push notification token expected")
private def parseToken(s: String): Either[PushNotificationTokenCoercionViolation.type, PushNotificationToken] =
Right(PushNotificationToken(s))
val PushNotificationTokenType =
stringScalarType(
"PushNotificationToken", s"An iOS push notification token.",
parseToken, () => PushNotificationTokenCoercionViolation
)
val PushNotificationTokenArg =
Argument("token", PushNotificationTokenType, description = s"An iOS push notification token.")
//
// Input type for our type
//
val FieldPushNotificationToken = InputField(
"token",
OptionInputType(PushNotificationTokenType),
"If available, the push notification token for the device. May be empty if the user has not given permission to send notifications."
)
val RegisterDeviceType: InputObjectType[DefaultInput] =
InputObjectType(
name = "RegisterDevice",
description = "Register device fields.",
fields = List(FieldPushNotificationToken, FieldBundleId, FieldAppVersion, FieldOsVersion)
)
val RegisterDeviceArg = Argument(InputFieldName, RegisterDeviceType, "Register device fields.")
//
// Let's use that type in a mutation
//
object DeviceRegistration extends InputHelper {
def registerDevice(ctx: Context[RootContext, Unit]): Action[RootContext, RegisteredDevice] = {
val token = ctx.inputArg(FieldPushNotificationToken).flatten
val registeredDevice = for {
bundleId <- ctx.inputArg(FieldBundleId)
appVersion <- ctx.inputArg(FieldAppVersion).flatMap(fromRawVersion)
osVersion <- ctx.inputArg(FieldOsVersion).flatMap(fromRawVersion)
} yield {
val device = Device.device(token, App(bundleId, appVersion), osVersion)
ctx.ctx.registerDevice(device)
}
registeredDevice.getOrElse(Future.exception(graphQlError("Unable to parse device input fields"))).asScala
}
}
val MutationType: ObjectType[RootContext, Unit] = ObjectType(
"MutationAPI",
description = "The Redbubble iOS Mutation API.",
fields[RootContext, Unit](
Field(
name = "registerDevice",
arguments = List(RegisterDeviceArg),
fieldType = OptionType(RegisteredDeviceType),
resolve = registerDevice
)
)
)
For contributors, a cheat sheet to making a new release:
$ git commit -m "New things" && git push
$ git tag -a v0.0.3 -m "v0.0.3"
$ git push --tags
$ ./sbt publish
Issues and pull requests are welcome. Code contributions should be aligned with the above scope to be included, and include unit tests.