Friends of OpenJDK Today

Exposing your data using Spring GraphQL

September 19, 2023

Author(s)

  • Simon Verhoeven

    Simon Verhoeven is a Java software engineer that's interested in sharing experience, and always up for an interesting conversation.

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:

  1. queries: used to define which data should be fetched, and which fields thereof should be included
  2. 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:
  • We can change the supported file type(s) by changing: spring.graphql.schema.file-extensions
  • The default schema location can be adapted using spring.graphql.schema.locations, we can even include files in our dependencies by using the classpath*: prefix
  • field introspection is enabled by default since tools like GraphiQL require it. If you do not want to expose your schema, set spring.graphql.schema.introspection.enabled to false

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

graphiql.png

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);
}
  1. reference to the bookInfo.graphql file in our resources folder
  2. passing in the variable we want to use for the call
  3. in case your request has no response data use executeAndVerify rather than execute to check whether there were no errors in the response, or executeSubscription 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.

References

Topics:

Related Articles

View All

Author(s)

  • Simon Verhoeven

    Simon Verhoeven is a Java software engineer that's interested in sharing experience, and always up for an interesting conversation.

Comments (0)

Your email address will not be published. Required fields are marked *

Highlight your code snippets using [code lang="language name"] shortcode. Just insert your code between opening and closing tag: [code lang="java"] code [/code]. Or specify another language.

Save my name, email, and website in this browser for the next time I comment.

Subscribe to foojay updates:

https://foojay.io/feed/
Copied to the clipboard