Variables, fragments and directives
Keeping values out of the query string, and reusing pieces of a query.
Real clients don't write a new query for every book. They write one query with a hole in it, and fill the hole at run time. This lesson covers the three GraphQL features for that: variables for values, fragments for repeated fields, and directives for fields that come and go.
Building the string by hand
The obvious way to put a value into a query is string building:
// Don't do this
const query = `{ books(genre: "${genre}") { title } }`;It works until someone searches for a genre with a double quote in it. Then the query doesn't parse, or worse, the value closes the string early and the rest of it becomes part of the query. That's the same class of bug as SQL injection. It also makes every request a different query string, so servers that cache parsed queries, or allow only a list of known queries, can't do either.
Variables
A variable starts with $. You declare it after the operation name, with a type, and then use it wherever a value would go:
query BooksByGenre($genre: String) {
books(genre: $genre) {
title
}
}The values travel separately, in a variables object next to query in the JSON body:
{ "genre": "science-fiction" }The query text stays the same for every request. Only the variables change. Here's a full request with curl:
curl -X POST https://api.testingapis.com/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "query GetBook($id: Int!) { book(id: $id) { title author } }",
"variables": { "id": 2 }
}'{
"data": {
"book": {
"title": "The Pragmatic Programmer",
"author": "Andrew Hunt & David Thomas"
}
}
}/graphql{"query":"query GetBook($id: Int!) { book(id: $id) { title author } }","variables":{"id":2}}The type after the variable name matters. Int!means an integer that must be provided. The exclamation mark is GraphQL's word for required; the schema lessoncovers it properly. A variable's type has to fit the argument it's used in, so declaring $id: String and passing it to book(id:) fails validation before anything runs.
Variables that go wrong
The server checks variables against their declared types. Try these on any GraphQL API, because client code gets them wrong all the time:
| Variables sent | What this API returns |
|---|---|
{ "id": 2 } | The book. |
{ "id": "two" } | 400: Int cannot parse the given literal of type `StringValueNode`. |
{} | 400: Variable `id` is required. |
{ "id": 2, "extra": true } | The book. Variables nobody declared are ignored. |
One mistake is common enough to call out on its own. Some clients encode the variables to a JSON string, then encode the whole body again, so variables arrives as a string instead of an object:
{
"query": "query GetBook($id: Int!) { book(id: $id) { title } }",
"variables": "{\"id\":1}"
}"Expected an object or a null-token, but found a String-token with value `{\"id\":1}`."If you see this error, the bug is in the client, not the API. Give it a test of its own if you're testing a client library or an SDK.
Defaults and null
Optional variables can have a default value:
query BooksByGenre($genre: String = "science-fiction") {
books(genre: $genre) {
title
}
}Send it with no variables and you get the five science fiction books: Dune, The Left Hand of Darkness, Neuromancer, The Three-Body Problem and Foundation. Now send { "genre": null }. You might expect the default to kick in. It doesn't. An explicit nullreplaces the default, so the filter is off and all 25 books come back. That's what the GraphQL specification asks for, and it surprises people. A client that sends nullfor "the user didn't choose" gets every record instead of the default filter. Test the three cases separately: variable missing, variable null, and variable set.
Fragments
When several parts of a query ask for the same fields, a fragment lets you write the list once. A fragment has a name and says which type it applies to:
query TwoBooks {
first: book(id: 1) { ...BookCard }
second: book(id: 3) { ...BookCard }
}
fragment BookCard on Book {
title
author
publishedYear
}{
"data": {
"first": {
"title": "Clean Code",
"author": "Robert C. Martin",
"publishedYear": 2008
},
"second": {
"title": "Designing Data-Intensive Applications",
"author": "Martin Kleppmann",
"publishedYear": 2017
}
}
}The ...BookCardis replaced by the fragment's fields before the query runs. The response has no trace of the fragment. In apps, fragments usually map to components: the book card component declares the fields it needs as a fragment, and every query that shows a book card includes it.
Directives
Directives start with @ and change how a field is handled. Two are built into every GraphQL server. @include(if: $x) keeps the field only when $x is true; @skip(if: $x) drops it when $x is true.
query Book($id: Int!, $short: Boolean!) {
book(id: $id) {
title
author @skip(if: $short)
pages @skip(if: $short)
}
}With { "id": 2, "short": true } the response has only the title. With "short": falseit has all three fields. The skipped fields are left out of the response entirely. They don't come back as null. A test that checks book.author === null to confirm a field was skipped will fail; check that the key is absent instead.
Several operations in one document
A single query string can hold more than one named operation:
query First { book(id: 1) { title } }
query Second { book(id: 2) { title } }Send it as it is and the server doesn't know which to run: The operation name can only be omitted if there is just one operation in a GraphQL document. Add operationName to the body to pick one:
{
"query": "query First { book(id: 1) { title } } query Second { book(id: 2) { title } }",
"operationName": "Second"
}That returns The Pragmatic Programmer, and only that. The other operation doesn't run. Some teams keep all of an app's queries in one file and send the whole file with an operation name, so know the rule. An operationNamethat isn't in the document, such as Third, gets a 400 and The specified operation `Third` cannot be found.
Exercise
Write a query named MoviesByGenre that takes an optional $genre variable with a default of "Drama" and returns each movie's title and year, plus its director only when a $withDirector variable is true. Send it three times: with no genre at all, with genre set to null, and with genre set to "Crime". Count the movies each time and explain the difference.