Overview
We've seen the power of specific and expressive queries that let us retrieve exactly the data we're looking for, all at once. But querying in GraphQL is just one part of the equation.
When we want to actually change, insert, or delete data, we need to reach for a new tool: GraphQL mutations.
In this lesson, we will:
- Explore mutation syntax and write an operation to add tracks to a playlist
- Learn about GraphQL
input
types - Learn about mutation response best practices
Mutations in Spotify
On to the next feature in our MusicMatcher project: adding tracks to an existing playlist.
Let's take a look at the corresponding REST API method that enables this feature: POST /playlists/{playlist_id}/tracks
From the documentation, we need the following parameters:
playlist_id
- The ID of the playlist, as a stringposition
- An integer, zero-indexed, where we want to insert the track(s)uris
- A comma-separated string of uri values corresponding to the tracks we want to add
The method then returns an object with a snapshot_id
property that represents the state of the playlist at that point in time.
All right, now how do we enable this functionality in GraphQL?
Designing mutations
Much like the Query
type, the Mutation
type serves as an entry point to our schema. It follows the same syntax as the schema definition language, or SDL, that we've been using so far.
We declare the Mutation
type using the type
keyword, then the name Mutation
. Inside the curly braces, we have our entry points, the fields we'll be using to mutate our data.
Let's open up schema.graphql
and add a new Mutation
type.
type Mutation {}
For the fields of the Mutation
, we recommend starting with a verb that describes the specific action of our update operation (such as add
, delete
, or create
), followed by whatever data the mutation acts on.
We'll explore how we can add items to a playlist, so we'll call this mutation addItemsToPlaylist
.
type Mutation {"Add one or more items to a user's playlist."addItemsToPlaylist: #TODO}
For the return type of the addItemsToPlaylist
mutation, we could return the Playlist
type; it's the object type we want the mutation to act upon. However, we recommend following a consistent Response
type for mutation responses. Let's see what this looks like in a new type.
The Mutation
response type
Return types for Mutation
fields usually end with the word Payload
or Response
.
Following convention, we'll combine the name of our mutation (addItemsToPlaylist
) with Payload
. (Don't forget to capitalize!)
type AddItemsToPlaylistPayload {}
We should return the object type that we're mutating (Playlist
, in our case), so that clients have access to the updated object.
type AddItemsToPlaylistPayload {playlist: Playlist}
Note: Though our mutation acts upon a single Playlist
object, it's also possible for a mutation to change and return multiple objects at once.
Notice that playlist
can be null
, because our mutation might fail.
To account for any partial errors that might occur and return helpful information to the client, there are a few additional fields we can include in a response type.
code
: anInt
that refers to the status of the response, similar to an HTTP status code.success
: aBoolean
flag that indicates whether all the updates the mutation was responsible for succeeded.message
: aString
to display information about the result of the mutation on the client side. This is particularly useful if the mutation was only partially successful and a generic error message can't tell the whole story.
Let's also add comments for each of these fields so that it makes our GraphQL API documentation more useful.
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}
Lastly, we can set the return type of our mutation to this new AddItemsToPlaylistPayload
type, and make it non-nullable. Here's what the addItemsToPlaylist
mutation should look like now:
type Mutation {"Add one or more items to a user's playlist."addItemsToPlaylist: AddItemsToPlaylistPayload!}
The Mutation
input
To make any changes to a particular playlist, our mutation needs to receive some input.
Let's think about the kind of input this addItemsToPlaylist
mutation would expect. We're potentially adding many new tracks to a singular playlist. This means that whoever is sending this query should be able to provide the specific playlist, along with the item(s) to be added. Furthermore, we could let them pass additional customization, specifying where in the playlist the items should be inserted.
We've used a GraphQL argument before in the Query.playlist
field: we passed in a single argument called id
.
type Query {playlist(id: ID!): Playlist}
But addItemsToPlaylist
takes more than one argument. One way we could tackle this is to add each argument, one-by-one, to our addItemsToPlaylist
mutation. But this approach can become unwieldy and hard to understand. Instead, it's a good practice to use GraphQL input types as arguments for a field.
Exploring the input
type
The input
type in a GraphQL schema is a special object type that groups a set of arguments together, and can then be used as an argument to another field. Using input
types helps us group and understand arguments, especially for mutations.
To define an input type, use the input
keyword followed by the name and curly braces ({}
). Inside the curly braces, we list the fields and types as usual. Note that fields of an input type can be only a scalar, an enum, or another input type.
input AddItemsToPlaylistInput {}
Next, we'll add properties. Remember, we need the ID of the playlist and a list of URIs, at the very minimum. We could also specify the position in the playlist these items get added to, but it's not required for the REST API. By default, tracks will be appended to the end of the playlist, so we're safe to omit it from our GraphQL schema. Remember, your GraphQL API does not need to match your REST API exactly!
input AddItemsToPlaylistInput {"The ID of the playlist."playlistId: ID!"A comma-separated list of Spotify URIs to add."uris: [String!]!}
Note: You can learn more about the input
type, as well as other GraphQL types and features in Side Quest: Intermediate Schema Design.
Using the input
To use an input
type in the schema, we can set it as the type of a field argument. Let's update the addItemsToPlaylist
mutation to use the AddItemsToPlaylistInput
type.
type Mutation {"Add one or more items to a user's playlist."addItemsToPlaylist(input: AddItemsToPlaylistInput!): AddItemsToPlaylistPayload!}
Notice that the AddItemsToPlaylistInput
is non-nullable. To run this mutation, we actually need to require some input!
Building the SpotifyAPI
method
As we've done for other requests, we'll add a method to the SpotifyAPI
class to manage this call to update data.
Back in datasources/spotify-api.ts
, let's add a new method called addItemsToPlaylist
. We'll expect this method to receive an input
parameter, which is an object containing playlistId
(a string type) and uris
(an array of string types).
addItemsToPlaylist(input: { playlistId: string, uris: string[] }) {// TODO}
This time, because the endpoint uses the POST
method, we'll use the this.post
helper method from RESTDataSource
instead of this.get
.
Next, we'll pass our endpoint to this method. We'll replace the {playlist_id}
query param with ${playlistId}
so we can interpolate it from this method's arguments.
addItemsToPlaylist(input: { playlistId: string, uris: string[] }) {const { playlistId, uris } = input;return this.post(`playlists/${playlistId}/tracks`);}
We need to send a parameter with our call to this endpoint: namely, the uris
that we wish to add to the specified playlist. We can pass a second argument to the this.post
method, which is an object with a params
key. Inside of the params
object, we'll add our uris
. Referring to our REST API, we know that uris
needs to be a single string of comma-separated uri values, so we'll use the join
method to transform our array of strings into a single string.
addItemsToPlaylist(input: { playlistId: string, uris: string[] }) {const { playlistId, uris } = input;return this.post(`playlists/${playlistId}/tracks`, {params: {uris: uris.join(',')}});}
To give this method its correct return type, let's consider the two possible outcomes from running the mutation.
Glancing back at our REST API documentation, we can see that when a playlist is successfully updated, we get back an object with a "snapshot_id"
key that matches the ID of the playlist we updated.
{"snapshot_id": "string" // this should match our updated playlist's id!}
And if something goes wrong—like if we try to update a playlist that doesn't exist—we should instead get back an object with an "error"
key.
{"error": "string"}
Back in models.ts
, let's create a type specifically for these possible mutation responses. It will have two optional properties, both string
types: snapshot_id
and error
.
export type SnapshotOrError = {snapshot_id?: string;error?: string;};
We'll first import this type into spotify-api.ts
.
import { PlaylistModel, SnapshotOrError } from "../models";
Then we can give our addItemsToPlaylist
method the appropriate return type of Promise<SnapshotOrError>
.
addItemsToPlaylist(input: { playlistId: string, uris: string[] }): Promise<SnapshotOrError> {// body of method}
That's it for the call to the endpoint—let's add a resolver function for our Mutation.addItemsToPlaylist
field, and make sure it's calling this method in SpotifyAPI
!
Connecting the dots in the resolvers
Jump back into the resolvers.ts
file. We'll add a new entry to our resolvers
object called Mutation
.
Mutation: {// TODO},
Let's add our addItemsToPlaylist
resolver.
Mutation: {addItemsToPlaylist: (parent, args, contextValue, info) => {// TODO}},
We won't need the parent
or info
parameters here, so we'll replace parent
with _
and remove info
altogether.
addItemsToPlaylist: (_, args, contextValue) => {// TODO},
We know from our Mutation.addItemsToPlaylist
schema field that this resolver will receive an argument called input
. We'll destructure args
for this property. And while we're here, we'll also destructure contextValue
for the dataSources
property.
addItemsToPlaylist: (_, { input }, { dataSources }) => {// TODO},
Now we'll call the addItemsToPlaylist
method from the spotifyAPI
data source, passing it our input
.
addItemsToPlaylist: (_, { input }, { dataSources }) => {dataSources.spotifyAPI.addItemsToPlaylist(input);},
We're not returning anything from this method just yet, so we might see some errors. In our schema we specified that the Mutation.addItemsToPlaylist
field should return a AddItemsToPlaylistPayload
type.
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 means that the object we return from our resolver needs to match this shape. Let's start by setting up the object that we'll return in the event that our mutation operation succeeds.
addItemsToPlaylist: (_, { input }, { dataSources }) => {dataSources.spotifyAPI.addItemsToPlaylist(input);// everything succeeds with the mutationreturn {code: 200,success: true,message: "Tracks added to playlist!",playlist: null, // We don't have this value yet}},
Checking the response
Now, let's take a look at the response from our API call. We'll need to make our entire resolver async
in order to await
the results of calling dataSources.spotifyAPI.addItemsToPlaylist()
.
addItemsToPlaylist: async (_, { input }, { dataSources }) => {const response = await dataSources.spotifyAPI.addItemsToPlaylist(input);console.log(response);// everything succeeds with the mutationreturn {code: 200,success: true,message: "Tracks added to playlist!",playlist: null, // We don't have this value yet}},
In Sandbox, let's put together a mutation operation.
mutation AddTracksToPlaylist($input: AddItemsToPlaylistInput!) {addItemsToPlaylist(input: $input) {codesuccessmessage}}
And add the following to the Variables panel.
{"input": {"playlistId": "6LB6g7S5nc1uVVfj00Kh6Z","uris": ["spotify:track:4iV5W9uYEdYUVa79Axb7Rh","spotify:track:1301WleyT98MSxVHPZCA6M"]}}
When we run the operation, we'll see that this mutation actually works as expected! We can clearly see the values we set for code
, success
, and message
in our happy path.
And in the terminal of our running server, we'll see the response from the API logged out.
{snapshot_id: "6LB6g7S5nc1uVVfj00Kh6Z";}
The snapshot_id
, we can see, is the playlistId
of the playlist we added tracks to!
Let's build out the sad path—when our mutation doesn't go as expected.
Handling the sad path
Returning to our Mutation.addItemsToPlaylist
, we'll account for the error state.
We'll start by wrapping everything in a try/catch
block.
try {const response = await dataSources.spotifyAPI.addItemsToPlaylist(input);return {code: 200,success: true,message: "Tracks added to playlist!",playlist: null, // We don't have this value yet};} catch (err) {}
Next, we'll contain the object we return in an if
block. We'll check for the existence of the snapshot_id
in the response from the REST API. If it doesn't exist, we'll throw an Error
.
try {const response = await dataSources.spotifyAPI.addItemsToPlaylist(input);if (response.snapshot_id) {return {code: 200,success: true,message: "Tracks added to playlist!",playlist: null,};} else {throw Error("snapshot_id property not found");}} catch (err) {}
Inside of the catch
block, we'll set some different properties.
try {// try block body} catch (err) {return {code: 500,success: false,message: `Something went wrong: ${err}`,playlist: null,};}
Here's how your entire resolver function should look.
Let's try out a mutation that we know won't work. Back in Sandbox, replace the values in the Variables panel with the input below.
{"input": {"playlistId": "playlist-that-doesnt-exist","uris": ["spotify:track:4iV5W9uYEdYUVa79Axb7Rh","spotify:track:1301WleyT98MSxVHPZCA6M"]}}
When we run the operation, we'll see the values we expect: the mutation failed, and our error message comes through loud and clear. Something went wrong: 404 Not Found!
Great! We have both the happy path and the sad path working, but we'll notice that there's one piece missing: we're still not returning any useful data for the AddItemsToPlaylistPayload.playlist
field.
This means if we include subfields from the Playlist
type (as shown in the mutation operation below), we won't get actual data—we don't have a Playlist
object to pull subfields from, much less its tracks
! We clearly can't yet take advantage of GraphQL's ability to traverse from object type to object type.
mutation AddTracksToPlaylist($input: AddItemsToPlaylistInput!) {addItemsToPlaylist(input: $input) {codesuccessplaylist {idnametracks {name}}}}
So how can we use the value we do have—the ID for the playlist we modified—to build out the remainder of our AddItemsToPlaylistPayload
response?
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}
We'll tackle this in the next lesson!
Practice
AddItemsToPlaylistPayload
), why is the modified object's return type (Playlist
) nullable?input
type in our schema?Key takeaways
- Mutations are write operations used to modify data.
- Naming mutations usually starts with a verb that describes the action, such as "add," "delete," or "create."
- It's a common convention to create a consistent response type for mutation responses.
- Mutations in GraphQL often require multiple arguments to perform actions. To group arguments together, we use a GraphQL input type for clarity and maintainability.
Up next
In our final lesson, let's complete our mutation response—and return Playlist
data!
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.