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
parent
argument of a resolver
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 their amenities, we'll need to make one additional call per listing 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
resolver function. We might give it some extra logic to reach out to GET /listings/{listing_id}/amenities
to fetch this extra data.
async getFeaturedListings(): Promise<Listing[]> {const listings = await this.get<Listing[]>("featured-listings");// for each listing ID, request this.get<Amenity[]>(`listings/{id}/amenities`)// Then map each set of amenities back to its listingreturn 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 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()
resolver function, which returns a Listing
type, then the Listing.title()
resolver which returns a String
type and ends the chain.
Note: We didn't need to define a separate resolver for Listing.title
because the title
property can be returned directly from the instance returned by Query.listing
.
Each resolver passes the value it returns to the next function down, using the resolver's parent
argument. Hence: the resolver chain!
Remember, a resolver has access to a number of parameters. So far, we've used contextValue
(to access our ListingAPI
data source) and args
(to get the id
for a listing). parent
is another such parameter!
In the example above, Query.listing()
returns a Listing
object, which the Listing.title()
resolver would receive as its parent
argument.
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 parent
, just as Amenity.name()
would have access to the Amenity
object as the parent
.
If our operation didn't include the amenities
field (like the first example we showed), then the Listing.amenities()
resolver would never be called!
Implementing the Listing.amenities
resolver
So far, we've defined resolver functions exclusively for fields that exist on our Query
type. But we can actually define a resolver function for any field in our schema.
Let's create a resolver function whose sole responsibility is to return amenities
data for a given Listing
object.
Jump into resolvers.ts
. Here, we'll add a new entry, just below the Query
object, called Listing
.
export const resolvers: Resolvers = {Query: {// query resolvers, featuredListings and listing},Listing: {// TODO},};
Inside of the Listing
object, we'll define a new resolver function called amenities
. Right away we'll return null
so that TypeScript continues to compile as we explore our function's parameters.
Listing: {amenities: (parent, args, contextValue, info) => {return null;}},
We know the value of the parent
argument—by following the resolver chain, we know that this resolver will receive the Listing
object that it's attempting to return amenities
for. (We won't need the args
or info
parameters here, so we'll replace args
with _
and remove info
from the function signature.)
Let's log out the value of parent
.
Listing: {amenities: (parent, _, contextValue) => {console.log(parent);return null;}},
Then we'll return to Sandbox to run a query that will call this resolver function.
query GetFeaturedListings {featuredListings {idtitledescriptionamenities {idnamecategory}}}
When we run this operation, the Response panel will show "Cannot return null for non-nullable field Listing.amenities."
, but that's ok, we're more interested in investigating parent
right now.
We can see from the value we logged out in the terminal that parent
is the Listing
object that we queried for—along with all of its properties. Now within the Listing.amenities
resolver, let's clean up our log and return statements. Then we'll destructure parent
for its id
and amenities
properties, and contextValue
for its dataSources
.
Listing: {amenities: ({ id, amenities }, _, { dataSources }) => {// TODO}},
There are two scenarios we need our Listing.amenities
resolver to handle.
- If we've queried for a single listing, we'll already have full amenity data available on the
parent
argument. In this case, we can return theamenities
directly. - If our resolver's
parent
argument comes fromQuery.featuredListings
, however, we'll have only an array of amenity IDs. In this case, we'll need to make a follow-up request to the REST API!
Listing: {amenities: ({ id, amenities }, _, { dataSources }) => {// If `amenities` contains full-fledged Amenity objects, return them// Otherwise make a follow-up request to /listings/{listing_id}/amenities}},
Let's build out our new ListingAPI
method for amenity data, and then we'll return to handle these two paths.
The getAmenities
method
In listing-api.ts
, let's bring in our Amenity
type from types.ts
.
import { Listing, Amenity } from "../types";
We'll give our class a new method: getAmenities
. This method accepts a single argument, a listingId
, which is a string
, and returns a Promise
that returns a list of Amenity
types.
getAmenities(listingId: string): Promise<Amenity[]> {// TODO}
Inside the function, we'll call this.get
, which returns a list of Amenity
types, and pass in the endpoint we need.
getAmenities(listingId: string): Promise<Amenity[]> {return this.get<Amenity[]>(`listings/${listingId}/amenities`);}
Finishing the resolver
Then, back in our Listing.amenities
resolver, we'll make a call to the listingAPI.getAmenities
method, passing in our Listing
's id
.
amenities: ({ id, amenities }, _, { dataSources }) => {return dataSources.listingAPI.getAmenities(id);};
We've taken care of the scenario when we need to request follow-up amenity data. Now, let's update our resolver to first check whether we already have full amenity data available on the parent
argument.
We've provided a utility that helps us do just that. Jump into src/helpers.ts
and uncomment the code there. We'll use the validateFullAmenities
function exported here: it takes an amenities
parameter (a Amenity[]
type), and checks to see whether at least some of the objects in the array provided contain a name
property.
export const validateFullAmenities = (amenityList: Amenity[]) =>amenityList.some(hasOwnPropertyName);
Note: We made the arbitrary choice to check for the presence of a name
property (to determine whether we're dealing with complete amenity data), but we could have just as well used the category
property instead to perform the check.
Back in resolvers.ts
, let's import the validateFullAmenities
function.
import { validateFullAmenities } from "./helpers";
Down in our Listing.amenities
resolver, we'll first check whether our amenities
contain all their properties. If so, we'll return them directly. Otherwise, we can make our follow-up request for additional amenity data.
amenities: ({ id, amenities }, _, { dataSources }) => {return validateFullAmenities(amenities)? amenities: dataSources.listingAPI.getAmenities(id);},
Trying out our queries
To see when our ListingAPI
's getAmenities
method gets called for follow-up amenity data, let's add a console log inside the method in listing-api.ts
.
getAmenities(listingId: string): Promise<Amenity[]> {console.log("Making a follow-up call for amenities with ", listingId);return this.get<Amenity[]>(`listings/${listingId}/amenities`)}
Now we can return to Explorer at http://localhost:4000 and try out a few queries.
First, a query for an individual listing.
query GetListing($listingId: ID!) {listing(id: $listingId) {amenities {idname}}}
And in the Variables panel:
{"listingId": "listing-1"}
When we run this query, we should see that the response for a single listing and its amenities hasn't changed. Our resolver simply returns amenities
from the parent
argument.
Now let's try the same thing for our featured listings query. Open up a new tab in the Explorer, and paste in the following query.
query GetFeaturedListings {featuredListings {idtitledescriptionamenities {idnamecategory}}}
Now when we run this query, we'll still see amenity data—but our terminal shows that three additional requests have been made to populate the remaining properties for each listing's set of amenities!
Making a follow-up call for amenities with listing-1Making a follow-up call for amenities with listing-2Making a follow-up call for amenities with listing-3
Practice
Use the following schema and GraphQL query to answer the multiple choice question.
type Query {featuredPlanets: [Planet!]!}type Planet {name: String!galaxy: Galaxy!}type Galaxy {name: String!totalPlanets: Int!dateDiscovered: String}
query GetFeaturedPlanetsGalaxies {featuredPlanets {galaxy {name}}}
GetFeaturedPlanetsGalaxies
query above?Key takeaways
- A resolver chain is the order in which resolver functions are called when resolving a particular GraphQL operation.
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.