10. Resolver chains
20m

Overview

We can for a playlist's tracks, but only through the playlist(id: ID) root , not through featuredPlaylists. What's going on?

In this lesson, we will:

  • Learn about chains
  • Learn about the source of a datafetcher method

Examining the data source response

Let's examine the response from our GET /browse/featured-playlists endpoint.

For each of our featured playlists, we do have access to a tracks property—but it looks a bit different from what we expect.

"tracks": {
"href": "https://api.spotify.com/...",
"total": 5
}

Instead of a list of track objects (like what we get for a playlist from the GET /playlists/{playlist_id} endpoint), we get a single object with two properties: total, the total number of tracks available, and href, a URL for the endpoint where we can retrieve the full list of track objects.

This is a common pattern in REST APIs. Imagine if the response did include the full list of track objects. That would make for a very large response, to have a list of playlists and a list of tracks for each playlist.

Instead, to make sure we can return featured playlists along with their tracks, we'll need to make one more additional call to the REST API. In this case, the GET /playlists/{playlist_id}/tracks endpoint.

The next question becomes: where in our code will we make that call?

Accounting for missing tracks

This is our first problem, and what's causing the error we see in our terminal and in Sandbox. The playlist data returned for our featured playlists only contains a playlist's total number of tracks and a separate href property we can use to make a follow-up request.

Let's account for this in our MappedPlaylist class; we'll attempt to set the class' tracks property only if the JSON response's "tracks" property contains actual track objects. In other words, we'll check to see if the "items" property exists.

MappedPlaylist
@JsonSetter("tracks")
public void mapTracks(JsonNode tracks) throws IOException {
ObjectMapper mapper = new ObjectMapper();
JsonNode items = tracks.get("items");
if (items != null) {
List<MappedTrack> trackList = mapper.readValue(items.traverse(), new TypeReference<>() {});
this.setTracks(trackList.stream().map(MappedTrack::getTrack).toList());
}
}

This preserves the functionality for traversing the JSON response when we request a single playlist.

But we still need to make an extra call to fetch the tracks for each of our featured playlist objects. This means reaching out with each playlist ID to the GET /playlists/{playlist_id}/tracks endpoint to request the list of tracks. And we're back to our original question: where in our code will we make that call?

We could add it here in the mapTracks method, inside of an else block.

if (items != null) {
List<MappedTrack> trackList = mapper.readValue(items.traverse(), new TypeReference<>() {});
this.setTracks(trackList.stream().map(MappedTrack::getTrack).toList());
} else {
// Should we make a follow-up call to GET /playlists/{playlist_id}/tracks here?
}

But that would mean that whenever we for playlist or featuredPlaylists, we would always make an additional network call to the REST API, even when the didn't ask for a playlist's tracks!

So instead, we're going to make use of the 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 . It can contain a sequential path as well as parallel branches.

Let's take an example from our project. This GetPlaylist retrieves the name of a playlist.

query GetPlaylist($playlistId: ID!) {
playlist(id: $playlistId) {
name
}
}

When resolving this , the will first call the Query.playlist() datafetcher method, then the Playlist.name() method which returns a string type and ends the chain.

Resolver chain in a diagram

Note: We didn't need to define a separate datafetcher method for Playlist.name because the name property can be returned directly from the instance returned by Query.playlist.

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 is optional for a datafetcher method to use, but it contains a lot of information about the being executed, the server's context, as well as the parameter we're concerned with: source.

In this example, the Playlist.name() datafetcher method could use the DgsDataFetchingEnvironment's source property to access the Playlist object the Query.playlist() method returned.

Let's look at another .

query GetPlaylistTracks($playlistId: ID!) {
playlist(id: $playlistId) {
name
tracks {
uri
}
}
}

This time, we've added more and asked for each playlist's list of tracks, specifically their uri values.

Our chain grows, adding a parallel branch.

Resolver chain in a diagram

Because Playlist.tracks returns a list of potentially multiple tracks, this might run more than once to retrieve each track's URI.

Following the trail of the , Playlist.tracks() would have access to Playlist as the source, just as Track.uri() would have access to the Track object as the source.

If our didn't include the tracks (like the first example we showed), then the Playlist.tracks() method would never be called!

The Playlist.tracks datafetcher method

Now that we know what a chain is, we can use it to determine the best place to insert the additional REST API call for a playlist's tracks.

Remember, we were debating including it in the MappedPlaylist class' mapTracks method, where it would be called every single time the class was instantiated, even when the doesn't include the tracks :

if (items != null) {
List<MappedTrack> trackList = mapper.readValue(items.traverse(), new TypeReference<>() {});
this.setTracks(trackList.stream().map(MappedTrack::getTrack).toList());
}
// Probably not the best place to call GET /playlists/{playlist_id}/tracks!

Instead, we'll add a new datafetcher method to PlaylistDataFetcher—one that's specifically responsible for fulfilling the Playlist.tracks 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 on our schema's Query type.

This time, however, we want to define a method that's responsible for fulfilling data for a on the Playlist type.

schema.graphqls
"A curated collection of tracks designed for a specific activity or mood."
type Playlist {
# ... other Playlist fields
"The tracks of the playlist."
tracks: [Track!]! # We want to define a datafetcher method for THIS field!
}

The DgsData annotation

Instead of using the @DgsQuery annotation, which lets us specify datafetcher methods specific to the Query type in our schema, we'll use @DgsData. This annotation lets us specify the type and we're defining the method for. Here's what that will look like for the Playlist.tracks :

@DgsData(parentType="Playlist", field="tracks")

And if we give our method the same name as the , tracks, we can omit the field="tracks" specification here in the annotation.

Let's define this method in our PlaylistDataFetcher class now.

PlaylistDataFetcher
// ... other class methods
@DgsData(parentType="Playlist")
public void tracks() {
// TODO
}

And we'll import the new @DgsData annotation at the top, along with our server's generated Track type, which we'll use momentarily.

import com.netflix.graphql.dgs.DgsData;
import com.example.soundtracks.generated.types.Track;

Returning Playlist.tracks

Right away, we can update the return type for our method to be a List of Track types.

public List<Track> tracks() {
// TODO
}

Now, it's time to make use of that source we mentioned earlier in the lesson. The source, remember, is the playlist instance that we're resolving tracks for; it's the value returned by the previous datafetcher method.

We haven't defined separate datafetcher methods for a Playlist type's id, name, or description, so our server will look for these property on each MappedPlaylist instance that is returned when we for a playlist or featured playlists.

Our Playlist.tracks , however, now has its own datafetcher method. Rather than checking the MappedPlaylist instance for its tracks property, our server will rely on this method to provide the data we need for a playlist's tracks.

To make its job easier, this method receives the MappedPlaylist it's resolving tracks for as the source property on DgsDataFetchingEnvironment. This lets us access and use MappedPlaylist properties—such as its id—to make our follow-up request possible.

Let's import DgsDataFetchingEnvironment at the top of the file.

PlaylistDataFetcher
// ... other imports
import com.netflix.graphql.dgs.DgsDataFetchingEnvironment;

Next, we'll add it as an to our method called dfe.

PlaylistDataFetcher
@DgsData(parentType="Playlist", field="tracks")
public List<Track> tracks(DgsDataFetchingEnvironment dfe) {
// TODO
}

To access the source property, we'll call dfe.getSource(). We'll receive this value as a MappedPlaylist type called playlist.

PlaylistDataFetcher
public List<Track> tracks(DgsDataFetchingEnvironment dfe) {
MappedPlaylist playlist = dfe.getSource();
}

We know that source is an instance of MappedPlaylist, because the previous datafetcher method in the chain (Query.featuredPlaylists) returns a List of MappedPlaylist types. To provide tracks for each of these instances, this datafetcher method is invoked for each object in the list. The same is true when the previous datafetcher is Query.playlist and only a single MappedPlaylist instance is returned.

Next, we'll access two properties from the playlist: its id and its tracks.

PlaylistDataFetcher
public List<Track> tracks(DgsDataFetchingEnvironment dfe) {
MappedPlaylist playlist = dfe.getSource();
String id = playlist.getId();
List<Track> tracks = playlist.getTracks();
}

Now, there are two scenarios that our datafetcher method takes into account.

  1. The first is when we're returning data for a single playlist. In that case, we'll have track data set as the tracks property on the MappedPlaylist instance. In this case, we can simply return the tracks property that we've accessed.
  2. In the second case, when we're returning data for featured playlists, we just need each playlist's ID to make a follow-up request for track data.

Let's take care of the first scenario. We'll check for the existence of tracks, and if they exist, we'll return them directly.

if (tracks != null) {
return tracks;
}

But if our MappedPlaylist instance doesn't have tracks, we'll need to use its id property to make a follow-up request to GET /playlists/{playlist_id}/tracks.

Let's build this out in the else block.

Requesting tracks

Let's stub out a call to our class' instance of SpotifyClient. We haven't yet built out the method responsible for retrieving tracks, which we'll call tracksRequest, but we'll do that next. Using the playlist's id as an , we'll return the results of calling spotifyClient.tracksRequest.

if (tracks != null) {
return tracks;
} else {
return spotifyClient.tracksRequest(id);
}

Now let's jump into our datasources/SpotifyClient file and build out this method. First, import the Track type from our generated folder, along with the List utility.

SpotifyClient
// ... other imports
import com.example.soundtracks.generated.types.Track;
import java.util.List;

We'll need this method to return a List of Track types, since that's what our Playlist.tracks expects to return. It will receive the id of the playlist that we want to retrieve tracks for, a String we'll call playlistId.

SpotifyClient
public List<Track> tracksRequest(String playlistId) {
// 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 /playlists/{playlist_id}/tracks endpoint, passing in the playlistId as the value for {playlist_id}.

client
.get()
.uri("/playlists/{playlist_id}/tracks", playlistId)

Next, we'll chain on retrieve and body.

client
.get()
.uri("/playlists/{playlist_id}/tracks", playlistId)
.retrieve()
.body()

But what class should we use to receive the results from the API? Let's check out the results when we call this endpoint with a playlist_id such as 6LB6g7S5nc1uVVfj00Kh6Z.

API response
{
"href": "https://api.spotify.com...",
"items": [
{
"added_at": "2024-01-17T22:39:23Z",
// ... other properties
"track": {
"id": "2epbL7s3RFV81K5UhTgZje",
"name": "Lemon Tree"
// ... other track properties
}
},
{
"added_at": "2024-01-17T22:39:44Z",
// ... other properties
"track": {
"id": "2E90kpMSYmIcBMTLD3DGGm",
"name": "Fresh Squeeze"
// ... other track properties
}
}
]
}

What we get back is a top-level object with two properties, href and items. Inside of items is an array of track objects, each with a "track" property like we saw in the last lesson. To access that "items" property and turn it into a List of MappedTrack types we can actually work with, we'll first need to create a wrapper class that we can use to receive our API results and pluck out the properties we want.

In our method, let's update our body call with a class we're about to create: TrackCollection.

SpotifyClient
.body(TrackCollection.class)

The TrackCollection class

At the top of the file, we'll add the import for TrackCollection. We'll create this file in the models package next.

SpotifyClient
import com.example.soundtracks.models.TrackCollection;

Let's build out this class. In the models package, add a new file, TrackCollection. Here's some boilerplate to get us started.

models/TrackCollection
package com.example.soundtracks.models;
import com.example.soundtracks.generated.types.Track;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.annotation.JsonSetter;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public class TrackCollection {
List<Track> tracks;
public List<Track> getTracks() {
return this.tracks;
}
}

We expect our TrackCollection to hold a tracks property, which is a List of Track types. To actually set this property on our class, we'll need to give it a method that pulls data from that "items" property on the object. Let's add a method called setTracks, which is annotated as the JsonSetter for the "items" property. It will receive that array of raw track objects as a JsonNode we'll call trackItems.

TrackCollection
@JsonSetter("items")
public void setTracks(JsonNode trackItems){
// TODO
}

We want to traverse this array, casting each of the objects that we encounter into an instance of MappedTrack. Just like we did in PlaylistCollection, we'll use ObjectMapper to read the JSON data and turn it into useable Java classes. Calling readValue can throw an exception, so we'll also update our method signature with throws IOException.

TrackCollection
public void setTracks(JsonNode trackItems) throws IOException {
ObjectMapper mapper = new ObjectMapper();
List<MappedTrack> trackList = mapper.readValue(trackItems.traverse(), new TypeReference<>() {});
}

Next, we'll call the getTrack method on each of the MappedTrack instances in the list. This will return each object upcast as a Track object, instead of a MappedTrack.

TrackCollection
public void setTracks(JsonNode trackItems) throws IOException {
ObjectMapper mapper = new ObjectMapper();
List<MappedTrack> trackList = mapper.readValue(trackItems.traverse(), new TypeReference<>() {});
this.tracks = trackList.stream().map(MappedTrack::getTrack).toList();
}

Great! This accepts the list of raw track objects in the "items" array, unpacks them and returns them as clean Track instances. Now there's just one little update to make to our new method, so let's jump back to datasources/SpotifyClient.

Finalizing the SpotifyClient method

SpotifyClient
public List<Track> tracksRequest(String playlistId) {
client
.get()
.uri("/playlists/{playlist_id}/tracks", playlistId)
.retrieve()
.body(TrackCollection.class)
}

Right now, our method isn't returning anything. We're receiving our API response as an instance of TrackCollection, but we still need to return its tracks property to get that List of Track objects.

Let's update the call to the API, receiving the results as an instance of TrackCollection.

TrackCollection trackList = client
.get()
.uri("/playlists/{playlist_id}/tracks", playlistId)
.retrieve()
.body(TrackCollection.class);

We'll check that trackList actually exists (that our request was valid and we got some data back). If it exists, we'll call getTracks on trackList to return the results; otherwise, we'll return null.

if (trackList != null) {
return trackList.getTracks();
} else {
return null;
}

Cleaning up MappedPlaylist

Now that we've created the TrackCollection class, which extracts a list of Track objects from the "items" property, we can reduce some duplicative code in MappedPlaylist, and employ our new class instead!

Back in MappedPlaylist, take a look at how we're setting a playlist's tracks. Doesn't some of that logic look familiar?

MappedPlaylist
@JsonSetter("tracks")
public void mapTracks(JsonNode tracks) throws IOException {
ObjectMapper mapper = new ObjectMapper();
JsonNode items = tracks.get("items");
if (items != null) {
List<MappedTrack> trackList = mapper.readValue(items.traverse(), new TypeReference<>() {});
this.setTracks(trackList.stream().map(MappedTrack::getTrack).toList());
}
}

In this class, just as in TrackCollection, we're plucking out the raw track objects from the "items" property, traversing them to create MappedTrack instances out of each, and then calling getTrack to upcast each to a Track instance.

Let's jump into that if block that checks for the existence of "items", and remove the code currently inside it.

if (items != null) {
- List<MappedTrack> trackList = mapper.readValue(items.traverse(), new TypeReference<>() {});
- this.setTracks(trackList.stream().map(MappedTrack::getTrack).toList());
}

Then, instead of traversing items and creating a List of MappedTrack types, let's update the code to instead create a new instance of TrackCollection with the entire tracks node.

if (items != null) {
TrackCollection trackList = mapper.readValue(tracks.traverse(), new TypeReference<TrackCollection>() {
});
}

Take note that we're traversing tracks instead of items; this is because the TrackCollection expects to receive an object with an "items" property to do its job!

Now that we have an instance of TrackCollection, we can call its getTracks method to return a List of Track types. This is just what our MappedPlaylist class needs! We'll call its setTracks method with the results.

if (items != null) {
TrackCollection trackList = mapper.readValue(tracks.traverse(), new TypeReference<TrackCollection>() {
});
this.setTracks(trackList.getTracks());
}

Explorer time: round 2!

Server restarted, and running with the latest changes? Great! Now when we jump back over to Sandbox and run the for featuredPlaylists and its list of tracks, we get what we asked for!

query GetFeaturedPlaylists {
featuredPlaylists {
id
name
description
tracks {
id
name
explicit
uri
}
}
}

👏👏👏

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 .

If we had used REST, the app logic would have included:

  • Making the HTTP GET call to the /browse/featured-playlists endpoint
  • Making an extra HTTP GET call for each playlist in the response to GET /playlists/{playlist_id}/tracks. Waiting for all of those to resolve, depending on the number of playlists, could take a while. Plus, this introduces the common N+1 problem.
  • Retrieving just the id, name and explicit and uri properties, discarding all the rest of the response. There's so much more to the response that wasn't used! Again, if the client app had slow network speeds or not much data, that big response comes with a cost.

With , we have our short and sweet, clean, readable coming from the client, coming back 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 are needed are all done on the 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 and inconsistent).

Note: We can address the N+1 problem on the side using Data Loaders. Keep an eye out for this course, coming soon!

Key takeaways

  • A chain is the order in which datafetcher functions are called when resolving a particular . 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 the DgsDataFetchingEnvironment.
  • 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 chain, as well as other data about the being executed.

Up next

Feeling confident with queries? It's time to explore the other side of : .

Previous

Share your questions and comments about this lesson

This course is currently in

beta
. Your feedback helps us improve! If you're stuck or confused, let us know and we'll help you out. All comments are public and must follow the Apollo Code of Conduct. Note that comments that have been resolved or addressed may be removed.

You'll need a GitHub account to post below. Don't have one? Post in our Odyssey forum instead.