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
sourceargument 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 ids 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-listingsendpoint - 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,nameandcategoryproperties, 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
sourceproperty on a large object called theDgsDataFetchingEnvironment. - The
DgsDataFetchingEnvironmentobject 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'
DataFetcherResultto 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.