Reading the schema
Types, the exclamation mark, lists, and asking the server to describe itself.
Every GraphQL API has a schema: a list of every type, every field on each type, and the type of each field. The server checks each query against it before running anything, which is why a typo in a field name gets a precise error instead of a silent null. For a tester, the schema is the closest thing to a complete specification you're likely to get. This lesson is about reading it.
Reading a type
Schemas are usually written in a short notation called SDL, the schema definition language. Here is the Book type on this API:
type Book {
id: Int!
title: String!
author: String!
isbn: String!
genre: String!
publishedYear: Int!
pages: Int!
language: String!
price: Decimal!
rating: Float!
available: Boolean!
tags: [String!]!
}Each line is a field name, a colon and a type. Int, Float, String and Boolean are built into GraphQL, along with ID, which this API doesn't use. The rest are defined by the API. Decimal and Longare custom scalars here: a decimal number for money and a 64-bit integer for big counts like a film's box office takings.
Custom scalars need care, because the schema only gives the name. It doesn't say how the value is written in JSON. On this API both are plain JSON numbers ("price": 37.99), so each one turns into a JavaScript Numberwhen parsed, which is a 64-bit float. That's fine for 37.99 and for a box office of 58,300,000. It isn't fine for a Long above 9,007,199,254,740,991, where JavaScript starts rounding. If an API you test has Long fields that can grow that large, send one through and compare the digits.
The exclamation mark
A ! after a type means the value can never be null. Without it, null is allowed. That single character decides a lot, so here is exactly what it promises.
| Type | Can the field be null? | Can items in the list be null? |
|---|---|---|
String | Yes | Not a list |
String! | No | Not a list |
[String] | Yes | Yes |
[String!] | Yes | No |
[String]! | No, but it can be an empty list | Yes |
[String!]! | No, but it can be an empty list | No |
The tags on a book are [String!]!: always a list, never null, and every tag a real string. An empty list is still allowed. A test for tags should accept [] and should fail on null or [null].
Nullable fields are where the interesting cases are. Look at the Address type:
type Address {
street: String!
city: String!
state: String!
zip: String!
country: String!
geo: GeoLocation
}Every field is required except geo. On this API, every employee in the Engineering department comes back with "geo": null. A client that reads address.geo.lat without checking will crash on the first one. When you see a nullable field, find a record where it actually is null and check that the app copes.
Arguments have types too
Entry points are fields on a special type called Query:
type Query {
book(id: Int!): Book
books(genre: String): [Book!]!
country(code: String!): Country
employees(department: String): [Employee!]!
# ...and 16 more
}Read the two book fields closely. book(id: Int!): Book takes a required integer and returns a Bookthat may be null. That's how not-found is expressed here: ask for book 999 and you get null, with no error. books(genre: String): [Book!]! takes an optional string and always returns a list. A filter that matches nothing returns [], never null.
These promises are checked by the server on the way out. If a resolver tried to return null for a ! field, the server would replace the null with an error and null out the parent object instead. The errors lesson shows that happening.
Asking the server for its schema
GraphQL servers can describe themselves. Fields starting with two underscores, like __schema and __type, return the schema as data. This is called introspection, and it's how IDEs offer autocomplete and how tools generate client code.
{
__type(name: "Book") {
name
fields {
name
type { kind name ofType { kind name } }
}
}
}{
"data": {
"__type": {
"name": "Book",
"fields": [
{
"name": "id",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": { "kind": "SCALAR", "name": "Int" }
}
},
{
"name": "title",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": { "kind": "SCALAR", "name": "String" }
}
},
...
]
}
}
}/graphql{"query":"{ __type(name: \"Book\") { name fields { name type { kind name ofType { kind name } } } } }"}Int! appears as a NON_NULL wrapper around the Int scalar, which is why the query has to ask for ofType. A list of non-null strings is a NON_NULL around a LIST around another NON_NULL around String, three levels deep. You rarely read this by hand. Tools turn it back into SDL.
What the schema doesn't tell you
A schema says what shape the data has. It says very little about which values are allowed. Adding a todo on this API takes a priority of type String!, so as far as the schema is concerned, any string will do. Send "urgent" and the server says otherwise:
{
"errors": [
{
"message": "Field 'priority' must be one of: high, medium, low.",
"locations": [{ "line": 1, "column": 12 }],
"path": ["addTodo"]
}
],
"data": null
}The real rule (high, medium or low) exists only in the server code. It doesn't show in the IDE, a code generator can't know it, and you only find it by sending a bad value. Had the field been declared as an enum, the schema itself would carry the rule:
enum Priority {
HIGH
MEDIUM
LOW
}
type Mutation {
addTodo(title: String!, userId: Int!, priority: Priority!): Todo!
}Then "urgent" would be rejected during validation, like any other type error, and every client would see the three options. When you find a Stringfield that only accepts a handful of values, raise it. It isn't a bug, but it is a gap in the contract.
Relationships are another gap on this particular API. A Post has a userId, but no author field that returns the user, so a client has to make a second query to fetch the author. Many GraphQL APIs do link types to each other, and on those the schema is also a map of how deep a query can go. That matters for performance, as lesson 8 explains.
The schema as a contract
Because the schema is a file, you can keep a copy and compare it with the next release. The changes that break existing clients are fairly predictable:
- A field or type is removed or renamed. Every query that asks for it now fails validation.
- A field's return type loses its
!. Clients that never checked for null start crashing. - An argument becomes required, or a new required argument is added.
- An argument's type changes, say from
InttoString. - A value is removed from an enum that clients send or receive.
Adding a new type, a new field or a new optional argument is safe, because no existing query asks for it. Tools such as GraphQL Inspector compare two schemas and sort the changes into breaking and safe. Running one in CI against the schema of the release you're replacing catches most accidental breaks before anyone writes a test for them. The REST version of this idea is in schemas and contract testing.
Exercise
Use introspection to find the type of the managerId field on Employee and the boxOffice field on Movie. Which one can be null? Then query all employees and find one whose managerIdreally is null. What does that person's job title suggest about why?