The schema is the central document defining a GraphQL API. It is a mandatory part, which means you can be sure it is present for every deployment.
The schema defines types and fields. For example, a simple User object with a username and an email can be defined:
type User {
username: String!
email: String
}
Fields can reference other types too. For example, Users can belong to a Group:
type Group {
name: String!
}
type User {
username: String!
email: String
group: Group
}
With just these two things, you can define complex structures that can model to all sorts of use-cases.
The schema defines the structure of the graph of objects. In the above example, Users have a link to a Group, but that only means the client can move from a specific User object to its corresponding Group object.
Apart from types defined in the schema, fields can be scalar too. Some are defined by GraphQL so they are guaranteed to be available in every implementation:
String
Int
Float
Boolean
ID
ID
is a String, it's just not meant to be used for anything other than identifying things.
There are three special types in GraphQL: Query, Mutation, and Subscription.
The Query defines the entry points for a client query. Think about it like REST endpoints that users can call with some parameters and they return some data. The idea here is the same, but instead of returning a fixed structure, a Query provides the first object(s) in the graph.
For example, a query that returns a user by its username:
# schema
type Query {
user(username: String!): User
}
This gets a username
, which is a String
(and it's required, as we'll soon see) and returns a User
. Then the client can specify what parts of the User
it needs by specifying the result fields. And this can span through multiple objects, traversing the graph.
This client query asks for a specific user, its fields, then its group:
# query
query MyQuery {
user(username: "user1") {
username
email
group {
name
}
}
}
And the result:
{
"data": {
"user": {
"username": "user1",
"email": null,
"group": {
"name": "group 1"
}
}
}
}
Mutations are similar to queries, the difference is only conceptually. They also can get arguments and they can also return objects that clients can use to navigate the graph. But mutations are used to change data instead of query it.
# schema
type Mutation {
addUser(username: String!, email: String): User
}
And to add the user:
# query
mutation addUser {
addUser(username: "new user"){
username
email
}
}
This call creates a User object using the arguments provided (username
), then return the username and the email fields of the new User.
The only difference between a Query and a Mutation is conceptual:
But there is nothing that enforces this distinction. We'll see in the Resolvers chapter how to implement what a Query/Mutation does and nothing stops you if you write a Query that modifies data. There are things, such as Subscriptions and caching, that depend on a clear separation of read-only/read-write operations, but otherwise they work the same.
Finally, Subscriptions provide a real-time notification channel clients can subscribe to and get events when some data is changed. This is especially useful when you want to build a real-time frontend that automatically fetch new data when it is available. For example, a chat application can work by all participants subscibing to message updates, and whenever a participant wants to sends a message it calls a Mutation.
Subscriptions are not supported in all GraphQL implementations. AppSync supports them using a WebSocket channel.
Arguments in the schema can be defined for any fields of any type. In the previous examples we've seen how they work for Queries and Mutations:
type Mutation {
addUser(username: String!, email: String): User
}
type Query {
user(username: String!): User
}
But arguments also work for "normal" fields too. Let's say users can have tickets, such as in a ticketing system, and we want to provide a way to get a user's tickets with an optional date filter:
# schema
type Ticket {
text: String!
# POSIX time
created: Int!
}
type User {
username: String!
tickets(after: Int): [Ticket]
}
When a nested field needs arguments, the client query can specify that:
# query
query MyQuery {
user(username: "user1") {
username
tickets(after: 1639907490) {
text
created
}
}
}
When a field does not need arguments in the schema or a client query does not provide them the (...)
is missing. The username
field in the previous example does not need an argument, but the tickets
does. Also, when an argument is optional and the client query chooses not to use that, the parentheses are missing.
How the arguments are used is up to the implementation. GraphQL by default does not handle things like after
, limit
, offset
, or any other arguments.
In our examples so far all the fields contained a single value, for example, the User can be part of a single Group. GraphQL supports Lists to define a one-to-many relationship between objects. This is done using the [...]
construct.
For example, a Group can contain multiple (0 or more) Users:
# schema
type Group {
name: String!
users: [User]
}
type User {
username: String!
email: String
group: Group
}
In a client query there are no differences between a single value (Group
) or a List ([User]
). The response will contain a JSON array if the field is a List, and an object otherwise.
# query
query MyQuery {
user(username: "user1") {
username
email
group {
name
users {
username
}
}
}
}
And the response contains all the users in the group:
{
"data": {
"user": {
"username": "user1",
"email": null,
"group": {
"name": "group 1"
"users": [
{
"username": "user1"
},
{
"username": "user2"
}
]
}
}
}
}
GraphQL schema supports marking a field (or argument) as required by adding a !
after it. We've already defined this for the User type:
# schema
type User {
username: String!
email: String
}
Here, the username
is required, so the response can not be null
for this field, while the email
is optional. If a client queries both fields, the response is guaranteed to have a non-null value for the username
.
In case of Lists, the required indicator can go to two places, each specifying a different requirement:
[User!]
: The list can not contain null
s[User]!
: The list itself can not be null
Here's a table showing what each combination allows:
[User, User] | [User, null] | [] | null | |
---|---|---|---|---|
[User] | β | β | β | β |
[User!] | β | - | β | β |
[User]! | β | β | β | - |
[User!]! | β | - | β | - |
In practice, most Lists are the strictest variety ([User!]!
) as it does not prevent returning empty lists but the caller can be sure it only contains non-null items.
The same type system is used for arguments too. For example, this Query requires a username
and a list of non-null permissions
:
type Query {
user(username: String!, permissions: [String!]!): User
}
Directives are extra metadata for fields and types. For example, Amplify uses the @model
directive to generate DynamoDB tables, queries, and mutations for a type:
type Post @model {
id: ID! # id: ID! is a required attribute.
title: String!
tags: [String!]!
}
Also, access control can use directives to define who can get a field, query, or mutation:
type Query {
# everybody can get themselves
currentUser: User
# only admins can query all users
allUsers: [User]
@aws_cognito_user_pools(cognito_groups: ["admin"])
}
Directives come after the type of field definition, not before. If you've worked with Java annotations before it will require some getting used to.
GraphQL supports interfaces that fill a similar role that in most programming languages. When a field can return multiple types and they can be logically grouped into an abstract type, you can move the common structure into an interface and use that instead.
For example, let's say the system can handle two types of users: administrators and normal users:
type AdminUser {
username: String!
email: String
permissions: [String!]!
}
type NormalUser {
username: String!
email: String
}
A list that returns all users in the system could specify in interface that both user types implement:
# defines what is common in all users
interface User {
username: String!
email: String
}
type AdminUser implements User {
username: String!
email: String
permissions: [String!]!
}
type NormalUser implements User {
username: String!
email: String
}
Then the query can return User
s without defining the exact type:
# schema
type Query {
allUsers: [User!]!
}
Types that implement the interface need to list all the inherited fields too. This leads to some repetition in the schema, such as the username
is listed 3 times: for the User
, the AdminUser
, and the NormalUser
.
Client queries can use a special construct to distinguish between the concrete types as we'll discuss in the Inline fragments chapter:
# query
query MyQuery {
allUsers {
username
email
... on AdminUser {
permissions
}
}
}
The result will include the permissions
field only for administrators, and the username
and the email
for all users.
Interfaces are great when there is a common logical ancestor to all the types a field needs to return, but it's not the case every time. This is when union types are useful. The official docs has a great example for this use-case: search results.
A ticketing system might handle User
s and Ticket
s and needs to provide a search functionality. Here, it would unnecessarily complicate the schema to add a common interface for every type the search can return. Instead, it can define that it return either a User
or an Ticket
:
type User {
username: String!
email: String
}
type Ticket {
text: String!
}
union SearchResult = User | Ticket
type Query {
search(query: String!): SearchResult
}
The client queries can use inline fragments just like with interfaces:
# query
query MyQuery {
search(query: "test") {
... on User {
username
email
}
... on Ticket {
text
}
}
}
Enums are also a common feature in most programming languages. They are a scalar that's value can be from a predefined list.
For example, tickets can be in one of three states:
enum STATUS {
OPEN
IN_PROGRESS
DONE
}
# can be used in fields
type Ticket {
id: ID!
status: STATUS!
description: String
}
# can be used in arguments
type Query {
getTickets(status: STATUS): [Ticket!]!
}
Enums provide stricter validation than a String
field.
Arguments can use scalar types (String
, Int
, and so on), but they can't use types that you defined in the schema. For example, you can't pass a User
to a mutation.
This is where input types are useful. They are like normal types, but they are for arguments. They can also contain other input types and scalars.
For example, if you want a mutation to create a new User object, you need to create a new input type that is similar to output type:
input UserInput {
username: String!
avatar: String
cv: String
}
type User {
username: String!
avatar: String
cv: String
}
type Mutation {
addUser(user: UserInput): User
}
Using input types is usually only a technical requirement and something that duplicates some code. But since GraphQL needs them for arguments, you'll have to use them for anything that is not a scalar.
Let's make a GraphQL schema using the elements we discussed above! In this example, we have a simple ticketing system where tickets can be added and assigned to users. Tickets can also have attachments, that can be either images or files.
Types for Tickets and Users:
enum SEVERITY {
CRITICAL
NORMAL
}
type Ticket {
id: ID!
title: String!
description: String!
owner: User
severity: SEVERITY!
attachments: [Attachment!]!
}
type User {
id: ID!
name: String!
}
The SEVERITY
is an enum that restricts the value of the Ticket.severity
field to either CRITICAL
or NORMAL
. Then most of the fields are required, such as the title: String!
, but there is an optional field too: owner: User
. This means that a Ticket must have a title but it might not have an owner.
Also, this schema contains lists: attachments: [Attachment!]!
and [Ticket!]!
. With the double !
, the field must contain a list, and every item must be an Attachment
(first case) or a Ticket
(second case).
Types for Attachments:
interface Attachment {
id: ID!
url: String!
}
type Image implements Attachment {
id: ID!
url: String!
content_type: String!
}
type File implements Attachment {
id: ID!
url: String!
size: Int!
}
The Attachment
is an interface so it can appear as a type for fields, such as the attachments: [Attachment!]!
, but it has to be a concrete type. In this case, that can either be Image
or File
. Note that all these types need to define the common fields (id
and url
).
Now that we have the object graph, let's define two queries, one to get all the Tickets in the system, and the other for a free-form search that can return a Ticket or a User:
union SearchResult = Ticket | User
type Query {
getTickets: [Ticket!]!
search(query: String!): SearchResult
}
The search
query returns a union type: it can be either a Ticket
or a User
. Also, the search
query uses an argument that the resolver will get.
Finally, let's add two mutations, one to add a new Ticket and one to delete an existing one:
input TicketInput {
title: String!
description: String!
severity: SEVERITY!
}
type Mutation {
addTicket(details: TicketInput!, owner: ID): Ticket!
deleteTicket(id: ID!): ID
@aws_cognito_user_pools(cognito_groups: ["admin"])
}
schema {
query: Query
mutation: Mutation
}
The addTicket
defines a details
argument which is not a scalar type, so it needs the TicketInput
input type. With this, callers can define the title
and the description
in one argument, and an optional owner
in a second one.
Finally, the deleteTicket
uses a directive to restrict callers to the admin
Cognito group. Note that this is an AppSync-specific directive.