9. Adding the Track type
10m

Overview

We're getting our playlist data back from the REST API, but we haven't met the needs of our mockup. Our playlist objects—as far as we can see—don't actually contain any music! Let's fix that.

In this lesson, we will:

  • Introduce the Track type to our schema
  • for playlist and track details in a single

Building the Track type

As we learned in the lesson on syntax, on types don't have to return a basic type—they can also return other !

For instance, we can add a tracks to our Playlist type—but what's the appropriate return type?

"A curated collection of tracks designed for a specific activity or mood."
type Playlist {
id: ID!
name: String!
description: String
tracks: # What type should this be?
}

Putting our business glasses on, we can see how details for a track object—such as name, duration, and whether or not it's explicit—would come in handy. Multiple tracks could appear in multiple playlists, and we might want different views that show us all of the tracks in a single playlist. For these reasons, we need to think of a "track" as a standalone —in other words, we should make it its own type called Track.

This means that our Playlist type should be updated: we need its tracks to return a list of Track types!

Update your Playlist type with the tracks description and highlighted below.

schema.graphqls
"A curated collection of tracks designed for a specific activity or mood."
type Playlist {
id: ID!
name: String!
description: String
"The tracks of the playlist."
tracks: [Track!]!
}

Now, let's actually define what a Track looks like. We'll concern ourselves with just a few properties: id, name, durationMs, explicit, and uri. In the schema.graphqls file, add the new Track type shown below:

schema.graphqls
"A single audio file, usually a song."
type Track {
"The ID for the track."
id: ID!
"The name of the track"
name: String!
"The track length in milliseconds."
durationMs: Int!
"Whether or not the track has explicit lyrics (true = yes it does; false = no it does not OR unknown)"
explicit: Boolean!
"The URI for the track, usually a Spotify link."
uri: String!
}

And from the schema's perspective, our work is done!

Testing the Track type

Now when we restart our server, DGS will pick up the changes to the schema and regenerate our Java classes. Our generated folder will have a new Track class, and the Playlist class will be updated with a tracks property that returns—surprise!—Track instances. We'll also see getters and setters to help manage this property.

generated/types/Playlist snippet
// other properties and methods
/**
* The tracks of the playlist.
*/
private List<Track> tracks;
/**
* The tracks of the playlist.
*/
public List<Track> getTracks() {
return tracks;
}
public void setTracks(List<Track> tracks) {
this.tracks = tracks;
}

Note: Remember that because MappedPlaylist extends this Playlist class, it now has access to tracks, getTracks, and setTracks.

Task!

Let's recompile and jump back into the Explorer. We'll try running a that calls for a playlist's tracks details.

query Playlist($playlistId: ID!) {
playlist(id: $playlistId) {
name
description
tracks {
id
name
}
}
}

And make sure that in the Variables panel, our $playlistId is still set.

{ "playlistId": "6LB6g7S5nc1uVVfj00Kh6Z" }

But when we run the ... kaboom! A big error appears in the Response panel rather than the data we want. But what's the problem?

Our server's terminal holds a clue:

"JSON parse error: Cannot deserialize value of type
`java.util.ArrayList<com.example.soundtracks.generated.types.Track>`
from Object value (token `JsonToken.START_OBJECT`)"

Note: You might have seen this error in the response even before you added tracks to the . This is because the endpoint we're querying automatically returns track data alongside playlist data. When our playlist data is being converted into an instance of MappedPlaylist, it's automatically looking for what to do with the "tracks" key it finds. When the class doesn't understand how to interpret the object we hand it, it produces this error. We'll resolve it in the next section!

Revisiting the JSON response

To solve this mystery, we need to return to our REST API and take a closer look at the shape of our playlist object. Let's inspect that /playlists/{playlist_id} endpoint, passing in the following ID to get our response.

6LB6g7S5nc1uVVfj00Kh6Z

What properties do you see on the playlist object? At first glance, it looks like everything we need is there—id, name, description, and even tracks. But when we drill into the tracks property, we'll see something we don't expect—it's not an array of track objects at all, but another object!

Response object
{
// other playlist properties
"tracks": {
"href": "https://...",
"limit": 100,
"next": null,
"items": [
/* track objects */
]
// other tracks properties
}
}

We don't find our actual track objects (or at least the data we want!) until we drill even further into this object's items property.

This is a problem: our MappedPlaylist class inherits directly from the the Playlist class that DGS generated from our schema. And Playlist doesn't know anything about these additional levels of nesting, much less that there's an "items" property it needs to look inside! All it knows is that tracks should have a type of List<Track>, but we're feeding it a JSON object with a different shape and multiple keys it doesn't understand.

generated/types/Playlist
public class Playlist {
// other properties and methods
private List<Track> tracks;
}

We can see this issue reflected in the error message with JsonToken.START_OBJECT; we're expecting a List<Track>, but what we actually receive is the start of a JSON object—essentially, "{".

Resolving the type mismatch

We don't need to pester the REST API team to fix the shape of their response; we can do a bit of extra mapping inside of MappedPlaylist to account for this difference in the shape the API returns and the shape that GraphQL expects.

Note: Our constraints aren't arbitrary—they model our ideal relationship between the Playlist and Track types. By accounting for the extra layers in our JSON response, we're able to provide this much more intuitive experience to our clients: they don't have to worry about parsing through all the extra details the REST API provides!

Let's import some of the packages we'll need to make this happen.

models/MappedPlaylist
import com.example.soundtracks.generated.types.Playlist;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSetter;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.type.TypeReference;
import java.io.IOException;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public class MappedPlaylist extends Playlist {
}

Next, we'll add a new method called mapTracks. We are calling it mapTracks, and not setTracks per setter method convention, because our parent class Playlist already has a setTracks method. We won't give MappedPlaylist a method of the same name because we don't intend to override the behavior of the parent setTracks method, as we'll see shortly.

To classify this method as a setter, we'll add the Jackson annotation @JsonSetter that we imported and pass it the string "tracks". This instructs the contents of our JSON object's "tracks" key to be passed into this method.

@JsonSetter("tracks")
public void mapTracks() {}

This method receives the incoming JsonNode that contains the "tracks" key and corresponding object.

@JsonSetter("tracks")
public void mapTracks(JsonNode tracks) {}

To pluck properties from this JsonNode, we'll initiate a new ObjectMapper. Then, we'll reach inside the tracks JSON object for the "items" key, another JsonNode type.

@JsonSetter("tracks")
public void mapTracks(JsonNode tracks) {
ObjectMapper mapper = new ObjectMapper();
JsonNode items = tracks.get("items");
}

We can use our mapper to take everything contained in items and cast it all to a list of track objects. But let's take another look at what the "items" JSON object looks like:

Response object
"items": [
// first object
{
"added_at": "2024-01-17T22:39:23Z",
"added_by": {},
"is_local": false,
"track": {
"name": "Lemon Tree",
"duration_ms": 191026
// other properties
}
},
// second object
{
"added_at": "2024-01-17T22:39:44Z",
"added_by": {},
"is_local": false,
"track": {
"name": "Citrus_Groove",
"duration_ms": 142785
// other properties
}
}
]

We have an array of objects, but we don't actually get to the details we want until we go another level deeper—into the "track" subnode of each object!

For this reason, we can't just cast the objects in the "items" array to Track types, shove them all in a List, and call it a day. They have a fundamentally different shape than what our generated Track type expects; it won't know what to do with the data we pass it!

Creating the MappedTrack class

Just like we did with MappedPlaylist, we need to create a class that builds on our generated Track type—this class will take care of unwrapping this object and setting those properties where we can grab them.

In the com.example.soundtracks.models package, create a new class called MappedTrack that extends the Track class.

models/MappedTrack
package com.example.soundtracks.models;
import com.example.soundtracks.generated.types.Track;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSetter;
import com.fasterxml.jackson.databind.JsonNode;
@JsonIgnoreProperties(ignoreUnknown = true)
public class MappedTrack extends Track {
}

Note: We're applying the @JsonIgnoreProperties annotation from the start because we can anticipate that there will be many properties on the JSON response that our Track class does not explicitly account for. This lets us ignore them, and avoid errors!

Each of the objects inside of "items" will be passed into this class, and we want to tell it what to do when it sees that "track" key inside of each.

Response object
{
"added_at": "2024-01-17T22:39:23Z",
"added_by": {},
"is_local": false,
"track": {
"name": "Lemon Tree",
"duration_ms": 191026
// other properties
}
},

So let's create a method that's responsible for receiving the "track" property and plucking out the properties it contains. We'll call this method setTrackProperties. To ensure it receives the "track" JSON Node, we'll annotate it with @JsonSetter("track") and give it a trackObject parameter.

@JsonSetter("track")
public void setTrackProperties(JsonNode trackObject) {}

Note: Here's another instance where we've given our setter a different name than what we might anticipate, such as setTracks. The Track class that MappedTrack extends already contains a setTracks class, and we don't intend to override this behavior.

As an extension of the Track class, MappedTrack still shares all of the properties that were generated automatically from our schema. This means that we can call the underlying setter methods for each property, passing in the values that we pluck from the "track" object. Here's the syntax:

@JsonSetter("track")
public void setTrackProperties(JsonNode trackObject) {
this.setId(trackObject.get("id").asText());
this.setName(trackObject.get("name").asText());
this.setDurationMs(trackObject.get("duration_ms").asInt());
this.setExplicit(trackObject.get("explicit").asBoolean());
this.setUri(trackObject.get("uri").asText());
}

Finally, we'll give our class one last method. This method, getTrack, will return our current instance, which we'll upcast to a Track type. (By "upcasting", we mean that this method returns an instance of the parent Track rather than MappedTrack.)

public Track getTrack() {
return this;
}

Let's jump back to MappedPlaylist to see why we actually need to upcast to a Track class rather than returning the MappedTrack instance.

Returning Tracks

Our MappedTrack class is now ready to receive the "track" portion of the JSON response, and unpack its properties (like id, name, duration_ms, etc.) into a Track instance we can use and operate on. We just need to pass that data in from our MappedPlaylist class!

Jump back into models/MappedPlaylist.

In our mapTracks method, we need to first deserialize each of the objects in the "items" array to a MappedTrack. We'll end up with a List of MappedTrack instances.

models/MappedPlaylist
@JsonSetter("tracks")
public void mapTracks(JsonNode tracks) {
ObjectMapper mapper = new ObjectMapper();
JsonNode items = tracks.get("items");
List<MappedTrack> trackList = mapper.readValue(items.traverse(), new TypeReference<>() {});
}

Calling mapper.readValue can result in a thrown exception, so we'll update our method signature accordingly.

// other imports
import java.io.IOException;
// class body
@JsonSetter("tracks")
public void mapTracks(JsonNode tracks) throws IOException {
ObjectMapper mapper = new ObjectMapper();
JsonNode items = tracks.get("items");
List<MappedTrack> trackList = mapper.readValue(items.traverse(), new TypeReference<>() {});
}

Recall that while this is the only method exclusive to the MappedPlaylist class, it also has access to the methods on its parent class, Playlist.

The setTracks method was generated from our schema, so it expects to be handed a List of Track instances (again, referring to the generated Track class). If we try to call setTracks with our List of MappedTrack objects, we'll get an error! This is not the type that setTracks knows how to accept.

generated/types/Playlist
public void setTracks(List<Track> tracks) {
this.tracks = tracks;
}

This is exactly why we've given MappedTrack the method that returns itself, upcast to its parent type: Track!

models/MappedTrack
public Track getTrack() {
return this;
}

This method essentially says, "Return me! But return me as a Track, not a MappedTrack."

So, for each item in our list of MappedTracks, we can call its getTrack method to get the instance upcast to a Track, with all of its properties included.

@JsonSetter("tracks")
public void mapTracks(JsonNode tracks) throws IOException {
ObjectMapper mapper = new ObjectMapper();
JsonNode items = tracks.get("items");
List<MappedTrack> trackList = mapper.readValue(items.traverse(), new TypeReference<>() {});
this.setTracks(trackList.stream().map(MappedTrack::getTrack).toList());
}

Putting everything together

The moment of truth! Restart your server, then jump back to Explorer. Let's try running that again.

Task!
query Playlist($playlistId: ID!) {
playlist(id: $playlistId) {
name
description
tracks {
id
name
}
}
}

With the following set in the Variables panel:

{ "playlistId": "6LB6g7S5nc1uVVfj00Kh6Z" }

Now, we should see that our functions just as we expect: we see our specific playlist's details, along with a list of its track names!

An alternate path

Now what about the featuredPlaylists path? It's another entry point to our schema that returns a list of Playlist types, which then has access to its tracks . Let's try it out.

GraphQL operation
query GetFeaturedPlaylists {
featuredPlaylists {
id
name
description
tracks {
id
name
explicit
uri
}
}
}

Uh-oh!

Terminal error
JSON parse error: Cannot invoke "com.fasterxml.jackson.databind.JsonNode.traverse()" because "items" is null

Another error—but what's going on now? We were able to return tracks data for a single playlist, but something's going wrong when we try to include it for each of our featured playlists.

Key takeaways

  • An 's can return types or other object types.
  • When a on an returns another , we can write complex queries that traverse from one object to another—no follow-up queries necessary!

Up next

Let's investigate the source of that error and fix it.

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.