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
parent
argument of a resolver
Examining the data source response
Let's examine the response from our GET /browse/featured-playlists
endpoint. It looks like we do have access to a tracks
property under playlist.items.tracks
.
"tracks": {"href": "string","total": 0}
Note: Alternatively, you can find the same information by following the trail of types and properties in the mock_spotify_rest_api_client
package.
Instead of a list of track objects (similar to the list that the earlier GET /playlists/{playlist_id}
endpoint returns), 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.
That means we won't be able to get the list of tracks for a playlist inside the Query.featured_playlists
resolver function.
Instead, 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?
Examining the query
Let's take a step back and look at the query we want to implement:
query GetFeaturedPlaylists {featuredPlaylists {idnamedescriptiontracks {idnameexplicituri}}}
From this query, we're resolving the featuredPlaylists
field using the Query.featured_playlists
resolver function:
async def featured_playlists(self, info: strawberry.Info) -> list[Playlist]:client = info.context["spotify_client"]data = await get_featured_playlists.asyncio(client=client)return [Playlist(id=strawberry.ID(playlist.id),name=playlist.name,description=playlist.description,)for playlist in data.playlists.items]
In this case, we're not doing anything to initialize the playlist's tracks, which is why we're getting an error (tracks
is required but not provided).
Remember, we need to make an extra call to the GET /playlists/{playlist_id}/tracks
endpoint to get 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 in the same featured_playlists
resolver function.
But that would mean that whenever we query for featuredPlaylists
, we would always make an additional network call to the REST API, even when the query didn't ask for 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 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
resolver function, then the playlist.name
function, which returns a str
type and ends the chain.
Each resolver in this chain passes their return value to the next function down, using the resolver's parent
argument. In Python, we get access to this parent
object using self
.
Remember, a resolver has access to a number of parameters. So far, we've used info
(to get the the spotify_client
from the context
) and arguments (like the playlist id
argument). parent
is another such parameter!
In this example, the playlist.name
resolver function would have access to the Playlist
object that Query.playlist
resolver 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.
Note that since 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 parent
, Track.uri
would have access to the Track
object as the parent
.
If our operation didn't include the tracks
field (like the first example we showed), then the Playlist.tracks
resolver would never be called!
Refactoring Playlist.tracks
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 featured_playlists
resolver, where it would be called every single time, even when the operation doesn't include it.
Instead, we'll jump into the Playlist
class and refactor the tracks
property into a resolver function with a body. Right now, it's a basic property, with a default resolver.
tracks: list[Track] = strawberry.field(description="The playlist's tracks.")
First, let's add a private field for tracks
, prefixing it with an underscore (_
) to follow common convention. To prevent this field from showing up in the schema, we use the strawberry.Private
function.
We'll also give it a default of None
so we don't have to pass it in the constructor when we don't have the data yet.
_tracks: strawberry.Private[list[Track] | None] = None
Then, we'll update the Query.playlist
resolver to set the _tracks
field (instead of tracks
without the underscore) when creating a Playlist
object:
return Playlist(id=strawberry.ID(data.id),name=data.name,description=data.description,- tracks=[+ _tracks=[Track(id=strawberry.ID(item.track.id),name=item.track.name,duration_ms=item.track.duration_ms,explicit=item.track.explicit,uri=item.track.uri,)for item in data.tracks.items],)
Back over to the Playlist
class, let's transform the tracks
property to a resolver function and apply the @strawberry.field
decorator.
- tracks: list[Track] = strawberry.field(description="The tracks in the playlist.")+ @strawberry.field(description="The tracks in the playlist.")+ def tracks(self) -> list[Track]:
For now, we'll return what's in the private _tracks
field.
return self._tracks
Our GetPlaylistDetails
operation should still be working with these changes. Take a moment to save our changes and confirm!
Now we have a perfect place to make our additional HTTP call.
Instead of returning the _tracks
private field immediately, we'll check to see if it exists. If it does, return it, but if it doesn't, we'll make the HTTP call.
def tracks(self) -> list[Track]:if self._tracks is None:# TODO: HTTP call...return self._tracks
Time for the HTTP call! First, we'll need to access the info
argument in the resolver parameters and then extract the spotify_client
from info.context
. We'll also update the function to be async
:
async def tracks(self,info: strawberry.Info) -> list[Track]:if self._tracks is None:spotify_client = info.context["spotify_client"]# TODO: HTTP callreturn self._tracks
Next, let's call the get_playlist_tracks
function. We'll pass in the spotify_client
and the playlist's id
, await
the results and store it in a variable called data
. You know the drill by now!
if self._tracks is None:spotify_client = info.context["spotify_client"]data = await get_playlists_tracks.asyncio(client=spotify_client, playlist_id=self.id)
Then, we'll update the _tracks
field with the data we get back from REST API call, instantiating a list of Track
classes with the required properties.
data = await get_playlists_tracks.asyncio(client=spotify_client, playlist_id=self.id)self._tracks = [Track(id=strawberry.ID(item.track.id),name=item.track.name,duration_ms=item.track.duration_ms,explicit=item.track.explicit,uri=item.track.uri,)for item in data.items]
Finally, don't forget to import the get_playlists_tracks
function at the top of the file.
from mock_spotify_rest_api_client.api.playlists import get_playlists_tracks
Explorer time: round 2!
Server 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 DataLoaders. Check out the Strawberry documentation for how to implement them.
Key takeaways
- 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.
- Each resolver in this chain passes their return value to the next function down, using the resolver's
parent
argument.
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.