In this article, we’ll take an introductory look at how we can use Spring GraphQL in our Java applications. GraphQL is a query language (hence the QL) that in conjunction with a framework such as Spring GraphQL
can be used to efficiently manage our data, and even reuse existing services.
It has 2 core concepts:
- queries: used to define which data should be fetched, and which fields thereof should be included
- mutations: used to manage our dataset
It’s an alternative to REST, SOAP, or gRPC, and supports calls over HTTP, WebSocket and RSocket.
We can use it to query & mutate our data, and in the case of Spring Webflux/WebSocket/RSocket to set up subscriptions.
Note: | Spring GraphQL is the successor of GraphQL Java Spring |
Feel free to check out the code from this repository to more easily follow along.
Setup
Dependencies
To get started we just need the following dependencies in our pom.xml:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-graphql</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.graphql</groupId> <artifactId>spring-graphql-test</artifactId> <scope>test</scope> </dependency> </dependencies>
Note: | We’re using Spring MVC here, but we could also use web/webflux/rsocket here (see for reference: possible starters) |
Schema resources
We’ll create a folder src/main/resources/graphql/
where we’ll put our .graphqls
or .gqls
file(s).
Spring Boot will automatically pick up the files placed here.
These files define our graph’s data types, the relationships between them and possible operations.
Our basic schema will look like this:
type Query { bookById(id: ID): Book } type Book { id: ID name: String! pageCount: Int summary: String publicationDate: String author: Author! } type Author { id: ID firstName: String! lastName: String! shortBio: String! linkedinUrl: String! }
We define a top-level Query
type (every GraphQL service has to have one, mutations are optional), which contains the exposed operations and its arguments. Here we can see we’re exposing a bookById
query which expects an ID
to be passed in, and will return a Book
type.
Below that we can see our Book
and Author
type with their fields. In this case, we’re using the default scalar types, and the !
marks the fields as non-null.
More information on how to define a Schema
can be found on the GraphQL schema page.
Note: |
|
Properties
We’ll also be enabling the graphical interactive GraphQL IDE (GraphiQL), by adding:
spring.graphql.graphiql.enabled=true
to our application.properties
.
This allows us to easily interact with & develop GraphQL APIs.
And since we want to use Subscriptions in GraphIQL we’ll also add:
spring.graphql.websocket.path=/graphql
Using the default GraphiQL path.
Note: | This can be adapted by configuring spring.graphql.path |
Registering extra scalar types
In some cases, you might need more than the default Scalar types that we mentioned earlier.
Let us add a simple Article:
type Article { id: ID title: String! publicationDate: Date! }
As you can see our publicationDate
is of type Date
which is not known by default.
To resolve this we can add the graphql-java-extended-scalars
dependency to our project,
<dependency> <groupId>com.graphql-java</groupId> <artifactId>graphql-java-extended-scalars</artifactId> <version>21.0</version> </dependency>
and then we can add the following to our @Configuration
to register the Date
scalar:
@Bean public RuntimeWiringConfigurer runtimeWiringConfigurer() { return runtimeWiringConfigurer -> runtimeWiringConfigurer .scalar(ExtendedScalars.Date); }
And finally, also add this Scalar to our schema.graphqls
scalar Date @specifiedBy(url:"https://tools.ietf.org/html/rfc3339")
And then when we query for this Article, we’ll get our publicationDate
back properly.
Controller configuration
Spring for GraphQL allows us to define handler methods using annotations in @Controller
components.
The handler methods are registered as DataFetcher
s through RuntimeWiring.builder
.
Since we’re using the Spring boot starter, we don’t need to do anything special, but if you’re not you’ll need to add this RuntimeWiringConfiguration
to GraphQLSource.Builder
. (see for reference)
We can use @SchemaMapping
to define our handler methods & specify the type name, and field name. or leave it out in which case it’ll use the simple class name of the source object & the method nome.
However, we can also use the meta annotations to make our life a bit easier, since these preset the typeName for us.
These are:
@QueryMapping
@MutationMapping
@SubscriptionMapping
Querying data
For our earlier book query, we can add:
@QueryMapping public Book bookById(@Argument String id) { return Book.getById(id); }
Which makes use of the implicit mapping.
Now in the case of our Book
, we’ll also need to do a little bit extra. Because our Book
itself only contains the authorId
, but in the response we want to return the Author
immediately, to avoid our client having to do an extra round trip, and to aggregate the data.
We can resolve this by adding this to our controller:
@SchemaMapping public Author author(Book book) { return Author.getById(book.authorId()); }
Which will act as the DataFetcher
for the Author
field.
Which we can then test using the following query in GraphiQL:
{ authorById(id: "a535fe2f-7d06-41bd-bbff-c802e42a8b06") { id firstName lastName shortBio linkedinUrl } }
If we add the following to our schema file:
authorById(id: ID): Author
We can set up an explicit mapping using the following, in case we don’t want to call our function authorById
@QueryMapping("authorById") public Author findAuthor(@Argument String id) { return Author.getById(id); }
Note that here we’ve explicitly added authorById
to our @QueryMapping
While both approaches are valid, the value annotation does encourage a higher level of abstraction and leaves us free to rename our method names without breaking the integration.
Mutations
We use @MutationMapping
for these, and jut like with @QueryMapping
our method name/annotation value must match the operation name.
We’d love to be able to add some of our favourite authors, so we’ll add the following to our schema.graphqls
type Mutation { addAuthor(firstName: String!, lastName: String!, shortBio: String!): Author }
As you can see, we expect the first name, last name & a short bio for the Author to be passed in, and we’ll get an Author
response.
This aligns with:
@MutationMapping("addAuthor") public Author createAuthor( @Argument String firstName, @Argument String lastName, @Argument String shortBio ) { Author author = new Author(UUID.randomUUID().toString(), firstName, lastName, shortBio, ""); Author.addAuthor(author); return author; }
As you can see, our inputs are annotated with @Argument
.
Note: | @Argument does not have a required flag, nor the option to specify a default value. These can be specified in the GraphQL schema, and are enforced by GraphQL Java.If the distinction between Null and Omitted is important, one can instead declare an ArgumentValue parameter which is a container for the resulting value alongside a flag to indicate whether the input was omitted. |
We can then create a new one using:
mutation addAuthor { addAuthor( firstName: "Venkat" lastName: "Subramaniam" shortBio: "Venkat Subramaniam is an award-winning author, founder of Agile Developer, Inc., and an instructional professor at the University of Houston." ) { id firstName lastName } }
Subscriptions
In case we want to stay up to date, we can also set up a subscription.
Note: | Keep in mind that we need WebSocket/RSocket transports for this support. |
Say we want to get a stream of new books we can add this to our schema:
type Subscription { notifyNewBook: Book }
Then in our controller, we can add:
@SubscriptionMapping("notifyNewBook") public Flux<Book> newBooks() { ... }
And we’ll get an incoming stream of new books.
We can just do:
subscription { notifyNewBook { id isbn name } }
Testing
So it’s quite easy to set up our GraphQL API, but what about the testing?
Spring makes it easy for us to test our application using GraphQLTester
which offers us an easy way to test agnostic of the underlying transport.
Note: | To perform requests through a client we need one of the following extensions: HttpGraphQLTester/WebSocketGraphQLTester/RSocketGraphQLTester To perform server-side testing without a client we need either the ExecutionGraphQLServiceTester or WebGraphQLServiceTester extension. |
It offers us a fluent API to write our test.
We can pass in a document (thank you text blocks!), or pass in a document filename ending with .graphql
or .gql
under graphql-test/
in our resources
folder.
Testing a request
Let’s start with a basic request test, where we check the expected output. (we could also )
@Test void bookById() { this.graphQlTester .documentName("bookInfo") //1 .variable("id", "a8950574-a399-4f42-a168-31f59c0079a5") //2 .execute() //3 .path("bookById") .matchesJson(CLEAN_CODE_PAYLOAD); }
- reference to the
bookInfo.graphql
file in our resources folder - passing in the variable we want to use for the call
- in case your request has no response data use
executeAndVerify
rather thanexecute
to check whether there were no errors in the response, orexecuteSubscription
for Subscriptions.
Testing a mutation
The flow for a mutation is basically the same as for a query:
final Author author = this.graphQlTester .document(document) .execute() .path("addAuthor") .entity(Author.class) .get();
Testing a subscription
Subscriptions are a bit different in that we invoke executeSubscription
instead of execute
and then use StepVerifier
to inspect the Flux.
To start we’ll need to add the reactor-test
dependency:
<dependency> <groupId>io.projectreactor</groupId> <artifactId>reactor-test</artifactId> <version>3.5.10</version> <scope>test</scope> </dependency>
Then we can get our Flux using:
final var bookFlux = this.graphQlTester.document(document) .executeSubscription() .toFlux("notifyNewBook", Book.class);
And let’s add an easy-to-test to check we received Kent Beck’s 9 books. (the API itself offers us a lot more options!)
final var bookFlux = this.graphQlTester.document(document) .executeSubscription() .toFlux("notifyNewBook", Book.class);
Handling errors
If we use verify, any errors in the "errors" key will lead to an Assertion failure.
For example with this test case:
@Test void bookById_verify() { this.graphQlTester .documentName("bookInfo") .variable("id", "a8950574-a399-4f42-a168-31f59c0079a5") .execute() .errors() .verify() .path("data.bookById.name") .entity(String.class) .isEqualTo("Clean Code"); }
If we want to suppress specific error(s) we can filter these out using:
.errors() .filter(error -> ...) .path("data.bookById.name")
We can also apply this filtering on the builder level, so they apply to all our tests using:
WebGraphQlTester.builder(client) .errorFilter(error -> ...) .build()
Additionally, we can also inspect the errors through a Consumer
using satisfy
, which will also mark them as filtered so that we can inspect the data in the response.
.errors() .satisfy(responseErrors -> ...) .verify()
The other way around, in case we want to verify that an error exists we can use expect
instead.
This will lead to an assertion error if the expected error is not present.
.errors() .expect(error -> ...) .verify()
(Dis)advantages
GraphQL has its advantages, and disadvantages over REST, and one can even use both in the sample application.
Advantages:
- flexible: the client can specify the required fields
- higher decoupling from API changes
- less expensive operations (reduced payload size and data can be aggregated so fewer round trips)
- high discoverability given the APIs are introspectable, so clients can query the schema to find the available types & fields
- real-time data using subscriptions, without the need for polling
- it is possible us to observe the requested field, thus allowing us to more easily deprecate seldom used fields which can be a much bigger challenge with REST.
Disadvantages:
- no native file upload support
- no native support for web caching
- harder to cache given its flexible nature
- the flexible nature can also lead to complexity in managing the schema and efficient query resolution
In case of data flexibility is needed/over-under-fetching is an issue/real-time data is needed/mobile use-cases GraphQL is a good fit.
However, if the data structure is stable, caching is critical, resource-based models or simple CRUD calls there’s certainly nothing wrong with rest.
At the end of the day you need to evaluate which fits your use-cases the best, and maybe even use a mix of both.
Extra
Introspection report - detect mismatches
We can make use of this report to easily detect whether all our schema fields have corresponding data fetchers.
To enable it we’ll need to enable introspection in our application.properties
spring.graphql.schema.introspection.enabled
And add a bean to our configuration to handle the reporting, for example to log it:
@Bean GraphQlSourceBuilderCustomizer inspectionCustomizer() { return schemaResourceBuilder -> schemaResourceBuilder.inspectSchemaMappings(reportConsumer -> log.info(reportConsumer.toString())); }
When we then start our application we’ll get a report akin to the following in our console:
Unmapped fields: {} Unmapped registrations: {} Skipped types: []
Warning: | Introspected should be disabled in production as it exposes quite a bit of information about your API which might not be desireable. |
File uploads
Whilst the GraphQL protocol is focused on textual data, there is the informal graphql-multipart-request-spec which allows file upload over HTTP. Keep in mind that this does lead to certain issues as documented on the Appolo GraphQL blog. If you would like to use the spec in your application you can do so using: multipart-spring-graphql
Data access
GraphQL isn’t tied to a specific database/storage engine. However, there are certainly a lot of interesting & convenient things to be done using the Spring data QueryDSL extension.
It allows us a flexible and typesafe approach for our query predicates.
Spring Data allows us to use our QueryDSL/Query by Example repositories for a DataFetcher
, which will build a QueryDSL Predicate
from GraphQL arguments. We can also to mark our repositories with @GraphQlRepository
for automated detection and GraphQL Query registration.