Most GraphQL libraries for the JVM require developers to maintain two sources of truth for their GraphQL API, the schema and the corresponding code (data fetchers and types). Given the similarities between Kotlin and GraphQL, such as the ability to define nullable/non-nullable types, a schema should be able to be generated from Kotlin code without any separate schema specification. graphql-kotlin
builds upon graphql-java
to allow code-only GraphQL services to be built.
For information on GraphQL, please visit the GraphQL website.
For information on graphql-java
, please visit GraphQL Java.
Using a JVM dependency manager, simply link graphql-kotlin
to your project.
With Maven:
<dependency>
<groupId>com.expedia.www</groupId>
<artifactId>graphql-kotlin</artifactId>
<version>0.0.11</version>
</dependency>
With Gradle:
compile(group: 'com.expedia.www', artifact: 'graphql-kotlin', version: '0.0.11')
graphql-kotlin
provides a single function, toSchema
, to generate a schema from Kotlin objects.
import graphql.schema.GraphQLSchema
import com.expedia.graphql.toSchema
class Query {
fun getNumber() = 1
}
val schema: GraphQLSchema = toSchema(listOf(TopLevelObjectDef(Query())))
generates a GraphQLSchema
with IDL that looks like this:
type TopLevelQuery {
getNumber: Int!
}
The GraphQLSchema
generated can be used to expose a GraphQL API endpoint.
toSchema
uses Kotlin reflection to build a GraphQL schema from given classes using graphql-java
's schema builder. We don't just pass a KClass
though, we have to actually pass an object, because the functions on the object are transformed into the query or mutation's data fetchers. In most cases, a TopLevelObjectDef
can be constructed with just an object:
class Query {
fun getNumber() = 1
}
val def = TopLevelObjectDef(query)
toSchema(listOf(def))
In the above case, toSchema
will use query::class
as the reflection target, and query
as the data fetcher target.
In a lot of cases, such as with Spring AOP, the object (or bean) being used to generate a schema is a dynamic proxy. In this case, query::class
is not Query
, but rather a generated class that will confuse the schema generator. To specify the KClass
to use for reflection on a proxy, pass the class to TopLevelObjectDef
:
@Component
class Query {
@Timed
fun getNumber() = 1
}
val def = TopLevelObjectDef(query, Query::class)
toSchema(listOf(def))
More about writing schemas with Kotlin below. All examples below are based on the example project included in this repo.
toSchema
requires a list of TopLevelObjectDef
objects for both queries and mutations to be included in the GraphQL schema.
A query type is simply a Kotlin class that specifies fields, which can be functions or properties:
class WidgetQuery {
fun widgetById(id: Int): Widget? {
// grabs widget from a data source
}
}
class SimpleMutation: Mutation {
private val data: MutableList<String> = mutableListOf()
fun addToList(entry: String): MutableList<String> {
data.add(entry)
return data
}
}
will generate:
schema {
query: TopLevelQuery
mutation: TopLevelMutation
}
type TopLevelQuery {
widgetById(id: Int!): Widget
}
type TopLevelMutation {
addToList(entry: String!): [String!]!
}
Any public
functions defined on a query or mutation Kotlin class will be translated into GraphQL fields on the object type. toSchema
will recursively use Kotlin reflection to generate all object types, fields, arguments and enums.
For the most part, graphql-kotlin
can directly map most Kotlin "primitive" types to standard GraphQL scalar types:
Kotlin Type | GraphQL Type |
---|---|
kotlin.Int |
Int |
kotlin.Long |
Long |
kotlin.Short |
Short |
kotlin.Float |
Float |
kotlin.Double |
Float |
kotlin.BigInteger |
BigInteger |
kotlin.BigDecimal |
BigDecimal |
kotlin.Char |
Char |
kotlin.String |
String |
kotlin.Boolean |
Boolean |
graphql-kotlin
also ships with a few extension scalar types:
By default, graphql-kotlin
uses Kotlin reflections to generate all schema objects. If you want to apply custom behavior to the objects, you can define custom scalars and expose it to your schema using com.expedia.graphql.schema.hooks.SchemaGeneratorHooks
. Example usage
class CustomSchemaGeneratorHooks: NoopSchemaGeneratorHooks() {
override fun willGenerateGraphQLType(type: KType): GraphQLType? = when (type.classifier as? KClass<*>) {
UUID::class -> graphqlUUIDType
else -> null
}
}
val graphqlUUIDType = GraphQLScalarType("UUID",
"A type representing a formatted java.util.UUID",
object: Coercing<UUID, String> { ... }
)
Once the scalars are registered you can use them anywhere in the schema as regular objects.
Both kotlin.Array
and kotlin.collections.List
are automatically mapped to the GraphQL List
type. Type arguments provided to Kotlin collections are used as the type arguments in the GraphQL List
type.
class SimpleQuery {
fun generateList(): List<Int> {
val random = Random()
return (1..10).map { random.nextInt(100) }.toList()
}
}
The above Kotlin class would produce the following GraphQL schema:
schema {
query: TopLevelQuery
}
type TopLevelQuery {
generateList: [Int!]!
}
Both GraphQL and Kotlin have nullable as a marked typed so we can generated null safe schemas.
class SimpleQuery {
fun generateNullableNumber(): Int? {
val num = Random().nextInt(100)
return if (num < 50) num else null
}
fun generateNumber(): Int = Random().nextInt(100)
}
The above Kotlin code would produce the following GraphQL schema:
schema {
query: TopLevelQuery
}
type TopLevelQuery {
generateNullableNumber: Int
generateNumber: Int!
}
Any public fields on the returned objects will be exposed as part of the schema unless they are explicitly marked to be ignored with @GraphQLIgnore
annotation. Documentation and deprecation information is also supported. For more details about different annotations see sections below.
@GraphQLDescription("A useful widget")
data class Widget(
@property:GraphQLDescription("The widget's value that can be null")
val value: Int?,
@property:Deprecated(message = "This field is deprecated", replaceWith = ReplaceWith("value"))
@property:GraphQLDescription("The widget's deprecated value that shouldn't be used")
val deprecatedValue: Int? = value,
@property:GraphQLIgnore
val ignoredField: String? = "ignored",
private val hiddenField: String? = "hidden"
)
The above Kotlin code would produce the following GraphQL object type:
"""A useful widget"""
type Widget {
"""DEPRECATED: The widget's deprecated value that shouldn't be used"""
deprecatedValue: Int @deprecated(reason: "This field is deprecated, replace with value")
"""The widget's value that can be null"""
value: Int
Method arguments are automatically exposed as part of the arguments to the corresponding GraphQL fields.
class SimpleQuery{
@GraphQLDescription("performs some operation")
fun doSomething(@GraphQLDescription("super important value") value: Int): Boolean = true
}
The above Kotlin code will generate following GraphQL schema:
type TopLevelQuery {
"""performs some operation"""
doSomething(
"""super important value"""
value: Int!
): Boolean!
}
This behavior is true for all arguments except for the GraphQL context objects. See section below for detailed information about @GraphQLContext
.
Enums are automatically mapped to GraphQL enum type.
enum class MyEnumType {
ONE,
TWO
}
Above enum will be generated as following GraphQL object
enum MyEnumType {
ONE
TWO
}
Functions returning interfaces will automatically expose all the types implementing this interface that are available on the classpath.
interface Animal {
val type: AnimalType
fun sound(): String
}
enum class AnimalType {
CAT,
DOG
}
class Dog: Animal {
override val type: AnimalType
get() = AnimalType.DOG
override fun sound() = "bark"
fun doSomethingUseful(): String = "something useful"
}
class Cat: Animal {
override val type: AnimalType
get() = AnimalType.CAT
override fun sound() = "meow"
fun ignoreEveryone(): String = "ignore everyone"
}
class PolymorphicQuery {
fun animal(type: AnimalType): Animal? = when (type) {
AnimalType.CAT -> Cat()
AnimalType.DOG -> Dog()
else -> null
}
}
Code above will produce the following GraphQL code
interface Animal {
type: AnimalType!
sound: String!
}
enum AnimalType {
CAT
DOG
}
type Cat implements Animal {
type: AnimalType!
ignoreEveryone: String!
sound: String!
}
type Dog implements Animal {
type: AnimalType!
doSomethingUseful: String!
sound: String!
}
type TopLevelQuery {
animal(
type: AnimalType!
): Animal
}
Unions are not supported.
TBD
graphql-kotlin
ships with a number of annotation classes to allow you to enhance your GraphQL schema for things that can't be directly derived from Kotlin reflection.
All GraphQL servers have a concept of a "context". A GraphQL context contains metadata that is useful to the GraphQL server, but shouldn't necessarily be part of the GraphQL query's API. A prime example of something that is appropriate for the GraphQL context would be trace headers for an OpenTracing system such as Haystack. The GraphQL query itself does not need the information to perform its function, but the server itself needs the information to ensure observability.
The contents of the GraphQL context vary across GraphQL applications. For JVM based applications, graphql-java
provides a context interface that can be extended.
Simply add @GraphQLContext
to any argument to a field, and the GraphQL context for the environment will be injected. These arguments will be omitted by the schema generator.
class ContextualQuery {
fun contextualQuery(
value: Int,
@GraphQLContext context: MyGraphQLContext
): ContextualResponse = ContextualResponse(value, context.myCustomValue)
}
The above query would produce the following GraphQL schema:
schema {
query: TopLevelQuery
}
type TopLevelQuery {
contextualQuery(
value: Int!
): ContextualResponse!
}
Note that the @GraphQLContext
annotated argument is not reflected in the GraphQL schema.
There are two ways to ensure the GraphQL schema generation omits fields when using Kotlin reflection:
The first is by marking the field as private
scope. The second method is by annotating the field with @GraphQLIgnore
.
class SimpleQuery {
@GraphQLIgnore
fun notPartOfSchema() = "ignore me!"
private fun privateFunctionsAreNotVisible() = "ignored private function"
fun doSomething(
value: Int
): Boolean {
return true
}
}
The above query would produce the following GraphQL schema:
schema {
query: TopLevelQuery
}
type TopLevelQuery {
doSomething(value: Int!): Boolean!
}
Note that the public method notPartOfSchema
is not included in the schema.
Since Javadocs are not available at runtime for introspection, graphql-kotlin
includes an annotation class @GraphQLDescription
that can be used to add schema descriptions to any GraphQL schema element:
@GraphQLDescription("A useful widget")
data class Widget(
@property:GraphQLDescription("The widget's value that can be null")
val value: Int?
)
class WidgetQuery: Query {
@GraphQLDescription("creates new widget for given ID")
fun widgetById(@GraphQLDescription("The special ingredient") id: Int): Widget? = Widget(id)
}
The above query would produce the following GraphQL schema:
schema {
query: TopLevelQuery
}
"""A useful widget"""
type Widget {
"""The widget's value that can be null"""
value: Int
}
type TopLevelQuery {
"""creates new widget for given ID"""
widgetById(
"""The special ingredient"""
id: Int!
): Widget
Note that the data class property is annotated as @property:GraphQLDescription
. This is due to the way kotlin maps back to the java elements. If you do not add the property
prefix the annotation is actually on the contructor argument and will not be picked up by the generator.
GraphQL schemas can have fields marked as deprecated. Instead of creating a custom annotation, graphql-kotlin
just looks for the kotlin.Deprecated
annotation and will use the message for the deprecated reason.
class SimpleQuery {
@Deprecated(message = "this query is deprecated", replaceWith = ReplaceWith("shinyNewQuery"))
@GraphQLDescription("old query that should not be used always returns false")
fun simpleDeprecatedQuery(): Boolean = false
@GraphQLDescription("new query that always returns true")
fun shinyNewQuery(): Boolean = true
}
The above query would produce the following GraphQL schema:
schema {
query: TopLevelQuery
}
type TopLevelQuery {
"""DEPRECATED: old query that should not be used always returns false"""
simpleDeprecatedQuery: Boolean! @deprecated(reason: "this query is deprecated, replace with shinyNewQuery")
"""new query that always returns true"""
shinyNewQuery: Boolean!
}
While you can deprecate any fields/methods in your code, GraphQL only supports deprecation directive on the queries, mutations and output types. All deprecated objects will have "DEPRECATED" prefix in their description.
Schemas are often evoling over time and while some feature are getting removed others can be added. Some of those new features may be experimental meaning they are still being tested out and can change without any notice. Functions annotated with @GraphQLExperimental
annotations will have set @experimental
directive. You can access those directives during instrumentation to provide some custom logic. Experimental methods will also have EXPERIMENTAL
prefix in their description.
class SimpleQuery {
/*
* NOTE: currently GraphQL directives are not exposed in the schema through introspection but they
* are available on the server that exposes the schema
*/
@GraphQLExperimental("this is an experimental feature")
@GraphQLDescription("echoes back the msg")
fun experimentalEcho(msg: String): String = msg
}
Will translate to
type TopLevelQuery {
"""EXPERIMENTAL: echoes back the msg"""
experimentalEcho(msg: String!): String!
}
Note that GraphQL directives are currently not available through introspection. See: graphql/graphql-spec#300 and graphql-java/graphql-java#1017 for more details.