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 - Query for playlist and track details in a single operation
Building the Track
type
As we learned in the lesson on SDL syntax, fields on GraphQL types don't have to return a basic scalar type—they can also return other object types!
For instance, we can add a tracks
field 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: Stringtracks: # 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 entity—in other words, we should make it its own GraphQL type called Track
.
This means that our Playlist
type should be updated: we need its tracks
field to return a list of Track
types!
Update your Playlist
type with the tracks
description and field highlighted below.
"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:
"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.
// 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
.
Let's recompile and jump back into the Explorer. We'll try running a query that calls for a playlist's tracks
details.
query Playlist($playlistId: ID!) {playlist(id: $playlistId) {namedescriptiontracks {idname}}}
And make sure that in the Variables panel, our $playlistId
variable is still set.
{ "playlistId": "6LB6g7S5nc1uVVfj00Kh6Z" }
But when we run the query... 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 query. 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!
{// 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.
public class Playlist {// other properties and methodsprivate 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 GraphQL schema 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 querying 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.
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:
"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.
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.
{"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 Track
s
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.
@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 importsimport 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.
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
!
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 MappedTrack
s, 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 query again.
query Playlist($playlistId: ID!) {playlist(id: $playlistId) {namedescriptiontracks {idname}}}
With the following set in the Variables panel:
{ "playlistId": "6LB6g7S5nc1uVVfj00Kh6Z" }
Now, we should see that our query 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
field. Let's try it out.
query GetFeaturedPlaylists {featuredPlaylists {idnamedescriptiontracks {idnameexplicituri}}}
Uh-oh!
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 object type's fields can return scalar types or other object types.
- When a field on an object type returns another object type, 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.
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.