Overview
We can query for a playlist's tracks, but only through the playlist(id: ID)
root field, not through featuredPlaylists
. What's going on?
In this lesson, we will:
- Learn about resolver chains
- Learn about the
source
argument 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.
@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 query for playlist
or featuredPlaylists
, we would always make an additional network call to the REST API, even when the query didn't ask for a playlist's tracks
!
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 GetPlaylist
operation retrieves the name of a playlist.
query GetPlaylist($playlistId: ID!) {playlist(id: $playlistId) {name}}
When resolving this operation, the GraphQL server will first call the Query.playlist()
datafetcher method, then the Playlist.name()
method which returns a string
type and ends the chain.
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
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 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 GraphQL operation.
query GetPlaylistTracks($playlistId: ID!) {playlist(id: $playlistId) {nametracks {uri}}}
This time, we've added more fields and asked for each playlist's list of tracks, specifically their uri
values.
Our resolver chain grows, adding a parallel branch.
Because Playlist.tracks
returns a list of potentially multiple tracks, this resolver might run more than once to retrieve each track's URI.
Following the trail of the resolver, 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 operation didn't include the tracks
field (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 resolver 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 operation doesn't include the tracks
field:
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
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 Playlist
type.
"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 GraphQL type and field we're defining the method for. Here's what that will look like for the Playlist.tracks
field:
@DgsData(parentType="Playlist", field="tracks")
And if we give our method the same name as the field, tracks
, we can omit the field="tracks"
specification here in the annotation.
Let's define this method in our PlaylistDataFetcher
class now.
// ... 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
argument 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 query for a playlist or featured playlists.
Our Playlist.tracks
field, 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.
// ... other importsimport com.netflix.graphql.dgs.DgsDataFetchingEnvironment;
Next, we'll add it as an argument to our method called dfe
.
@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
.
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
.
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.
- 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 theMappedPlaylist
instance. In this case, we can simply return thetracks
property that we've accessed. - 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 argument, 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.
// ... other importsimport 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
field expects to return. It will receive the id
of the playlist that we want to retrieve tracks for, a String
we'll call playlistId
.
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
.
{"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
.
.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.
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.
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
.
@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
.
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
.
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 data source method, so let's jump back to datasources/SpotifyClient
.
Finalizing the SpotifyClient
method
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?
@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 query for featuredPlaylists
and its list of tracks, we get what we asked for!
query GetFeaturedPlaylists {featuredPlaylists {idnamedescriptiontracks {idnameexplicituri}}}
👏👏👏
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
/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
andexplicit
anduri
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 GraphQL, we have our short and sweet, clean, readable operation 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 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. Keep an eye out for this course, coming soon!
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.
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.