Overview
Querying in GraphQL is just one part of the equation. 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 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:
- Boost our schema with the ability to change data
- Explore mutation syntax and write an operation to add tracks to a playlist
- Learn about GraphQL
input
types - Learn about mutation response best practices
- Create a Java record to hold immutable response data
Mutations in Spotify
Onward to the next feature in our Spotify 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.graphqls
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.
Note: In this course, we've defined all of our types in a single schema.graphqls
file. This isn't required, however; the DGS framework builds the final GraphQL schema from all .graphqls
files it finds in the schema
folder. This means that as your schema grows larger, you can choose to break it up across multiple files if preferred.
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. For example, we can update the addItemsToPlaylist
mutation to use uses AddItemsToPlaylistInput
type like so:
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 SpotifyClient
method
As we've done for other requests, we'll build a method in SpotifyClient
to manage this call to update data.
Back in SpotifyClient
, let's add a new method for this mutation.
public void addItemsToPlaylist() {return client.post()}
This time, because the endpoint uses the POST
method, we'll chain .post()
rather than .get()
.
Next, we'll craft our destination endpoint in-line. We want to attach some query parameters to our specified endpoint, so instead of passing in a plain String
value to uri()
, we'll use its uriBuilder
parameter.
public void addItemsToPlaylist() {return client.post().uri(uriBuilder -> uriBuilder)}
From here, we can chain on our path
, and pass in the POST endpoint we want to hit.
.uri(uriBuilder -> uriBuilder.path("/playlists/{playlist_id}/tracks"))
Before interpolating the value for {playlist_id}
, we should chain on our query parameter, uris
. Then we'll call build
with our playlistId
parameter, closing the parentheses. Finally, outside of the uri
method, we can call retrieve
.
public void addItemsToPlaylist(String playlistId, String uris) {return client.post().uri(uriBuilder -> uriBuilder.path("/playlists/{playlist_id}/tracks").queryParam("uris", uris).build(playlistId)).retrieve()}
Watch out: Take note of where the parentheses appear in this call! There should be two parentheses that wrap up our URI construction before .retrieve()
is called.
To work with the results of this call, we should map the response to a Java class. But what shape does it take?
Checking the response
Glancing back at our 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"}
To work with the response from this endpoint, we need a new model that can handle both of these shapes. Because we won't have to do any manipulation to this model, we'll use a Java record instead of a class. In com.example.soundtracks.models
, let's create a new Java record called Snapshot
.
package com.example.soundtracks.models;import com.fasterxml.jackson.annotation.JsonProperty;public record Snapshot(@JsonProperty("snapshot_id") String id, String error) { }
Note: We use the @JsonProperty
annotation to mark id
as the argument that will receive the value of the JSON object's snapshot_id
property. (This way we can work with the much simpler property, id
!)
Now in SpotifyClient
we can import Snapshot
:
import com.example.soundtracks.models.Snapshot;
And in addItemsToPlaylist
, we can provide a class for our JSON response to be mapped to, also updating the return type for the method.
public Snapshot addItemsToPlaylist(String playlistId, String uris) {return client.post().uri(uriBuilder -> uriBuilder.path("/playlists/{playlist_id}/tracks").queryParam("uris", uris).build(playlistId)).retrieve().body(Snapshot.class);}
That's it for the call to the endpoint—let's wrap up our method in PlaylistDataFetcher
and make sure it's calling this method in SpotifyClient
.
Connecting the dots in the datafetcher
Our schema is complete with all the types we need to make our mutation work, and SpotifyClient
is updated with a new method. Now we just need to hook up the remaining pieces on the backend!
Let's first make sure that our generated code accounts for these new schema types, and restart our server.
Next, we'll jump back into our PlaylistDataFetcher
file. Remember the @DgsQuery
annotation we used on our class' featuredPlaylists
method? Well, there's another we can use to mark a method as responsible for a mutation: @DgsMutation
!
Let's import it at the top of the file.
import com.netflix.graphql.dgs.DgsMutation;
Now we can add this annotation, and write our new method just below it. We'll give it the same name as our Mutation
type's field: addItemsToPlaylist
.
public class PlaylistDataFetcher {@DgsMutationpublic void addItemsToPlaylist() {}// other methods}
If you're using IntelliJ as your IDE, you'll see immediately that a yellow squiggly line appears beneath the name of our method. Hovering over it, we'll see a message that encourages us to use the @InputArgument
annotation, and add the input
argument the schema field expects.
@DgsMutationpublic void addItemsToPlaylist(@InputArgument AddItemsToPlaylistInput input) {}
We haven't defined an AddItemsToPlaylistInput
Java class we can use as input
's data type, but fortunately DGS has us covered! After updating our schema and restarting our server, we should now see a new class made just for this purpose in our generated code folder: AddItemsToPlaylistInput
!
Tip: If you don't see the AddItemsToPlaylistInput
generated class, try restarting your server.
We can import it from our generated folder to complete our annotation.
// other importsimport com.example.soundtracks.generated.types.AddItemsToPlaylistInput;// other annotations@DgsMutationpublic void addItemsToPlaylist(@InputArgument AddItemsToPlaylistInput input) {}
We'll also find that we can now assign an appropriate return type to our addItemsToPlaylist
method, as the generated code folder now contains an AddItemsToPlaylistPayload
class as well.
// other importsimport com.example.soundtracks.generated.types.AddItemsToPlaylistInput;import com.example.soundtracks.generated.types.AddItemsToPlaylistPayload;// other annotations@DgsMutationpublic AddItemsToPlaylistPayload addItemsToPlaylist(@InputArgument AddItemsToPlaylistInput input) {}
Preparing query parameters
Let's continue by extracting the value we'll use for the playlist_id
parameter. We can declare a new variable, playlistId
, which is a String
.
@DgsMutationpublic AddItemsToPlaylistPayload addItemsToPlaylist(@InputArgument AddItemsToPlaylistInput input) {String playlistId;}
Our input
argument, of type AddItemsToPlaylistInput
, has a convenient getter method, getPlaylistId
, we can use to pull out the value of the playlistId
that the client provides when running the mutation.
@DgsMutationpublic AddItemsToPlaylistPayload addItemsToPlaylist(@InputArgument AddItemsToPlaylistInput input) {String playlistId = input.getPlaylistId();}
We can do the same for the uris
that will be passed from the query into this function.
String playlistId = input.getPlaylistId();List<String> uris = input.getUris();
Calling the SpotifyClient
method
Within the PlaylistDataFetcher
's addItemsToPlaylist
method, we've extracted out the arguments we need to pass to the API endpoint. Now, let's make the call to the SpotifyClient
.
String playlistId = input.getPlaylistId();List<String> uris = input.getUris();Snapshot snapshot = spotifyClient.addItemsToPlaylist(playlistId, String.join(",", uris));
Note: We're transforming our List
of String
uris into a single string, as this is the format the endpoint expects.
And let's be sure that we've imported our Snapshot
class. We'll also need the generated Playlist
class, and the Objects
utility for reasons we'll discuss below.
import com.example.soundtracks.models.Snapshot;import com.example.soundtracks.generated.types.Playlist;import java.util.Objects;
Next, we expect our method to return an instance of AddItemsToPlaylistPayload
, so let's create a new object that we can set when the call succeeds or fails.
AddItemsToPlaylistPayload payload = new AddItemsToPlaylistPayload();
If our call to the endpoint fails for any reason, we'll want to have control over what we send back to the client. Let's make sure that we check on our snapshot
returned from SpotifyClient
.
As long as it is not null, we can pluck off the ID, verify it matches the playlist's ID, then set all of the properties on the returned payload
object.
if (snapshot != null) {String snapshotId = snapshot.id();if (Objects.equals(snapshotId, playlistId)) {Playlist playlist = new Playlist();playlist.setId(playlistId);payload.setCode(200);payload.setMessage("success");payload.setSuccess(true);payload.setPlaylist(playlist);return payload;}}
Note: Recall that our AddItemsToPlaylistPayload
type has a playlist
field that returns a Playlist
type (not MappedPlaylist
!). The same is true of the generated AddItemsToPlaylistPayload
class; it expects its playlist
property to return an instance of the Playlist
class. For this reason, we create a new Playlist
instance, set its ID from our mutation input, then call payload.setPlaylist()
with the entire Playlist
object. We'll return to this in just a moment.
Handling the sad path
If all is successful, we'll end up with a new AddItemsToPlaylistPayload
instance that we can return. But if anything goes wrong along the way, we can define a different set of properties outside of the if
block.
payload.setCode(500);payload.setMessage("could not update playlist");payload.setSuccess(false);payload.setPlaylist(null);return payload;
Running a simple mutation
And here's what our full method looks like!
We'll try running this mutation in the Explorer, but first: there's a missing piece we need to address.
As we can see in our schema, the AddItemsToPlaylistPayload.playlist
field returns a Playlist
type. After we made a successful mutation call, we created a new Playlist
instance and set its ID with the value from the mutation's input. But that means that the Playlist
instance we pass it currently looks a bit like this:
Playlist{id='6LB6g7S5nc1uVVfj00Kh6Z',name='null',description='null',tracks='null'}
So if we include subfields from the Playlist
type in the mutation operation shown below, we won't get actual data—most of these have null
values right now! This clearly doesn't take advantage of GraphQL's ability to traverse from object type to object type.
mutation AddTracksToPlaylist($input: AddItemsToPlaylistInput!) {addItemsToPlaylist(input: $input) {playlist {idname # name is 'null'tracks {# tracks are 'null'name # name won't even exist!}}}}
We can fix this, but first, let's see an operation that does work. We'll restart our server to make sure all of our changes have been applied.
In Explorer, fill out the following 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.
{"data": {"addItemsToPlaylist": {"code": 200,"success": true,"message": "success"}}}
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
Let's tackle bringing in Playlist
data in our final lesson.
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.