Overview
We can query for a listing's amenities, but only through the listing(id: ID)
root field, not through featuredListings
. What's going on?
In this lesson, we will:
- Learn about resolver chains
- Learn about the
source
argument of a datafetcher method - Pass local context from one datafetcher method to the next
Examining the data source response
Let's examine the response from our GET /featured-listings
endpoint again. Open a new browser tab, and paste in the URL below.
https://rt-airlock-services-listing.herokuapp.com/featured-listings
The array that we get back contains the listings objects that we expect; but we'll notice that one property in each listing object is different! From the /featured-listings
endpoint, a listing's list of "amenities"
is an array that contains just the identifier for each amenity. No additional data!
This is a common pattern in REST APIs. Imagine if the amenities list for each listing included the full data for each amenity. That would make for a very large response, to have a list of listings and a list of full amenities for each listing! The problem would compound if the endpoint decided to return even more featured listings than the three we already have.
To make sure that we can return featured listings along with all the data for their amenities, we'll need to make one more additional call to the REST API. In this case, to a new endpoint: GET /listings/{listing_id}/amenities
.
The next question becomes: where in our code will we make that call?
Accounting for missing amenities
To account for our missing amenities
, we could update our featuredListings
datafetcher method. We might give it some extra logic to reach out to GET /listings/{listing_id}
to fetch this extra data.
@DgsQuerypublic List<ListingModel> featuredListings() throws IOException {List<ListingModel> listings = listingService.featuredListingsRequest();// traverse listings for each listing id?// make a follow-up request to /listings/{listing_id}/amenities// then recompose each ListingModel object with the new amenities data?return listings;}
Well, this would work; but it would mean that every time we query for featuredListings
, we would always make an additional network call to the REST API, whether the query asked for a listing's amenities
or not.
So instead, we're going to make use of the resolver chain.
Following the resolver chain
A resolver chain is the order in which datafetcher methods (known in some other frameworks as resolver functions) are called when resolving a particular GraphQL operation. It can contain a sequential path as well as parallel branches.
Let's take an example from our project. This GetListing
operation retrieves the title of a listing.
query GetListing($listingId: ID!) {listing(id: $listingId) {title}}
When resolving this operation, the GraphQL server will first call the Query.listing()
datafetcher method, which returns a Listing
type, then the Listing.title()
method which returns a String
type and ends the chain.
Note: We didn't need to define a separate datafetcher method for Listing.title
because the title
property can be returned directly from the instance returned by Query.listing
.
Each datafetcher method in this chain passes their return value down to the next method as a property on a large object called the DgsDataFetchingEnvironment
.
The DgsDataFetchingEnvironment
argument is optional for a datafetcher method to use, but it contains a lot of information about the query being executed, the server's context, as well as the parameter we're concerned with: source
.
In this example, the Listing.title()
datafetcher method could use the DgsDataFetchingEnvironment
's source
property to access the Listing
object the Query.listing()
method returned.
Let's look at another GraphQL operation.
query GetListingAmenities($listingId: ID!) {listing(id: $listingId) {titleamenities {name}}}
This time, we've added more fields and asked for each listing's list of amenities, specifically their name
values.
Our resolver chain grows, adding a parallel branch.
Because Listing.amenities
returns a list of potentially multiple amenities, this resolver might run more than once to retrieve each amenity's name.
Following the trail of the resolver, Listing.amenities()
would have access to Listing
as the source
, just as Amenity.name()
would have access to the Amenity
object as the source
.
If our operation didn't include the amenities
field (like the first example we showed), then the Listing.amenities()
method would never be called!
The Listing.amenities
datafetcher method
Now that we know what a resolver chain is, we can use it to determine the best place to insert the additional REST API call for a listing's amenities.
Remember, we were debating including it in the Query.featuredListings
datafetcher method, where it would be called every single time we query for featured listing data, even when the operation doesn't include the amenities
field:
@DgsQuerypublic List<ListingModel> featuredListings() throws IOException {// Not the best place to make an extra network call!return listingService.featuredListingsRequest();}
Instead, we'll add a new datafetcher method to ListingDataFetcher
class—one that's specifically responsible for fulfilling the Listing.amenities
field from our schema.
For our last two datafetcher methods, we used the @DgsQuery
annotation. That's because the methods we defined were responsible for providing data for fields on our schema's Query
type.
This time, however, we want to define a method that's responsible for fulfilling data for a field on the Listing
type. To do this, we'll need to reach for a different annotation: @DgsData
.
type Listing {id: ID!# ... other Listing fields"The amenities available for this listing"amenities: [Amenity!]! # We want to define a datafetcher method for THIS field!}
The @DgsData
annotation
The @DgsData
annotation lets us specify the GraphQL type and field we're defining the method for. Here's what that will look like for the Listing.amenities
field:
@DgsData(parentType="Listing", field="amenities")
And if we give our method the same name as the field, amenities
, we can omit the field="amenities"
specification here in the annotation.
Let's define this method in our ListingDataFetcher
class now.
@DgsData(parentType="Listing")public void amenities() {}
And we'll import the new @DgsData
annotation at the top, along with our server's generated Amenity
type, which we'll use momentarily.
import com.netflix.graphql.dgs.DgsData;import com.example.listings.generated.types.Amenity;
Returning Listing.amenities
Right away, we can update the return type for our method to be a List
of Amenity
types.
@DgsData(parentType="Listing")public List<Amenity> amenities() {// TODO}
Now, it's time to make use of that source
argument we mentioned earlier in the lesson. The source
, remember, is the listing instance that we're resolving amenities for; it's the value returned by the previous datafetcher method.
We haven't defined separate datafetcher methods for a Listing
type's id
, title
, and so on, so our server will look for these property on each ListingModel
instance that is returned when we query for a listing or featured listings. Our Listing.amenities
field, however, now has its own datafetcher method. Rather than just checking the ListingModel
instance for its amenities
property, our server will rely on this method to provide the final say on what data is returned for a listing's amenities.
To make its job easier, this method receives the ListingModel
it's resolving amenities for as the source
property on DgsDataFetchingEnvironment
. This lets us access and use ListingModel
properties—such as its id
—to make our follow-up request possible.
Let's import DgsDataFetchingEnvironment
at the top of the file.
// ... other importsimport com.netflix.graphql.dgs.DgsDataFetchingEnvironment;
Next, we'll add it as an argument to our method called dfe
.
@DgsData(parentType="Listing")public List<Amenity> amenities(DgsDataFetchingEnvironment dfe) {// TODO}
To access the source
property, we'll call dfe.getSource()
. We'll receive this value as a ListingModel
type called listing
.
public List<Amenity> amenities(DgsDataFetchingEnvironment dfe) {ListingModel listing = dfe.getSource();}
Next, we'll access the id
property from the listing
.
public List<Amenity> amenities(DgsDataFetchingEnvironment dfe) {ListingModel listing = dfe.getSource();String id = listing.getId();}
So, how do we determine whether the ListingModel
instance we're resolving already has all the amenity data attached to it? We know that when we query for a single listing, we get all the amenity data from the REST API; in contrast, when we query for featuredListings
, we only get a list of amenity id
s for each listing; name
and category
are null! They weren't included in the REST API response.
// Amenities on an instance of ListingModel when returned by the Query.listing datafetcherListing{id='listing-1',amenities='[Amenity{id='am-2',category='Accommodation Details',name='Towel'},Amenity{id='am-10',category='Space Survival',name='Oxygen'},Amenity{id='am-11',category='Space Survival',name='Prepackaged meals'}]'}// Amenities on an instance of ListingModel when returned by the Query.featuredListings datafetcherListing{id='listing-1',amenities='[Amenity{id='am-2',category='null',name='null'},Amenity{id='am-10',category='null',name='null'},Amenity{id='am-11',category='null',name='null'}]}
Local context in DGS
DGS gives us a way to pass down custom context between our datafetchers: using the DataFetcherResult
type. We'll use this type's localContext
property to help us determine whether we're resolving amenities for a ListingModel
that already has them set on the class, or whether we need to make a follow-up request.
First, let's import the DataFetcherResult
type, along with Java's Map
utility, at the top of the file.
import graphql.execution.DataFetcherResult;import java.util.Map;
Then, we'll wrap the listing
method's return type with DataFetcherResult
.
@DgsQuerypublic DataFetcherResult<ListingModel> listing(@InputArgument String id) {return listingService.listingRequest(id);}
Right now, we'll see an error in our code: the listing
method is no longer returning the type that we've indicated. Instead of returning the data directly, we'll capture it in a variable ListingModel
called listing
.
@DgsQuerypublic DataFetcherResult<ListingModel> listing(@InputArgument String id) {ListingModel listing = listingService.listingRequest(id);}
To return a DataFetcherResult
type, we'll need to build an instance of the type, with additional properties attached. The syntax will look something like this:
return DataFetcherResult.<T>newResult().data() // pass in the data to return.localContext() // attach local context.build();
Let's update our method using this syntax. For the value of T
, the type variable, we'll give the original return type of our method, ListingModel
. We'll pass the listing
variable into data()
.
return DataFetcherResult.<ListingModel>newResult().data(listing).localContext().build();
The localContext
property is what we'll use in this datafetcher method to indicate to the next datafetcher in the chain that the ListingModel
we return already has its full amenity data.
To do so, we'll pass in a new Map
with a property hasAmenityData
that we set to true
.
return DataFetcherResult.<ListingModel>newResult().data(listing).localContext(Map.of("hasAmenityData", true)).build();
Now let's do the same for featuredListings
. The syntax will look the same, except for the localContext
value, where we'll set the hasAmenityData
property to false
.
@DgsQuerypublic DataFetcherResult<List<ListingModel>> featuredListings() throws IOException {List<ListingModel> listings = listingService.featuredListingsRequest();return DataFetcherResult.<List<ListingModel>>newResult().data(listings).localContext(Map.of("hasAmenityData", false)).build();}
Retrieving context with getLocalContext
Our Query.listing
and Query.featuredListings
datafetcher methods are both setting some custom context; now, let's set up the syntax that lets us retrieve that context from another datafetcher method.
Scroll back to the Listing.amenities
datafetcher method.
We'll use the dfe
parameter again, this time calling getLocalContext
. We'll receive this as a Map<String, Boolean>
type we'll call localContext
.
Map<String, Boolean> localContext = dfe.getLocalContext();
We'll check whether localContext
exists and hasAmenityData
on localContext
is true
, and if so, we can safely assume that our ListingModel
instance already has its amenities
property set and fully populated.
Map<String, Boolean> localContext = dfe.getLocalContext();if (localContext != null && localContext.get("hasAmenityData")) {return listing.getAmenities();}
Now if our local context doesn't have hasAmenityData
set to true
, then we'll assume that a follow-up request for complete amenity data is necessary.
Map<String, Boolean> localContext = dfe.getLocalContext();if (localContext != null && localContext.get("hasAmenityData")) {return listing.getAmenities();}// TODO: FOLLOW-UP REQUEST HERE
Let's build the call in ListingService
that can request amenity data.
Requesting amenities
Now let's jump into our datasources/ListingService
file and build out this method. First, import the Amenity
type from our generated
folder.
// ... other importsimport com.example.listings.generated.types.Amenity;
We'll need this method to return a List
of Amenity
types, since that's what our Listing.amenities
field expects to return. It will receive the id
of the listing that we want to retrieve amenities for, a String
we'll call listingId
.
public List<Amenity> amenitiesRequest(String listingId) {// TODO}
We'll start this request with the same boilerplate we've used previously: calling get
on our class' client
instance, and chaining on the uri
. We're reaching out to the /listings/{listing_id}/amenities
endpoint, passing in the listingId
as the value for {listing_id}
.
client.get().uri("/listings/{listing_id}/amenities", listingId)
Next, we'll chain on retrieve
and body
.
client.get().uri("/listings/{listing_id}/amenities", listingId).retrieve().body()
Because the response from this endpoint contains a list of objects that match our Amenity
class, we can first return the whole response as a JsonNode
, then call our mapper.readValue
method again.
Next, we'll map through each of the objects in the array, creating an instance of Amenity
out of each. Try it out, following the same steps we implemented in the featuredListingsRequest
method. When you're ready, compare your method against the final state below!
public List<Amenity> amenitiesRequest(String listingId) throws IOException {JsonNode response = client.get().uri("/listings/{listing_id}/amenities", listingId).retrieve().body(JsonNode.class);if (response != null) {return mapper.readValue(response.traverse(), new TypeReference<List<Amenity>>() {});}return null;}
Finishing the datafetcher
Let's wrap up our datafetcher by calling our new method. Back in ListingDataFetcher
, in the amenities
method, we'll use the listing's id
as an argument and return the results of calling listingService.amenitiesRequest
.
@DgsData(parentType="Listing")public List<Amenity> amenities(DgsDataFetchingEnvironment dfe) throws IOException {ListingModel listing = dfe.getSource();String id = listing.getId();Map<String, Boolean> localContext = dfe.getLocalContext();if (localContext != null && localContext.get("hasAmenityData")) {return listing.getAmenities();}return listingService.amenitiesRequest(id);}
Just one last thing to take care of! Our ListingService
request for amenities could result in a possible thrown exception, so let's account for this exception.
@DgsData(parentType = "Listing")public List<Amenity> amenities(DgsDataFetchingEnvironment dfe) throws IOException {// ... amenity fetching logic!}
Explorer time: round 2!
Server restarted, and running with the latest changes? Great! Now when we jump back over to Sandbox and run the query for featuredListings
and its list of amenities, we get what we asked for!
query GetFeaturedListings {featuredListings {idtitledescriptionamenities {idnamecategory}}}
👏👏👏
Comparing with the REST approach
Time to put on our product app developer hat again! Let's compare what this feature would have looked like if we had used REST instead of GraphQL.
If we had used REST, the app logic would have included:
- Making the HTTP GET call to the
/featured-listings
endpoint - Making an extra HTTP GET call for each listing in the response to
GET /listings/{listing_id}/amenities
. Waiting for all of those to resolve, depending on the number of listings, could take a while. Plus, this introduces the common N+1 problem. - Retrieving just the
id
,name
andcategory
properties, discarding the rest of the response. Depending on the response, this could mean we fetch a lot of data that's not used! And big responses come with a cost.
With GraphQL, the client writes a short and sweet, clean, readable operation, and the data returns in exactly the shape they specified, no more, no less!
All the logic of extracting the data, making extra HTTP calls, and filtering for which fields are needed are all done on the GraphQL server side. We still have the N+1 problem, but it's on the server-side (where response and request speeds are more consistent and generally faster) instead of the client-side (where network speeds are variable and inconsistent).
Note: We can address the N+1 problem on the GraphQL side using Data Loaders, which we cover in an upcoming course.
Key takeaways
- A resolver chain is the order in which datafetcher functions are called when resolving a particular GraphQL operation. It can contain a sequential path as well as parallel branches.
- Each datafetcher method in this chain passes their return value to the next method as the
source
property on a large object called theDgsDataFetchingEnvironment
. - The
DgsDataFetchingEnvironment
object is an optional parameter that all datafetcher methods have access to. It contains the return value of the previous datafetcher called in the resolver chain, as well as other data about the query being executed. - We can use DGS'
DataFetcherResult
to customize the value a datafetcher method returns, attaching local context that the next datafetcher method in the chain can access.
Up next
Feeling confident with queries? It's time to explore the other side of GraphQL: mutations.
Share your questions and comments about this lesson
This course is currently in
You'll need a GitHub account to post below. Don't have one? Post in our Odyssey forum instead.