Overview
One of the biggest benefits of federation is the ability to compose multiple smaller schemas together into a single, declarative document. Each subgraph schema is like a cog in the entire supergraph apparatus—and we can provide extra instructions for how its types and fields should be used when they're integrated into our graph. It's time to meet the next tool in our federation toolbox: schema directives.
In this lesson, we will:
- Learn about the
@external
and@requires
directives - Resolve fields in the
soundtracks
subgraph using data fromrecipes
Introducing directives
A GraphQL directive is like a special instruction for part of your GraphQL schema or operation. We can use them to communicate extra details about a field or customize responses. Directives always start with the @
symbol.
Many directives are default directives—that is, they're part of the GraphQL specification. Others, as we'll see shortly, are federation-specific directives. We apply these directives in subgraph schemas to enable features that help our federated graphs run more smoothly.
We've already encountered one such directive: @key
! It lets us use the same entity in both subgraphs, and provides the common "key" the router uses to link all the data together.
But as we saw in the last lesson, soundtracks
doesn't have enough information about a recipe to provide useful playlist recommendations. It makes sense to identify each recipe by its unique id
(some recipes with the same name might exist!), but we can't make recommendations based on an id
!
A recipe's name
, however, might be a bit more useful. How can we use that bit of information from the recipes
subgraph to determine what we return from soundtracks
?
@requires
and @external
In order to resolve its recommendedPlaylists
field, the soundtracks
subgraph requires more specific data from recipes
.
The soundtracks
subgraph might be unaware that recipes
exists—but we're not!
We know that recipes
provides a Recipe.name
field, and we have a mechanism to share that knowledge: when logic in one subgraph depends on data from another, we can use the @requires
and @external
directives. Let's dive into these one by one, and see how we use them together.
@requires
Let's return to our schema.
Here, our Recipe.recommendedPlaylists
field needs some additional data in order to do its job of providing music suggestions. To indicate which fields it needs data from, we'll attach the @requires
directive and set its fields
property.
type Recipe @key(fields: "id") {id: ID!"A list of recommended playlists to accompany the recipe"recommendedPlaylists: [Playlist!] @requires(fields: "name")}
This syntax effectively instructs the router that in order for the soundtracks subgraph to resolve the Recipe.recommendedPlaylists field, it also needs the recipe's name field.
These instructions are clear, but we need to make one more update to the Recipe
type. We're referring to its name
field from this subgraph, but our Recipe
entity—as far as our soundtracks
subgraph knows—doesn't actually have a field by this name!
We can fix this using the @external
directive.
@external
We reach for the @external
directive when we reference a field inside a subgraph that doesn't fulfill it. We need to mark these fields as @external
because the data needs to come from somewhere outside of the subgraph we're working in.
Let's jump back into our soundtracks
subgraph, and add the name
field to the Recipe
entity. We'll mark it with @external
.
type Recipe @key(fields: "id") {id: ID!"The name of the recipe"name: String @external"A list of recommended playlists for this particular recipe. Returns 1 to 3 playlists."recommendedPlaylists: [Playlist!] @requires(fields: "name")}
The datafetcher (or resolver function) for the Recipe.name
field lives in the recipes subgraph, which is why this field needs to be marked as @external
!
This directive gives us the freedom to refer to fields we know will exist once our supergraph has been composed, even if the subgraph we're working in doesn't define logic for them.
Great—our schema directives are applied, and our instructions to the router are complete. Now, let's step through how the router will actually execute them!
Required fields and the router
So we know that we require a recipe's name
before we can actually fulfill the recommendedPlaylists
field. What does this look like in practice? Let's return to our simplified query.
query GetRecipeAndRecommendedSoundtracks {recipe(id: "rec3j49yFpY2uRNM1") {namedescriptioningredients {text}instructionsrecommendedPlaylists {idnamedescription}}}
If we revisit our Query Plan, we'll see again that first the router plans to execute a request to the recipes
subgraph. Having retrieved that data, it will then send a request to soundtracks
for the remainder of the query.
When the recommendedPlaylists
datafetcher is called, the entity representation passed in as a parameter now contains an additional property: name
!
{__typename=Recipe, id=rec3j49yFpY2uRNM1, name=Luscious Lemon and Thyme Chicken}
How was the name
property automatically included in the object that was passed to the recommendedPlaylists
datafetcher? It all comes down to the stub of Recipe
that we added to the soundtracks
schema!
type Recipe @key(fields: "id") {id: ID!"The name of the recipe"name: String @external"A list of recommended playlists to accompany the recipe"recommendedPlaylists: [Playlist!] @requires(fields: "name")}
The id
field is passed along with our entity representation because it's marked with the @key
directive. The recommendedPlaylists
field states explicitly that it requires name
. And because we defined name
and marked it as @external
, the router is able to connect the dots and pass that data along with the __typename
and id
fields into the recommendedPlaylists
datafetcher.
@DgsEntityFetcher(name="Recipe")public Recipe recommendedPlaylists(Map<String, String> representation) {// contents of "representation":// {__typename=Recipe, id=rec3j49yFpY2uRNM1, name=Luscious Lemon and Thyme Chicken}}
But what happens if our query doesn't include the Recipe.name
field we require for recommendedPlaylists
?
Take the operation below, for example; we bypass a recipe's name
field, and go right to requesting its recommendedPlaylists
!
query GetRecommendedSoundtracksForRecipe {recipe(id: "rec3j49yFpY2uRNM1") {recommendedPlaylists {idnamedescription}}}
If we run this query, we'll see the same exact results as before—and upon inspecting the contents of the datafetcher's representation
parameter, we'll see that the value of name
gets passed into the recommendedPlaylists
datafetcher whether it's explicitly part of the query or not!
This is because the process of resolving recommendedPlaylists
requires that the value of the name
field be passed along too. For this reason, the router always fetches a recipe's name
data first, and ensures it's passed along to the recommendedPlaylists
datafetcher.
Recommending real playlists
One step closer to removing our hard-coded data! With the name
of our recipe in-hand, we can fire off a request for some good playlists that will actually fit the mood of what we're cooking up.
In the first course in this series, we connected our soundtracks
subgraph to a datasource called SpotifyClient
, which sent requests to a Spotify REST API.We used it to query for particular playlists, or even add new tracks to an existing playlist.
Now we need to set up a method we can call to search for playlists.
The search
method
Jump into datasources/SpotifyClient
, and add a new method called search
to the class..
public void search(String term) {}
This method will accept a single parameter that we'll call term
, which is a String
.
Our WebClient
instance is already set up in this file as a property called client
, so we can set up a new request right away to the /search
endpoint of our API. We'll add a few query parameters to suit the endpoint's needs (see the documentation for greater detail), such as the search term ("q"
) and the type of data we'll be searching ("playlist"
).
public void search(String term) {builder.get().uri(uriBuilder -> uriBuilder.path("/search").queryParam("q", term).queryParam("type", "playlist").build()).retrieve()}
To complete our call to this endpoint, we need to give our method a class that it can convert the response body to. Let's take a look at the response shape by opening up a new browser tab and pasting the following URL.
https://spotify-demo-api-fe224840a08c.herokuapp.com/v1/search?q=Luscious%20Lemon%20and%20Thyme%20Chicken&type=playlist
This URL takes in a search term of "Luscious Lemon and Thyme Chicken" and filters through a list of playlists to return two or three that might suit the recipe's theme. And when we navigate to this page, we see a big response object that starts with a "playlists"
property. We don't dig into the actual details of our playlist objects until we reach the "items"
property, at which point we have a big array we can actually work with.
This response takes the same shape as a class that already exists in our project—the PlaylistCollection
class, which already exists.
We'll add PlaylistCollection
as the class we pass to the body
method at the end of our request.
Be sure to also update the return type of the function—PlaylistCollection
! That's it for the search
method implementation.
public PlaylistCollection search(String term) {return builder.get().uri(uriBuilder -> uriBuilder.path("/search").queryParam("q", term).queryParam("type", "playlist").build()).retrieve().body(PlaylistCollection.class);}
Returning recommendedPlaylists
Next, let's actually call that method. We'll jump back into the RecommendationDataFetcher
file, and bring in some imports. We'll need our SpotifyClient
, and the Autowired
annotation from Spring.
import com.example.soundtracks.models.MappedPlaylist;import com.example.soundtracks.models.PlaylistCollection;import com.example.soundtracks.datasources.SpotifyClient;import org.springframework.beans.factory.annotation.Autowired;
At the top of our class, we'll create a spotifyClient
property, and initialize it in a new constructor method.
private final SpotifyClient spotifyClient;@Autowiredpublic RecommendationDataFetcher(SpotifyClient spotifyClient) {this.spotifyClient = spotifyClient;}
Finally, we'll jump down to the recommendedPlaylists
method and clear out all of the mocked data! We're ready for the real world.
Here's what your method should look like.
@DgsEntityFetcher(name="Recipe")public Recipe recommendedPlaylists(Map<String, String> representation) {Recipe recipe = new Recipe();return recipe;}
Note: Remember that our method's return type is still Recipe
—we're taking in the entity representation from the recipes
subgraph, and returning it with additional data: our recommendedPlaylists
! This gives the router all of the components it needs to build a comprehensive Recipe
object with all of its fields from different subgraphs.
First, let's pull out the name
we get from representation
.Then, we'll use spotifyClient.search()
and pass in the name
, storing the results in a response
variable.
@DgsEntityFetcher(name="Recipe")public Recipe recommendedPlaylists(Map<String, String> representation) {Recipe recipe = new Recipe();String name = representation.get("name");PlaylistCollection response = spotifyClient.search(name);return recipe;}
Because our response returns a PlaylistCollection
type, we have access to its getPlaylists
method. This will return a List
of MappedPlaylist
types.
List<MappedPlaylist> playlists = response.getPlaylists();
We've got our recommended playlists neatly tucked away in the playlists
variable, and now we need to set them on our recipe
so that we can return all this data to the router.
But there's an issue: calling recipe.setRecommendedPlaylists
with the playlists produces an error!
List<MappedPlaylist> playlists = response.getPlaylists();recipe.setRecommendedPlaylists(playlists); /* 🚫 ERROR! 🚫 */
There's a mismatch between the parameter the setRecommendedPlaylists
method expects, and what we're actually passing it.
If we look closely, the playlists
variable is a List
of MappedPlaylist
types.
But when we inspect the Recipe
class that DGS generated for us, we'll find that it expects a List
of Playlist
types.
public class Recipe {private String id;/*** The name of the recipe*/private String name;/*** A list of recommended playlists to accompany the recipe*/private List<Playlist> recommendedPlaylists;// other methods}
You might recall that we ran into a similar situation in part one of this series: we're working with a child class, but some part of our code expects an instance of the parent class.
We can jump into our MappedPlaylist
class and give it a new method: getPlaylist
. This method will return itself (this
), upcast as an instance of the class it inherits from, Playlist
.
public Playlist getPlaylist() {return this;}
Now, instead of passing the playlists
variable into setRecommendedPlaylists
right away, we'll call each instance's getPlaylist
method, and stick them all back into a new list.
List<MappedPlaylist> playlists = response.getPlaylists();List<Playlist> preparedPlaylists = playlists.stream().map(MappedPlaylist::getPlaylist).toList();
Then we'll pass this new variable into the setRecommendedPlaylists
method!
List<MappedPlaylist> playlists = response.getPlaylists();List<Playlist> preparedPlaylists = playlists.stream().map(MappedPlaylist::getPlaylist).toList();recipe.setRecommendedPlaylists(preparedPlaylists);
And with that, we should have all errors taken care of!
The dream query
It's the moment of truth. Let's revisit our dream query in all its glory.
query GetRecipeAndRecommendedSoundtracks {recipe(id: "rec3j49yFpY2uRNM1") {namedescriptioningredients {text}instructionsrecommendedPlaylists {idnamedescriptiontracks {explicitidnameuridurationMs}}}}
It's time to test it out. Jumping back into Sandbox, make sure the Operation panel is filled out and... we've got data! And live recommendations for our particular recipe.
Key takeaways
- The
@requires
directive is used to indicate that a field in the schema depends on the values of other fields that are resolved by other subgraphs. This directive ensures that externally-defined fields are fetched first, even if not explicitly requested in the original GraphQL operation. - The
@external
directive is used to mark a field as externally defined, indicating that the data for this field comes from another subgraph.
Up next
Okay, we've made lots of changes to our server, and rover dev
helped us test everything out in a locally composed supergraph. Emphasis on the word local; to get our changes actually "live" (at least in the tutorial sense of the word), we need to tell GraphOS about them!
In the next lesson, we'll take a look at how we can land these changes safely and confidently using schema checks and launches.
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.