# Middleware
Caliban allows you to perform additional actions at various levels of a query processing, via the concept of Wrapper
. Using wrappers, you can:
- verify that a query doesn't reach some limit (e.g. depth, complexity)
- modify a query before it's executed
- add timeouts to queries or fields
- log each field execution time
- support Apollo Tracing (opens new window), Apollo Caching (opens new window) or anything similar
- etc.
# Wrapper types
There are 6 basic types of wrappers:
OverallWrapper
to wrap the whole query processingParsingWrapper
to wrap the query parsing onlyValidationWrapper
to wrap the query validation onlyExecutionWrapper
to wrap the query execution onlyFieldWrapper
to wrap each field executionIntrospectionWrapper
to wrap the introspection query only
Each one requires a function that takes a ZIO
or ZQuery
computation together with some contextual information (e.g. the query string) and should return another computation.
Let's see how to implement a wrapper that times out the whole query if its processing takes longer than 1 minute.
import caliban._
import caliban.CalibanError._
import caliban.Value._
import caliban.wrappers.Wrapper._
import zio._
val wrapper = new OverallWrapper[Any] {
def wrap[R](
process: GraphQLRequest => ZIO[R, Nothing, GraphQLResponse[CalibanError]]
): GraphQLRequest => ZIO[R, Nothing, GraphQLResponse[CalibanError]] =
(request: GraphQLRequest) =>
process(request)
.timeout(1.minute)
.map(
_.getOrElse(
GraphQLResponse(
NullValue,
List(ExecutionError(s"Query was interrupted after 1 minute:\n${request.query}"))
)
)
)
}
You can also combine wrappers using |+|
and create a wrapper that requires an effect to be run at each query using EffectfulWrapper
.
To use your wrapper, call GraphQL#withWrapper
or its alias @@
.
val api = graphQL(...).withWrapper(wrapper)
// or
val api = graphQL(...) @@ wrapper
# Pre-defined wrappers
Caliban comes with a few pre-made wrappers in caliban.wrappers.Wrappers
:
maxDepth
returns a wrapper that fails queries whose depth is higher than a given valuemaxFields
returns a wrapper that fails queries whose number of fields is higher than a given valuemaxCost
returns a wrapper that fails queries when the estimated cost of execution exceeds a given valuequeryCost
returns a wrapper which adds an extension field that includes the cost of executing the querytimeout
returns a wrapper that fails queries taking more than a specified timeprintErrors
returns a wrapper that prints errorsprintSlowQueries
returns a wrapper that prints slow querieslogSlowQueries
returns a wrapper that logs slow queries via ZIO's built-in loggingonSlowQueries
returns a wrapper that can run a given function on slow queriesmetrics
returns a wrapper that adds field-level metrics (count & duration) to the schema
In addition to those, Caliban also ships with some non-spec but standard wrappers
caliban.wrappers.ApolloTracing.apolloTracing
returns a wrapper that adds tracing data into theextensions
field of each response following Apollo Tracing (opens new window) format.caliban.wrappers.Caching.extension
returns a wrapper that computes the total cache policy for a query and stores it in the extensions of the response. The result can then be used by http adapters to set the appropriate cache headers in the response.caliban.wrappers.ApolloPersistedQueries.apolloPersistedQueries
returns a wrapper that caches and retrieves query using a hash using the Apollo Persisted Queries (opens new window) format.
They can be used like this:
val api =
graphQL(...) @@
maxDepth(50) @@
timeout(3 seconds) @@
printSlowQueries(500 millis) @@
apolloTracing() @@
Caching.extension()
# Wrapping the interpreter
All the wrappers mentioned above require that you don't modify the environment R
and the error type which is always a CalibanError
. It is also possible to wrap your GraphQLInterpreter
by calling wrapExecutionWith
on it. This method takes in a function f
and returns a new GraphQLInterpreter
that will wrap the execute
method with this function f
.
It is used internally to implement mapError
(customize errors) and provide
(eliminate the environment), but you can use it for other purposes such as adding a general timeout, logging response times, etc.
val i: GraphQLInterpreter[MyEnv, CalibanError] = ???
// change error type to String
val i2: GraphQLInterpreter[MyEnv, String] = i.mapError(_.toString)
// provide the environment
val i3: GraphQLInterpreter[Any, CalibanError] = i.provide(myEnv)
// add a timeout on every query execution
val i4: GraphQLInterpreter[MyEnv, CalibanError] =
i.wrapExecutionWith(
_.timeout(30 seconds).map(
_.getOrElse(GraphQLResponse(NullValue, List(ExecutionError("Timeout!"))))
)
)
# Customizing error responses
During various phases of executing a query, an error may occur. Caliban renders the different instances of CalibanError
to a GraphQL spec compliant response. As a user, you will most likely encounter ExecutionError
at some point because this will encapsulate the errors in the error channel of your effects. For Caliban to be able to render some basic message about the error that occured during query execution, it is important that your error extends Throwable
.
For more meaningful error handling, GraphQL spec allows for an extension
(opens new window) object in the error response. This object may include, for instance, code
information to model enum-like error codes that can be handled by a front-end. In order to generate this information, one can use the mapError
function on a GraphQLInterpreter
. An example is provided below in which we map a custom domain error within an ExecutionError
to a meaningful error code.
import caliban.ResponseValue.ObjectValue
sealed trait ExampleAppEncodableError extends Throwable {
def errorCode: String
}
case object UnauthorizedError extends ExampleAppEncodableError {
override def errorCode: String = "UNAUTHORIZED"
}
def withErrorCodeExtensions[R](
interpreter: GraphQLInterpreter[R, CalibanError]
): GraphQLInterpreter[R, CalibanError] = interpreter.mapError {
case err @ ExecutionError(_, _, _, Some(exampleError: ExampleAppEncodableError), _) =>
err.copy(extensions = Some(ObjectValue(List(("errorCode", StringValue(exampleError.errorCode))))))
case err: ExecutionError =>
err.copy(extensions = Some(ObjectValue(List(("errorCode", StringValue("EXECUTION_ERROR"))))))
case err: ValidationError =>
err.copy(extensions = Some(ObjectValue(List(("errorCode", StringValue("VALIDATION_ERROR"))))))
case err: ParsingError =>
err.copy(extensions = Some(ObjectValue(List(("errorCode", StringValue("PARSING_ERROR"))))))
}
# Wrapping the GraphQL
If you need to implement new functionality that involves not just changes to execution but also to the underlying
schema you can use the higher-level GraphQLAspect
which allows full control of the resulting GraphQL
that it wraps.
Here is such an example that is part of the federation
package which makes a schema available to be used as a sub-graph in
a federated graph:
def federate[R](original: GraphQL[R]): GraphQL[R] = {
import Schema._
case class Query(
_service: _Service,
_fieldSet: FieldSet = FieldSet("")
)
graphQL(RootResolver(Query(_service = _Service(original.render))), federationDirectives) |+| original
}
lazy val federated: GraphQLAspect[Nothing, Any] =
new GraphQLAspect[Nothing, Any] {
def apply[R1](original: GraphQL[R1]): GraphQL[R1] =
federate(original)
}
# Cost Estimation
The queryCost
and maxCost
wrappers as well as their variants can be used to estimate the cost of a query, however they require a bit
more set up to work properly.
These wrappers are in the CostEstimation
object which also comes with a special directive that can be used out of the box to instrument your
schema for cost analysis.
Given a schema in which different fields have different costs to execute, either because they require additional network or computing resources, or database access, or they
have some other dependency that makes them expensive to compute. You can add the CostDirective
to your resolver like so:
import caliban.schema.Schema.auto._
import caliban.schema.ArgBuilder.auto._
import caliban.wrappers.CostEstimation
import caliban.wrappers.CostEstimation._
case class SpokenLineArgs(offset: Int, limit: Int)
@GQLCost(2)
case class Character(
name: String,
// Compute a realtime list of all the spoken lines for this character
@GQLCost(100, multipliers = List("limit"))
spokenLines: SpokenLineArgs => UIO[List[String]]
)
case class Query(
@GQLCost(5) characters: UIO[List[Character]],
)
def allCharacterNames: UIO[List[String]] = ZIO.succeed(???)
def getLines(name: String, offset: Int, limit: Int): UIO[List[String]] = ???
val api = graphQL(RootResolver(Query(
characters = allCharacterNames.flatMap {
names => ZIO.foreach(names) { name =>
val character = Character(
name,
spokenLines = args => getLines(name, args.offset, args.limit)
)
ZIO.succeed(character)
}
}
)))
val apiWithCost = api @@
queryCost @@ // or queryCost(f: Field => Double) to specify your own field cost estimation
// or queryCostWith(f: Field => Double)(p: Double => URIO[R, Any]) to also specify a side effect after computing the total cost
// or queryCostZIO(f: Field => URIO[R, Double]) if your field calculation returns an effect
maxCost(100)(CostEstimation.costDirective)
// or maxCostOrError(maxCost)(f: Double => ValidationError) to specify a different error
// or maxCostZIO(maxCost)(f: Field => URIO[R, Double]) if your field calculation returns an effect
In the above example we have provided a couple different examples. For instance, we can add the directive to both types and
to fields. In this case the field resolver will override the type cost, however if there is no field cost then the type cost will be used.
We may also specify a "multipliers" argument when using arguments. This will match the argument names and use numeric argument values to multiply the base value.
In the above case this means that a query that specifies a limit
of 10
for the spokenLines
field will have the base field cost multipled by 10 resulting in a total cost
of execution of 1000
# OpenTelemetry Tracing
Caliban ships with support for OpenTelemetry tracing via integration with zio-telemetry (opens new window) in the caliban-tracing
package.
In order to use tracing, first add caliban-tracing
to your built.sbt
.
"com.github.ghostdogpr" %% "caliban-tracing" % "2.9.0"
Then add it to your schema as any other wrapper:
import caliban.tracing.TracingWrapper
import zio.telemetry.opentelemetry.Tracing
val api: GraphQL[Any] = ???
val tracedApi = api @@ TracingWrapper.traced
This will now make sure that any effect (ZIO or ZQuery) is measured as its own span, which makes it easy to spot potential optimizations (such as sequential loading) in your schema.
More or less like this:
query |----------------------------------------------|
field a |-------|
field b |-------|
nested field |-----------------------|
nested field a |-------------------|
nested field b |-------------------|
which makes it easy to spot that e.g field a and field b could be resolved in parallel.