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.

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 .

First off, we'll need to import these . We can do this in the same line that imported @key—the Federation 2 link we added to schema.graphql at the start of the course. Here, we'll simply add our new , separated by commas.

schema.graphql
extend schema @link(url: "https://specs.apollo.dev/federation/v2.5",
import: ["@key", "@requires", "@external"])

Now we'll dive into these one-by-one, then see how they work together.

@requires

Jump down to the Recipe type. The 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.graphql
type Recipe @key(fields: "id") {
id: ID!
"A list of recommended playlists to accompany the recipe"
recommendedPlaylists: [Playlist!]! @requires(fields: "name")
}

This syntax says: before soundtracks can resolve the recommendedPlaylists , it needs the name .

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.graphql
type Recipe @key(fields: "id") {
id: ID!
name: String @external
"A list of recommended playlists for this particular recipe. Returns 1 to 3 playlists."
recommendedPlaylists: [Playlist!]! @requires(fields: "name")
}

The (or resolver 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 .

query GetRecipeAndRecommendedSoundtracks {
randomRecipe {
id
name
description
ingredients {
text
}
instructions
recommendedPlaylists {
id
name
description
tracks {
id
name
explicit
durationMs
}
}
}
}

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 —passing along the recipe's id and its name!

http://localhost:4001

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

When the recommendedPlaylists is called, the representation it receives as a parameter now contains that additional name property!

the Recipe entity representation
{__typename=Recipe, id=rec3j49yFpY2uRNM1, name=Luscious Lemon and Thyme Chicken}

How was the name property automatically included in the object passed to the recommendedPlaylists ? It all comes down to the stub of Recipe that we added to the soundtracks schema!

schema.graphql
type Recipe @key(fields: "id") {
id: ID!
name: String @external
"A list of recommended playlists to accompany the recipe"
recommendedPlaylists: [Playlist!]! @requires(fields: "name")
}

We know why id was included in the representation: it's our Recipe 's primary key, after all. But name was added because our the Recipe type in soundtracks tells the that it needs it (courtesy of @requires), and that it comes from a different (as indicated by @external).

With these instructions in place, the is able to connect the dots: in addition to the __typename and id , it understands that it must pass the name as part of the representation as well.

resolvers.ts
__resolveReference: (reference) => {
// reference now contains: `__typename`, `id`, AND `name`
return reference
},

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 {
randomRecipe {
recommendedPlaylists {
id
name
description
}
}
}

If we run this , we'll see the same exact results as before—and when we inspect the contents of the 's reference parameter, we'll see that the value of name gets passed into the recommendedPlaylists whether it's explicitly part of the or not!

Because recommendedPlaylists requires name, the always fetches a recipe's name data first, and ensures it's passed along to the recommendedPlaylists .

Updating RecipeModel

The new addition to our Recipe type representation (the required name ) makes it necessary to update our RecipeModel to include it. Back in models.ts, add a new to the RecipeModel type for the name we expect to receive from recipes.

models.ts
export type RecipeModel = {
id: string;
name: string;
};

Finally, start the server so these new codegen settings are applied.

npm run dev

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 SpotifyAPI, 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/spotify-client.ts, and add a new method called search to the class..

spotify-client.ts
search() {
// TODO
}

This method will accept a single parameter that we'll call term, which is a string.

spotify-client.ts
search(term: string) {
// TODO
}

Before we complete our call to this endpoint, let's take a look at the response shape by opening up this example search query in a new browser tab.

This URL has a q parameter of "Luscious Lemon and Thyme Chicken". It also specifies a parameter called type, which is playlist (since that's what we're looking for!). Using this search term, the endpoint 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

Let's construct the call to the search endpoint. We'll give it an object containing its params in another object that includes the following properties:

  • q, which is our search term, and
  • type, which we'll hardcode as "playlist".
spotify-client.ts
search(term: string) {
this.get('search', {
params: {
q: term,
type: "playlist"
}
});
}

We need to pluck some properties from the JSON object we get as a response, so we'll make the function async and await the results of the call in a new called response.

async search(term: string) {
const response = await this.get('search', {
params: {
q: term,
type: "playlist"
}
});
}

Next, we'll use optional chaining and the nullish coalescing operator to return response.playlists.items, or an empty array.

async search(term: string) {
const response = await this.get('search', {
params: {
q: term,
type: "playlist"
}
});
return response?.playlists?.items ?? [];
}

Let's also add our type definitions for the type of data we expect playlists and items to be.

async search(term: string) {
const response = await this.get<{ playlists: { items: PlaylistModel[] }}>('search', {
params: {
q: term,
type: "playlist"
}
});
return response?.playlists?.items ?? [];
}

Returning recommendedPlaylists

Next, let's actually call that method. We'll jump back into the resolvers.ts file.

First, delete the hardcoded playlist objects the recommendedPlaylists returns.

resolvers.ts
recommendedPlaylists: (parent, args, contextValue, info) => {
- const playlists = [
- { id: "1",
- name: "Rock n' Roll",
- description: "A rock n' roll playlist",
- tracks: {
- items: [
- {
- track: {
- id: "6",
- name: "Rockin' out",
- duration_ms: 13434,
- explicit: false,
- uri: 'uri-string'
- }
- }
- ]
- },
- },
- { id: "2",
- name: "Pop",
- description: "A pop playlist",
- tracks: {
- items: [
- {
- track: {
- id: "7",
- name: "Pop it up",
- duration_ms: 13433,
- explicit: false,
- uri: 'uri-string'
- }
- }
- ]
- },
- },
- ];
- return playlists;
},

In this function, we'll of course need the name from the Recipe ; we won't need args, so we'll replace it with _. We'll destructure our contextValue parameter to get access to the new method we created on our class, and we can omit info.

recommendedPlaylists: ({ name }, _, { dataSources }) => {
// TODO
};

Now we'll access the spotifyAPI.search method from dataSources, pass in the name, and return the results. Here's what your method should look like.

recommendedPlaylists: ({ name }, __, { dataSources }) => {
return dataSources.spotifyAPI.search(name);
};

The dream query

It's the moment of truth. Let's revisit our dream in all its glory.

query GetRecipeAndRecommendedSoundtracks {
randomRecipe {
id
name
description
ingredients {
text
}
instructions
recommendedPlaylists {
id
name
description
tracks {
id
name
explicit
durationMs
}
}
}
}

It's time to test it out. Make sure that your soundtracks server is still running (without errors), and that our rover dev process is still active.

Jumping back into Sandbox, we'll paste the into the Operations panel, hit the submit button, and... we've got data!

👏👏👏 Recipe details, instructions, ingredients… and the perfect playlists to cook along to. Woohoo!

http://localhost:4001

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.