8. Using directives
20m

Overview

One of the biggest benefits of federation is the ability to compose multiple smaller schemas together into a single, declarative . Each is like a cog in the entire apparatus—and we can provide extra instructions for how its types and should be used when they're integrated into our graph. It's time to meet the next tool in our federation toolbox: schema .

In this lesson, we will:

  • Learn about the @external and @requires s
  • Resolve in the soundtracks using data from recipes

Introducing directives

A directive is like a special instruction for part of your or . We can use them to communicate extra details about a or customize responses. always start with the @ symbol.

Many are default directives—that is, they're part of the specification. Others, as we'll see shortly, are federation-specific . We apply these directives in to enable features that help our federated graphs run more smoothly.

We've already encountered one such : @key! It lets us use the same in both , and provides the common "key" the 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 to determine what we return from soundtracks?

@requires and @external

In order to resolve its recommendedPlaylists , the soundtracks requires more specific data from recipes.

The soundtracks might be unaware that recipes exists—but we're not!

We know that recipes provides a Recipe.name , and we have a mechanism to share that knowledge: when logic in one depends on data from another, we can use the @requires and @external . 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 needs some additional data in order to do its job of providing music suggestions. To indicate which it needs data from, we'll attach the @requires and set its fields property.

schema.graphqls
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 that in order for the soundtracks to resolve the Recipe.recommendedPlaylists , 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 from this , but our Recipe —as far as our soundtracks knows—doesn't actually have a by this name!

We can fix this using the @external .

@external

We reach for the @external when we reference a inside a that doesn't fulfill it. We need to mark these as @external because the data needs to come from somewhere outside of the we're working in.

Let's jump back into our soundtracks , and add the name to the Recipe . We'll mark it with @external.

schema.graphqls
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 function) for the Recipe.name lives in the recipes , which is why this needs to be marked as @external!

This gives us the freedom to refer to we know will exist once our has been composed, even if the we're working in doesn't define logic for them.

Great—our schema are applied, and our instructions to the 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 . What does this look like in practice? Let's return to our simplified .

query GetRecipeAndRecommendedSoundtracks {
recipe(id: "rec3j49yFpY2uRNM1") {
name
description
ingredients {
text
}
instructions
recommendedPlaylists {
id
name
description
}
}
}

If we revisit our , we'll see again that first the plans to execute a request to the recipes . Having retrieved that data, it will then send a request to soundtracks for the remainder of the .

http://localhost:4000

The operation in Sandbox, with the Query Plan Preview opened, showing a linear path to retrieve data from both subgraphs

When the recommendedPlaylists datafetcher is called, the representation passed in as a parameter now contains an additional property: name!

the Recipe entity representation
{__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!

schema.graphqls
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 is passed along with our representation because it's marked with the @key . The recommendedPlaylists states explicitly that it requires name. And because we defined name and marked it as @external, the is able to connect the dots and pass that data along with the __typename and id into the recommendedPlaylists datafetcher.

RecommendationDataFetcher
@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 doesn't include the Recipe.name we require for recommendedPlaylists?

Take the below, for example; we bypass a recipe's name , and go right to requesting its recommendedPlaylists!

query GetRecommendedSoundtracksForRecipe {
recipe(id: "rec3j49yFpY2uRNM1") {
recommendedPlaylists {
id
name
description
}
}
}

If we run this , 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 or not!

This is because the process of resolving recommendedPlaylists requires that the value of the name be passed along too. For this reason, the 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 to a datasource called SpotifyClient, which sent requests to a Spotify REST API.We used it to 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..

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

https://spotify-demo-api-fe224840a08c.herokuapp.com/v1/search?...

The JSON response from our query to this endpoint, showing a collection of playlists and items

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.

RecommendationDataFetcher
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.

RecommendationDataFetcher
private final SpotifyClient spotifyClient;
@Autowired
public 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.

RecommendationDataFetcher
@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 representation from the recipes , and returning it with additional data: our recommendedPlaylists! This gives the all of the components it needs to build a comprehensive Recipe object with all of its from different .

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 .

@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 , and now we need to set them on our recipe so that we can return all this data to the .

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

com.example.soundtracks/generated/types/Recipe
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.

models/MappedPlaylist
public Playlist getPlaylist() {
return this;
}

Now, instead of passing the playlists into setRecommendedPlaylists right away, we'll call each instance's getPlaylist method, and stick them all back into a new list.

RecommendationDataFetcher
List<MappedPlaylist> playlists = response.getPlaylists();
List<Playlist> preparedPlaylists = playlists.stream().map(MappedPlaylist::getPlaylist).toList();

Then we'll pass this new into the setRecommendedPlaylists method!

RecommendationDataFetcher
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 in all its glory.

query GetRecipeAndRecommendedSoundtracks {
recipe(id: "rec3j49yFpY2uRNM1") {
name
description
ingredients {
text
}
instructions
recommendedPlaylists {
id
name
description
tracks {
explicit
id
name
uri
durationMs
}
}
}
}

It's time to test it out. Jumping back into Sandbox, make sure the panel is filled out and... we've got data! And live recommendations for our particular recipe.

http://localhost:4000

A screenshot of the Explorer, succesfully querying a recipe and its recommended playlists

Key takeaways

  • The @requires is used to indicate that a in the schema depends on the values of other that are resolved by other . This ensures that externally-defined are fetched first, even if not explicitly requested in the original .
  • The @external is used to mark a as externally defined, indicating that the data for this field comes from another .

Up next

Okay, we've made lots of changes to our server, and rover dev helped us test everything out in a locally composed . Emphasis on the word local; to get our changes actually "live" (at least in the tutorial sense of the word), we need to tell about them!

In the next lesson, we'll take a look at how we can land these changes safely and confidently using and .

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.