A simple wrapper for using Sangria from within Finch.
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:
Future
and Try
.Future
s containing Option
s or Either
s instead a failing Future
. Failing Future
s are only used for
things that we'd not reasonably expect a client to be able to handle (i.e. something catastrophic).There are some things that need improvement, including:
Json
, mainly because of the CirceResultMarshaller
. Ideally both of these
would use some form of class that represented the variables/results, and defined an InputUnmarshaller
and a
ResultMarshaller
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:
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.