Overview
It's clear that our REST API's mutation response doesn't give us everything we need to satisfy the AddItemsToPlaylistPayload
return type immediately. We've got a playlist's id value, but we actually need to return the entire playlist object in order to match the return type in our schema.
In this lesson, we will:
- Delegate responsibility for the
AddItemsToPlaylistPayload.playlist
field to its own resolver function - Apply the concept of resolver chains to use data from the
parent
argument in follow-up requests - Update our TypeScript mappers to keep our resolvers type definitions accurate
Revisiting resolver chains
As it is right now, the Mutation.addItemsToPlaylist
resolver returns the code
, success
, and message
properties as outlined in our schema.
type AddItemsToPlaylistPayload {"Similar to HTTP status code, represents the status of the mutation"code: Int!"Indicates whether the mutation was successful"success: Boolean!"Human-readable message for the UI"message: String!"The playlist that contains the newly added items"playlist: Playlist # this field is still null!}
To return an actual playlist object, we could have this same resolver make an additional call to our SpotifyAPI
's getPlaylist
method, passing in the playlist ID that we have.
For fun, (don't copy this) here's what that might look like:
Mutation: {async addItemsToPlaylist(_, { input }, { dataSources }) {try {const response = await dataSources.spotifyAPI.addItemsToPlaylist(input);if (response.snapshot_id) {const playlist = await dataSources.spotifyAPI.getPlaylist(response.snapshot_id);return {code: 200,success: true,message: "Tracks added to playlist!",playlist};} else {throw Error("snapshot_id property not found");}} catch (e) {return {code: 500,success: false,message: `Something went wrong: ${e}`,playlist: null};}},},
But doing so would actually overburden this resolver; it would always fetch additional playlist information, even if the query did not ask for it.
Instead, this is a great opportunity to put our knowledge of resolver chains to work. We can separate the logic we need to fetch a playlist object by its ID into a new resolver: one that takes responsibility specifically for the AddItemsToPlaylistPayload.playlist
field!
Resolving the AddItemsToPlaylistPayload.playlist
field
Here's the plan.
- We'll first define a new resolver function, specific to the
AddItemsToPlaylistPayload.playlist
field. - We'll ensure that the
Mutation.addItemsToPlaylist
resolver passes the updatedplaylistId
to the next resolver in the chain, rather than a nullplaylist
property. - Following the resolver chain, the
AddItemsToPlaylistPayload.playlist
resolver will receive thisplaylistId
on itsparent
argument. - Using the
playlistId
, theAddItemsToPlaylistPayload.playlist
resolver can handle all the special logic needed to reach out to the REST API and retrieve additional playlist details!
Let's get to it!
Step 1: The new AddItemsToPlaylistPayload.playlist
resolver
Let's add a new AddItemsToPlaylistPayload
entry to our resolvers
object, with a property called playlist
.
AddItemsToPlaylistPayload: {playlist: (parent, args, contextValue, info) => {return null;},},
By defining this function, we've told our server that it's no longer the Mutation.addItemsToPlaylist
resolver's responsibility to resolve the playlist
field on the AddItemsToPlaylistPayload
object it returns.
Step 2: Passing playlistId
to the next resolver
We need to access the playlist's id
in the AddItemsToPlaylistPayload.playlist
resolver. How do we do that? Using the parent
argument!
The Mutation.addItemsToPlaylist
resolver returns the following AddItemsToPlaylistPayload
object, which means the AddItemsToPlaylistPayload.playlist
resolver can access these properties from its parent
argument.
return {code: 200,success: true,message: "Tracks added to playlist!",playlist: null, // We don't have this value yet};
Right now, that playlist
property the resolver returns is pretty useless. And because it's no longer the responsibility of this resolver to resolve the playlist
field, let's pass along some data that the next resolver in the chain can use instead: the playlistId
.
Replace the playlist
property with playlistId
. The response.snapshot_id
holds the ID of the playlist we updated, so we'll pass that in here.
return {code: 200,success: true,message: "Tracks added to playlist!",- playlist: null,+ playlistId: response.snapshot_id};
Down in our catch
block, we'll also change the playlist
property to playlistId
instead.
return {code: 500,success: false,message: `Something went wrong: ${e}`,- playlist: null,+ playlistId: null};
Now, the playlistId
value will be passed on into the next resolver in the chain. Here's how our Mutation.addItemsToPlaylist
resolver should look now.
Step 3: Retrieving playlistId
from parent
Now, inside of our AddItemsToPlaylistPayload.playlist
resolver, we can log out the parent
argument to see if all of our values have arrived from the previous resolver in the chain.
AddItemsToPlaylistPayload: {playlist: (parent, args, contextValue, info) {console.log(parent);return null;}}
Let's try our mutation operation again, this time adding playlist
and a few of its subfields: id
, name
, and description
. Jump back into Sandbox and run the following mutation.
mutation AddTracksToPlaylist($input: AddItemsToPlaylistInput!) {addItemsToPlaylist(input: $input) {codemessagesuccessplaylist {idnametracks {idname}}}}
And in the Variables panel:
{"input": {"playlistId": "6LB6g7S5nc1uVVfj00Kh6Z","uris": ["spotify:track:4iV5W9uYEdYUVa79Axb7Rh","spotify:track:1301WleyT98MSxVHPZCA6M"]}}
Back in our terminal, we'll see that the parent
argument we logged out now contains all the values that were returned from the previous resolver in the chain!
{code: 200,success: true,message: 'Tracks added to playlist!',playlistId: '6LB6g7S5nc1uVVfj00Kh6Z'}
Step 4: Retrieve playlist data using playlistId
The playlistId
property is just what we need. Inside of our AddItemsToPlaylistPayload.playlist
resolver, we'll pluck off the playlistId
key from the parent
argument. We can also replace args
with _
, since we won't be using it, and destructure contextValue
for its dataSources
property. (We can also safely remove the info
parameter!)
playlist: ({ playlistId }, _, { dataSources }) => {return null;},
We've already defined a method in our SpotifyAPI
class that accepts a playlist's ID, and returns a playlist, so we can make a new call to our data source's getPlaylist
method, passing in the playlistId
.
playlist: ({ playlistId }, _, { dataSources }) => {return dataSources.spotifyAPI.getPlaylist(playlistId);},
When we try to run our code, however, we'll see a TypeScript error.
Property 'playlistId' does not exist on type 'Omit<AddItemsToPlaylistPayload, "playlist">& { playlist?: PlaylistModel; }'
Resolving the type errors
TypeScript knows from our schema that the Mutation.addItemsToPlaylist
field returns a AddItemsToPlaylistPayload
type. So, it expects the Mutation.addItemsToPlaylist
resolver function to return exactly that!
type Mutation {"Add one or more items to a user's playlist."addItemsToPlaylist(input: AddItemsToPlaylistInput!): AddItemsToPlaylistPayload!}
Following the resolver chain logic, that means it expects the next resolver in the chain—AddItemsToPlaylistPayload.playlist
—to receive that same AddItemsToPlaylistPayload
as its parent
argument.
So, it's a rude shock for TypeScript to discover that we're not actually returning an object that matches its expectations. Instead of a playlist
field, it finds playlistId
.
{code: 200,success: true,message: 'Tracks added to playlist!',playlistId: '6LB6g7S5nc1uVVfj00Kh6Z' // ❌ TypeScript doesn't love this property being here!}
The Mutation.addItemsToPlaylist
resolver is no longer responsible for returning the AddItemsToPlaylistPayload.playlist
field in our schema, but TypeScript doesn't know this. From its perspective, we're making a big mistake: referencing a property that doesn't actually exist.
We ran into this issue earlier when working with playlist and track objects from our REST API that didn't exactly align with the shape the types took in our schema. We can solve the problem here the same way as before: by adding a new mapper for the AddItemsToPlaylistPayload
type!
Let's jump back into models.ts
. We'll add a new definition, called AddItemsToPlaylistPayloadModel
.
export type AddItemsToPlaylistPayloadModel = {// TODO};
We'll give it the three properties that it has in common with the AddItemsToPlaylistPayload
type in our schema—code
, success
, and message
—along with the playlistId
string.
export type AddItemsToPlaylistPayloadModel = {code: number;success: boolean;message: string;playlistId: string;};
Now we can update our codegen.ts
file's mappers
property to use this type.
config: {contextType: "./context#DataSourceContext",mappers: {Playlist: "./models#PlaylistModel",Track: "./models#TrackModel",AddItemsToPlaylistPayload: "./models#AddItemsToPlaylistPayloadModel",},},
Phew! Let's restart our server so we can start completely fresh.
npm run dev
Back in Sandbox, we'll run that same mutation again.
mutation AddTracksToPlaylist($input: AddItemsToPlaylistInput!) {addItemsToPlaylist(input: $input) {codemessagesuccessplaylist {idnametracks {idname}}}}
{"input": {"playlistId": "6LB6g7S5nc1uVVfj00Kh6Z","uris": ["spotify:track:4iV5W9uYEdYUVa79Axb7Rh","spotify:track:1301WleyT98MSxVHPZCA6M"]}}
And with that, we've got all of the data we expect! We can see the same code
, success
and message
fields from before, along with the new playlist-specific fields.
Key takeaways
- We can define an individual resolver function for any field in our schema. This allows us to execute additional data-fetching logic only when a field is included in a query or mutation.
- By using mappers, we can equip TypeScript with a picture of what the data our resolvers are working with actually looks like. This is helpful when response from data sources need manipulation or traversal, or we need to pass objects between resolvers that don't match the types in our schema.
Journey's end
You've built a GraphQL API! You've got a working GraphQL server jam-packed with playlists and tracks using a REST API as a data source. You've written queries and mutations, and learned some common GraphQL conventions along the way. You've explored how to use GraphQL arguments, variables, and input types in your schema design. Take a moment to celebrate; that's a lot of learning!
But the journey doesn't end here! Put your newfound GraphQL skills to the test in Growing your GraphQL API with TypeScript & Apollo Server, a hands-on lab where you'll implement a new feature from start to finish.
And when you're ready to take your GraphQL API even further, jump into the next course in this series: Federation with TypeScript & Apollo Server.
Thanks for joining us in this course; we hope to see you in the next one!
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.