Experimental GraphQL
This blog post is a follow up on the initial introductory post, Supersonic Subatomic GraphQL, and here we will explore more features, some that is experimental, that we hope to eventually move to the MicroProfile GraphQL Specification (based on your feedback !)
We will look at the following:
- Operational Context – Optimize your downstream processes.
- Cache – Caching your endpoints.
- Asynchronous – Concurrent execution of multiple requests or sources.
- Batch – Solving N+1.
- Generics support.
- Events and custom execution.
- Transformation and mapping.
- Build tools – Maven and Gradle support.
All source code is available here: github.com/phillip-kruger/graphql-experimental
Operational Context
The Context Object is an experimental Object that can be injected anywhere in your code, downstream from your @GraphQLApi
.
It’s in the api
module in SmallRye GraphQL, with the intention to eventually move this up to the MicroProfile GraphQL Api.
Example:
We have a Person GraphQL Endpoint, that uses some service to get the person from where ever it is stored.
The endpoint:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 | @GraphQLApi public class PersonEndpoint { @Inject PersonService personService; @Query public List<Person> getPeople(){ return personService.getAllPeople(); } @Query public Person getPerson( int id){ return personService.getPerson(id); } } |
A Person is a basic POJO, that can have multiple relationships, that in turn has a Person. So making a call to the database to get a person, can end up retuning more people, depending on the number of relationships. In our example, we have Person 1 that has a Spouse
, Person 2.
Now let’s assume that PersonService
makes a call to a database or some other storage to get the data. We can now inject the context object to get details on the request, and optimise our call:
01 02 03 04 05 06 07 08 09 10 11 12 13 | @ApplicationScoped public class PersonService { @Inject Context context; public Person getPerson( int id){ // Use context to get more information on the query // Let's print out the context here and see what we have System.out.println(context); // Get the person from the datastore here. } } |
Let’s do a Query
to get the name and surname of Person 1:
1 2 3 4 5 6 | { person(id: 1 ){ names surname } } |
So what can you get from context ?
There are a few things we can get:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 | executionId = 30337360 request = { "query" : "{\n person(id:1){\n names\n surname\n }\n}" , "variables" : null } operationName = null operationTypes = [Query] parentTypeName = Query variables = null query = { person(id: 1 ){ names surname } }, fieldName = person selectedFields = [ "names" , "surname" ] source = null arguments = {id= 1 } path = /person |
What we probably want to know is which fields have been requested, so that we can do a better database query.
So the fieldName (person
) and the selectedFields (names
,surname
) is what we need.
A more complex GraphQL Request, will then lead to a more complex datasource query, example, if we want to know the relationships we would do:
01 02 03 04 05 06 07 08 09 10 11 12 13 | { person(id: 1 ){ names surname relations{ relationType person{ names surname } } } } |
That will give us this in the Context
selectedFields:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 | [ "names" , "surname" , { "relations" :[ { "relationType" :[ ] }, { "person" :[ "names" , "surname" ] } ] } ] |
Context in source methods
Let’s add a field to person using @Source
and see what the context can give us then. First we will add a service that fetches the exchange rate from an api (exchangeratesapi.io). This allows us to add the exchange rate for that person against some currency.
In Java we add this Source
method:
1 2 3 4 5 | public ExchangeRate getExchangeRate( @Source Person person, CurencyCode against){ Map<CurencyCode, Double> map = exchangeRateService.getExchangeRates(against); Double rate = map.get(person.curencyCode); return new ExchangeRate(person.curencyCode, against, rate); } |
Now we can query that (ExchangeRate
) field:
1 2 3 4 5 6 7 8 9 | { person(id: 1 ){ names surname exchangeRate(against:GBP){ rate } } } |
When we Inject
and print the context in the ExchangeRateService
we now get:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 | executionId = 17333236733 request = { "query" : "{\n person(id:1){\n names\n surname\n exchangeRate(against:GBP){\n rate\n }\n }\n}" , "variables" : null } operationName = null operationTypes = [Query] parentTypeName = Person variables = null query = { person(id: 1 ){ names surname exchangeRate(against:GBP){ rate } } } fieldName = exchangeRate selectedFields = [ "rate" ] source = com.github.phillipkruger.user.model.Person @7929ad0a arguments = {against=GBP} fieldName = exchangeRate path = /person/exchangeRate |
Note that the fieldName is now exchangeRate
and the selectedFields is ["rate"]
. You will also note that the source field is populated with the person.
Cache
Another question that comes up regularly is how can you cache your endpoint results. As an example, let’s say the Exchange Rate information can be updated daily, so we do not want to make a call to the exchangeratesapi.io for every call.
You can just use the caching that comes with Quarkus! Simply include the cache extension:
1 2 3 4 | <dependency> <groupId>io.quarkus</groupId> <artifactId>quarkus-cache</artifactId> </dependency> |
And add the @CacheResult
annotation to your method:
1 2 3 4 5 6 | @CacheResult (cacheName = "exchange-rate-cache" ) public ExchangeRate getExchangeRate( @Source Person person, CurencyCode against){ Map<CurencyCode, Double> map = exchangeRateService.getExchangeRates(against); Double rate = map.get(person.curencyCode); return new ExchangeRate(person.curencyCode, against, rate); } |
Read more about caching in Quarkus here: quarkus.io/guides/cache
Asynchronous
Now, let’s add another service that returns the weather conditions for a city:
01 02 03 04 05 06 07 08 09 10 11 12 | @GraphQLApi public class TravelEndpoint { @Inject WeatherService weatherService; @Query public Weather getWeather(String city){ return weatherService.getWeather(city); } } |
Let’s say this person is traveling to London, you can now do something like this:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 | { person(id: 1 ){ names surname exchangeRate(against:GBP){ rate } } weather(city: "London" ){ description min max } } |
At the moment the person and weather query will execute sequentially, and there is no real reason that this should be the case. We can get the weather at the same time that we get the person.
Let’s change the java code:
1 2 3 4 | @Query public CompletableFuture<Person> getPerson( int id){ return CompletableFuture.supplyAsync(() -> personService.getPerson(id)); } |
and
1 2 3 4 | @Query public CompletableFuture<Weather> getWeather(String city){ return weatherService.getWeather(city); } |
Now person and weather are being fetched concurrently.
Let’s say this person actually wants to travel to London and New York, we can do something like this:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 | { person(id: 1 ){ names surname gbp:exchangeRate(against:GBP){ rate } usd:exchangeRate(against:USD){ rate } } uk:weather(city: "London" ){ description min max } us:weather(city: "New York" ){ description min max } } |
We can now change the code to also fetch the exchange rates concurrently:
1 2 3 | public CompletableFuture<ExchangeRate> getExchangeRate( @Source Person person, CurencyCode against){ return CompletableFuture.supplyAsync(() -> exchangeRateService.getExchangeRate(against,person.curencyCode)); } |
Batch
If you want to get ALL people, and you are including a field (like exchangeRate
) with a Source
method, it means that for every person, we will call the getExchangeRate
method. Depending on the number of people, that could be a lot of calls. So you might rather want to do a batch source method.
This will allow you to get all the people in one method and do one call to get their exchange rates.
So, let’s change the getExchangeRate
method to take a List
of person and return a List
of ExchangeRate
:
01 02 03 04 05 06 07 08 09 10 11 | public List<ExchangeRate> getExchangeRate( @Source List<Person> people, CurencyCode against){ Map<CurencyCode, Double> map = exchangeRateService.getExchangeRates(against); List<ExchangeRate> rates = new ArrayList<>(); for (Person person : people){ Double rate = map.get(person.curencyCode); rates.add( new ExchangeRate(person.curencyCode, against, rate)); } return rates; } |
Note: Above will still work on getPerson
method where there is only one person.
Doing a query on all people:
1 2 3 4 5 6 7 8 9 | { people{ names surname exchangeRate(against:GBP){ rate } } } |
This will call the getExchangeRate
method with all people.
Generics
It’s the year 2050 and we need to extend our travel service to also cater for aliens. Let’s add a generic Being
type:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 | public class Being<T> { private T being; public Being() { } public Being(T being) { this .being = being; } public T getBeing() { return being; } public void setBeing(T being) { this .being = being; } } |
And now change the Endpoint to allow people and alien queries:
1 2 3 4 5 6 7 8 9 | @Query public Being<Person> getPerson( int id){ return new Being<>(personService.getPerson(id)); } @Query public Being<Alien> getAlien( int id){ return new Being<>(alienService.getAlien(id)); } |
We can then query both human and alien beings:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 | { person(id: 1 ){ being{ names surname } } alien(id: 1 ){ being{ type from } } } |
Events and custom execution
Events are used internally when you enable integration with MicroProfile Metrics, MicroProfile OpenTracing and Bean Validation, but you can also take part in these events. These are all CDI Events and can be used with the @Observes
annotation.
While building the schema
When we scan the classpath for annotations and types, we build up a model of all the operations. You can manipulate this model by taking part in the create operation event:
1 2 3 4 | public Operation createOperation( @Observes Operation operation) { // Here manipulate operation return operation; } |
Just before the final schema is built, after scanning all annotations and after the above event, you can take part and contribute to the schema: This exposes the underlying graphql-java
implementation details, and can be useful when you want to do things that are not yet implemented in SmallRye GraphQL, like subscriptions for instance:
1 2 3 4 | public GraphQLSchema.Builder beforeSchemaBuild( @Observes GraphQLSchema.Builder builder) { // Here add you own, in example a subscription return builder; } |
While running a request
In this example request:
01 02 03 04 05 06 07 08 09 10 | { person(id: 1 ){ names surname exchangeRate(against:USD){ rate base } } } |
the request flow is as follows:
- The Execution service gets the request.
- The person is being fetched with a
datafetcher
. - Your CDI bean (
@GraphQLApi
) method (getPerson
) is being invoked. - The exchange rate is being fetched, passing the above person as an argument.
- Your CDI bean (
@GraphQLApi
) method (getExchangeRate
) is being invoked. - Data is being returned.
You can receive events on all of these points:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 | public void beforeExecute( @Observes @BeforeExecute Context context) { System.err.println( ">>>>> Received beforeExecute event [" + context.getQuery() + "]" ); } public void beforeDataFetch( @Observes @BeforeDataFetch Context context) { System.err.println( ">>>>> Received beforeDataFetch event [" + context.getQuery() + "]" ); } public void beforeInvoke( @Observes InvokeInfo invokeInfo) { System.err.println( ">>>>> Received beforeInvoke event [" + invokeInfo.getOperationMethod().getName() + "]" ); } public void afterDataFetch( @Observes @AfterDataFetch Context context) { System.err.println( ">>>>> Received afterDataFetch event [" + context.getQuery() + "]" ); } public void afterExecute( @Observes @AfterExecute Context context) { System.err.println( ">>>>> Received afterExecute event [" + context.getQuery() + "]" ); } |
You can also get events when an error occurs:
1 2 3 4 5 6 7 | public void errorExecute( @Observes @ErrorExecute ErrorInfo errorInfo) { System.err.println( ">>>>> Received errorExecute event [" + errorInfo.getT() + "]" ); } public void errorDataFetch( @Observes @ErrorDataFetch ErrorInfo errorInfo) { System.err.println( ">>>>> Received errorDataFetch event [" + errorInfo.getT() + "]" ); } |
Using the Execution Service directly
The default assumed behavior is to interact with your endpoint via HTTP, you can however inject the ExecutionService
yourself and execute requests.
As an example, lets do a request that gets all the names of all the people on startup:
1 2 3 4 5 | { people{ names } } |
We can now do this:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 | @ApplicationScoped public class StartupService { @Inject ExecutionService executionService; public void init( @Observes StartupEvent event){ JsonObjectBuilder builder = Json.createObjectBuilder(); builder.add( "query" , ALL_NAMES); JsonObject request = builder.build(); JsonObject response = executionService.execute(request); System.err.println( ">>>>> " + response); } private static final String ALL_NAMES = "{\n" + "people{\n" + " names\n" + " }\n" + "}" ; } |
Transformation and mapping
By default, Date and Number values can be transformed using JsonB Formats
1 2 3 4 5 6 7 8 9 | public class Person { public String name; @JsonbDateFormat ( "dd.MM.yyyy" ) private Date birthDate; @JsonbNumberFormat ( "#0.00" ) public BigDecimal salary; } |
MicroProfile GraphQL Specification maps the relevant Java types to a GraphQL Scalar. You can change the mapping of an existing field to map to another Scalar type like this:
1 2 | @ToScalar (Scalar.Int. class ) Long id; // This usually maps to BigInteger |
In the GraphQL Schema this will now map to an int
.
You can also add an Object that should transform to a Scalar
Type and not a complex object, example you might have an Email
Object, but do not want to use a complex type in GraphQL, and rather map this to a String
:
To do this your Email
POJO needs to implement the toString
method and have a constructor that takes a String, or a static Email fromString(String s)
method, or a setValue(String value)
method.
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | public class Email { private String value; public Email() { } public Email(String value) { this .value = value; } public String getValue() { return value; } public void setValue(String value) { this .value = value; } @Override public String toString() { return value; } } |
You can then use this as a field on your Response and add the @ToScalar
annotation, i.e. person:
1 2 | @ToScalar (Scalar.String. class ) Email email; // This usually maps to a complex object |
Build tools
Lastly, support has been added to generate the schema on build using maven
(or gradle
).
Example, in maven
you can add this to your pom.xml
:
01 02 03 04 05 06 07 08 09 10 11 | <plugin> <artifactId>smallrye-graphql-maven-plugin</artifactId> <groupId>io.smallrye</groupId> <executions> <execution> <goals> <goal>generate-schema</goal> </goals> </execution> </executions> </plugin> |
and the generated schema will be stored in target/generated/
.
Published on Java Code Geeks with permission by Phillip Krüger, partner at our JCG program. See the original article here: Experimental GraphQL Opinions expressed by Java Code Geeks contributors are their own. |