Provides the application infrastructure for adding GraphQL support to a server application.
- GraphQL endpoint at
/app/graphql
:- UUID, java8 time, and threeten-extra Scalars
- Request Ids attached to the GraphQL response extensions
- Any guice bound Query, Subscription or Mutation Resolvers
- Supports websockets using the Apollo Protocol or the new GraphQL over WebSocket Protocol
- Supports the
GraphQL over Server-Sent Events Protocol
at
/app/graphql/stream
in both Single Connection Mode and Distinct Connections Mode - Supports subscriptions via coroutine Flows for any Resolvers that
return a
Flow<T>
orPublisher<T>
(Flow
preferred) - Supports Dropwizard Authentication Principals passed through to Resolvers via GraphQLContext
- Supports coroutine structured concurrency and cancellation of
POSTed GraphQL queries for Resolvers implemented as
suspend
functions
- Admin:
- GraphiQL available at
/admin/graphiql
- GraphiQL available at
Configuration is done primarily though Guice.
GraphQLApplicationModule
exposes binders for commonly bound objects. Additionally, some parameters are set though the HOCON config:
Default config:
graphql {
keepAliveIntervalSeconds: 15 # interval to send keepalive messages over websocket/sse protocols in seconds
idleTimeout: null # allows override of jetty websocket policy idleTimeout
maxBinaryMessageSize: null # allows override of jetty websocket policy maxBinaryMessageSize
maxTextMessageSize: null # allows override of jetty websocket policy maxTextMessageSize
checkAuthorization: false # whether to kick unauthenticated clients off websocket/sse sessions (allow by default)
connectionInitWaitTimeout: 15 # when using graphql-ws protocol, must receive the connection_init message within this many seconds
}
GraphQLApplicationModule
provides methods that expose multi-binders for configuring GraphQL resolvers. Any model classes must be added to
the graphQLPackagesBinder()
to allow GraphQL Kotlin
to use them. Query Resolver implementations can be added to the graphQLQueriesBinder()
, Subscriptions to
the graphQLSubscriptionsBinder()
, and Mutations to the graphQLMutationsBinder()
class ExampleApplicationModule : GraphQLApplicationModule() {
override fun configureApplication() {
// ...
graphQLPackagesBinder().addBinding().toInstance("com.example.api")
graphQLPackagesBinder().addBinding().toInstance("com.example.server.graphql")
graphQLQueriesBinder().addBinding().to<com.example.server.graphql.Query>()
graphQLMutationsBinder().addBinding().to<com.example.server.graphql.Mutation>()
graphQLSubscriptionsBinder().addBinding().to<com.example.server.graphql.Subscription>()
// ...
}
}
If Dropwizard Authentication is setup and an AuthFilter<*, *>
binding is provided per
the server README, GraphQL resolver
methods can receive the Principal
inside the GraphQL context map received from the DataFetchingEnvironment
using graphql-kotlin's get
extension method. When executing over the standard HTTP transport, resolver
methods can also access a jax-rs ResponseBuilder
object in order to affect the HTTP response
(useful when, for example, using a CookieTokenAuthFilter
for auth), and a ContainerRequestContext
object for reading information about the request itself.
class ExampleLoginMutations : Mutation {
fun login(dfe: DataFetchingEnvironment, email: String, pass: String): Boolean {
if (dfe.graphQlContext.get<Principal> == null) {
// log in!
val userSession = authenticate(email, pass)
if (userSession == null) {
return false
}
// cookie will be set in response
dfe.graphQlContext.get<ResponseBuilder>?.cookie(NewCookie("example-app-session-id", userSession.id))
} else {
// already logged in
}
return true
}
fun logout(dfe: DataFetchingEnvironment): Boolean {
if (dfe.graphQlContext.get<Principal> != null) {
// if sessionId not available in the Principal object, can grab from eg. HTTP header directly:
val sessionId = dfe.graphQlContext.get<ContainerRequestContext>()?.getHeaderString("session-header")
deleteSession(sessionId)
dfe.graphQlContext.get<ResponseBuilder>?.cookie(
NewCookie(
Cookie("example-app-session-id", ""),
null,
-1,
Date(0), // 1970
false,
false
)
)
}
return true
}
}
When using the WebSocket transport, credentials can be provided in HTTP headers/cookies of the upgrade request, or
provided in the payload of the connection_init
message.
When using the Server-Sent Events transport's Single Connection Mode, credentials provided in the HTTP headers/cookies of the reservation request are carried over to any requests made with the returned stream token.
A @GraphQLAuth
schema directive annotation is also provided to allow role based protection of GraphQL exposed fields. Any field in the
GraphQL schema that is annotated with @GraphQLAuth
will be restricted to being fetched by authenticated users. If an
Authorizer<Principal>
binding is provided per the
server README, then the @GraphQLAuth
directive allows for role based restriction of fields.
Note that any field annotated with @GraphQLAuth
will return null if auth fails, so must return a nullable type. Failed
auth will also result in Unauthorized/Forbidden errors in the GraphQL result.
class ExampleAuthedQuery : Query {
fun openField(): String {
return "anyone can access this"
}
@GraphQLAuth
fun protectedField(): String? {
return "only logged in users can access this"
}
@GraphQLAuth(roles = ["ADMIN", "SPECIAL"])
fun protectedField(): String? {
return "only logged in ADMIN and SPECIAL users can access this, assuming an Authorizer binding is provided"
}
}
The GraphQLContext
map also contains a CoroutineScope
. GraphQL resolver methods implemented as suspend
functions
will be run in this scope. A DELETE
call to
/app/graphql?id=${requestId}
will cancel the scope of a running query.
class ExampleSuspendQuery : Query {
suspend fun coroutineMethod(): String {
return coroutineScope {
// new scope whose parent scope is the in GraphQLContext map
val job1 = async {
// do stuff asynchronously
"value1"
}
val job2 = async {
// do more stuff asynchronously, concurrently
"value2"
}
"${job1.await()}:${job2.await()}"
}
}
}
Providing a binding for
KotlinDataLoaderRegistryFactoryProvider
(using GraphQLApplicationModule.dataLoaderRegistryFactoryProviderBinder()
) allows for providing DataLoader
s
that can be used by resolvers.
For implementing loaders,
CoroutineBatchLoader
and CoroutineMappedBatchLoader
allow for writing loader functions as suspend functions/coroutines. When subclassing these loader implementations,
the CoroutineScope
will use the same scope as in the GraphQLContext
map (see above section), and the
GraphQLContext
will be made available as the BatchLoaderEnvironment.context
. If using graphql-kotlin's
DataFetchingEnvironment.getValueFromDataLoader()
to load values in resolvers, the GraphQLContext
is also available through
BatchLoaderEnvironment.getGraphQLContext()
Note that resolver methods that call DataLoader
s CANNOT be suspend functions, but must be non-suspend
functions that return a CompletableFuture
(see upstream
graphql-java /
graphql-kotlin issues)
class ExampleListLoader<String, String>(contextMap: Map<*, Any>) : CoroutineBatchLoader(contextMap) {
override val dataLoaderName = "listLoader"
override suspend fun loadSuspend(
keys: List<String>,
environment: BatchLoaderEnvironment
): List<String> {
val context = environment.getContext<GraphQLContext>()
// ... can look at context objects like context.get<Principal> etc...
// ... can call suspend functions etc...
return keys.map { it.lowercase() }
}
}
class ExampleMapLoader<String, String>(contextMap: Map<*, Any>) : CoroutineBatchLoader(contextMap) {
override val dataLoaderName = "listLoader"
override suspend fun loadSuspend(
keys: Set<String>,
environment: BatchLoaderEnvironment
): Map<String, String> {
val context = environment.getContext<GraphQLContext>()
// ... can look at context objects like context.get<Principal> etc...
// ... can call suspend functions etc...
return keys.associateWith { it.lowercase() }
}
}
class ExampleDataLoaderRegistryFactoryProvider : KotlinDataLoaderRegistryFactoryProvider {
override fun invoke(request: GraphQLRequest, contextMap: Map<*, Any>): KotlinDataLoaderRegistryFactory {
return KotlinDataLoaderRegistryFactory(
ExampleListLoader(contextMap),
ExampleMapLoader(contextMap)
)
}
}
class ExampleBatchLoaderModule : GraphQLApplicationModule() {
override fun configureApplication() {
dataLoaderRegistryFactoryProviderBinder().setBinding()
.to<ExampleDataLoaderRegistryFactoryProvider>()
}
}
class ExampleDataLoaderQuery : Query {
// must be a non-suspend method that returns a CompleteableFuture!!!
fun notCoroutineMethod(dfe: DataFetchingEnvironment): CompletableFuture<String?> {
return dfe.getValueFromDataLoader("listLoader", "123")
}
}