10. Mutations
10m

Overview

in 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: .

In this lesson, we will:

  • Boost our schema with the ability to change data
  • Explore syntax and write an to add tracks to a playlist
  • Learn about input types
  • Learn about 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 string
  • position - 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 ?

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 , or , 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 we'll be using to mutate our data.

Let's open up schema.graphqls and add a new Mutation type.

schema.graphqls
type Mutation {
}

For the of the Mutation, we recommend starting with a verb that describes the specific action of our update (such as add, delete, or create), followed by whatever data the acts on.

We'll explore how we can add items to a playlist, so we'll call this addItemsToPlaylist.

type Mutation {
"Add one or more items to a user's playlist."
addItemsToPlaylist: #TODO
}

For the return type of the addItemsToPlaylist , we could return the Playlist type; it's the we want the to act upon. However, we recommend following a consistent Response type for 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 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 usually end with the word Payload or Response.

Following convention, we'll combine the name of our (addItemsToPlaylist) with Payload. (Don't forget to capitalize!)

type AddItemsToPlaylistPayload {
}

We should return the that we're mutating (Playlist, in our case), so that clients have access to the updated object.

type AddItemsToPlaylistPayload {
playlist: Playlist
}

Note: Though our acts upon a single Playlist object, it's also possible for a to change and return multiple objects at once.

Notice that playlist can be null, because our might fail.

To account for any partial errors that might occur and return helpful information to the client, there are a few additional we can include in a response type.

  • code: an Int that refers to the status of the response, similar to an HTTP status code.

  • success: a Boolean flag that indicates whether all the updates the was responsible for succeeded.

  • message: a String to display information about the result of the 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 so that it makes our 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 to this new AddItemsToPlaylistPayload type, and make it non-nullable. Here's what the addItemsToPlaylist 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 needs to receive some input.

Let's think about the kind of input this addItemsToPlaylist would expect. We're potentially adding many new tracks to a singular playlist. This means that whoever is sending this 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 before in the Query.playlist : we passed in a single called id.

type Query {
playlist(id: ID!): Playlist
}

But addItemsToPlaylist takes more than one . One way we could tackle this is to add each argument, one-by-one, to our addItemsToPlaylist . But this approach can become unwieldy and hard to understand. Instead, it's a good practice to use input types as for a .

Exploring the input type

The input type in a is a special that groups a set of together, and can then be used as an argument to another . Using input types helps us group and understand , especially for .

To define an input type, use the input keyword followed by the name and curly braces ({}). Inside the curly braces, we list the and types as usual. Note that fields of an input type can be only a , 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 . 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 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 . For example, we can update the addItemsToPlaylist 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 , 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 .

datasources/SpotifyClient
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 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 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.

Response object: success
{
"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.

Response object: error
{
"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.

models/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 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 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.

Task!

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 : @DgsMutation!

Let's import it at the top of the file.

datafetchers/PlaylistDataFetcher
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 : addItemsToPlaylist.

public class PlaylistDataFetcher {
@DgsMutation
public 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 the schema expects.

@DgsMutation
public 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 imports
import com.example.soundtracks.generated.types.AddItemsToPlaylistInput;
// other annotations
@DgsMutation
public 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 imports
import com.example.soundtracks.generated.types.AddItemsToPlaylistInput;
import com.example.soundtracks.generated.types.AddItemsToPlaylistPayload;
// other annotations
@DgsMutation
public 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 , playlistId, which is a String.

datafetchers/PlaylistDataFetcher
@DgsMutation
public AddItemsToPlaylistPayload addItemsToPlaylist(@InputArgument AddItemsToPlaylistInput input) {
String playlistId;
}

Our input , 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 .

@DgsMutation
public AddItemsToPlaylistPayload addItemsToPlaylist(@InputArgument AddItemsToPlaylistInput input) {
String playlistId = input.getPlaylistId();
}

We can do the same for the uris that will be passed from the 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 we need to pass to the API endpoint. Now, let's make the call to the SpotifyClient.

datafetchers/PlaylistDataFetcher
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 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 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 in the Explorer, but first: there's a missing piece we need to address.

As we can see in our schema, the AddItemsToPlaylistPayload.playlist returns a Playlist type. After we made a successful call, we created a new Playlist instance and set its ID with the value from the '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 sub from the Playlist type in the shown below, we won't get actual data—most of these have null values right now! This clearly doesn't take advantage of 's ability to traverse from to object type.

mutation AddTracksToPlaylist($input: AddItemsToPlaylistInput!) {
addItemsToPlaylist(input: $input) {
playlist {
id
name # name is 'null'
tracks {
# tracks are 'null'
name # name won't even exist!
}
}
}
}

We can fix this, but first, let's see an that does work. We'll restart our server to make sure all of our changes have been applied.

Task!

In Explorer, fill out the following :

mutation AddTracksToPlaylist($input: AddItemsToPlaylistInput!) {
addItemsToPlaylist(input: $input) {
code
success
message
}
}

And add the following to the Variables panel.

{
"input": {
"playlistId": "6LB6g7S5nc1uVVfj00Kh6Z",
"uris": [
"spotify:track:4iV5W9uYEdYUVa79Axb7Rh",
"spotify:track:1301WleyT98MSxVHPZCA6M"
]
}
}

When we run the , we'll see that this 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

Which of these are good names for mutations based on the recommended conventions above?
In the mutation response type (AddItemsToPlaylistPayload), why is the modified object's return type (Playlist) nullable?
How can we use the input type in our schema?
When creating an input type for a mutation, what naming convention is commonly used?

Key takeaways

  • are write used to modify data.
  • Naming 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 responses.
  • in often require multiple 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.

Previous

Share your questions and comments about this lesson

This course is currently in

beta
. Your feedback helps us improve! If you're stuck or confused, let us know and we'll help you out. All comments are public and must follow the Apollo Code of Conduct. Note that comments that have been resolved or addressed may be removed.

You'll need a GitHub account to post below. Don't have one? Post in our Odyssey forum instead.