A server that receives requests.
A GraphQL schema that's attached to the server.
A client-side mechanism for communicating with a GraphQL schema, via a server.
In Pup, all three of these are provided for you, ready to use out-of-the-box. In order to start customizing Pup for your application, it's important to understand what "schema" refers to in GraphQL as that is where you will define what data is available in your application and how to access it.
All of the available Types of data in your application.
All of the Queries that can be performed in your application.
All of the Mutations that can be performed in your application.
All of the Subscriptions that can be used in your application.
In addition to these core conventions, your GraphQL schema can also be used to define "helper" types of data like Fragments and Enums.
Your schema is best thought of as the "registration" mechanism that defines all of the possible ways to consume data in your application. When a request is made to your GraphQL server, it's passed to your schema and then your schema decides how to respond or resolve that request.
When we think about the traditional CRUD (create, read, update, delete) pattern for an application, your queries handle the "R" or "read" part and your mutations handle the "CUD" or "create, update, delete" part.
When a query request is made from the client of our application, it's done so by specifying a query field which maps to a resolver. A resolver is a function that—like the name implies—resolves the request (i.e., it returns the requested data).
When a mutation request is made from the client of our application, it's done so by specifying a mutation field which maps to a resolver. Again, just like with queries, a mutation resolver is a function that resolves the request (i.e., mutates or changes the data).
Whenever a resolver function—query or mutation—returns some data, it's passed or filtered through a Type before being returned to the client/request.
How a request flows in GraphQL.
Here, in our Document type we've omitted the revisions field visible on our example document in our database. Assuming we performed a query request from the client requesting a document with the _id of 123, when it's returned, even though revisions is defined on that document, it will be "scrubbed" by our GraphQL Type because it's not defined as a field supported by the Document data type.
Here, our query is asking for a document with the _id 123 and specifies that when that document is returned, it should only include the _id and title fields. This is unique to GraphQL, even though our Document type defines three possible fields that can be returned, from the client, we can request only the specific fields we need for our UI.
Schema sees a query for the document field defined under the type Query block (expecting it to return some data matching a required _id argument as a String that itself matches the Document type).
Schema locates the matching resolver function with the same name under the resolvers.Query object and calls the corresponding function.
Data is passed through the type for validation/filtering.
Data is returned to client.
Types are the essential building block of your schema. Types define the shape of the data that's coming in and out of your schema. Though the type keyword is most common, there are several other types of...types like input and enum.
In your schema, there are three "root" types type Query, type Mutation, and type Subscription along with your custom types. These three root types can be seen in the example above and are responsible for defining the query, mutation, and subscription fields that can be accessed from the client along with their expected return values.
The idea here is that when you define a field (e.g., documents: [Document]), you're telling GraphQL "I want users to be able to type in a query that looks like this and call to a resolver function with the same name:"
That resolver function, then, is defined in the resolvers.Query object on your schema.
An example of a basic type is displayed on the right. This represents the Document type in Pup. Types are simple. They define the fields that a piece of data can have and the scalar types those fields are expected to contain.
Notice that types can be combined. In our example Document type, the comments field is saying that it expects an array of the Comment type to be returned for that field. What's neat about this is that this can be defined as a nested query, meaning, we don't have store the comments on the document itself. Instead, we can retrieve the comments for the document separately and combine them with the document before sending a response back to the server.
inputs define the shape of input data or arguments passed to queries and mutations. Instead of specifying argument fields individually when defining a query or mutation, you can pass them as an object, defining its type as an input.
enums are pre-defined lists of values that are allowed by a given field on a type.
Example UserInput type taken from /api/Users/types.js.
key: String # What is the key value you'll access this setting with?
label: String # The user-facing label for the setting.
Notice that the AllowedSettingType enum is used to specify the type of data expected by the type field on the UserSetting type. This means only a value of 'boolean', 'string', or 'number' can be passed for this field's value. Example taken from /api/UserSettings/types.js.
A type for the data being returned needs to be added to the typeDefs object.
A field needs to be defined on the root Query type.
A resolver function needs to be defined on the resolvers.Query object.
We covered types above, but need to discuss defining a field on the root Query type along with a resolver function. On the right, we can see the schema from Pup displayed, simplified to show the code for querying a list of documents.
The example here is purposefully simplified to remove imports so that you can see how things wire together. Keep in mind that in Pup, parts of the schema like types and resolvers are stored in their own files and imported into /startup/server/api.js.
Here, we've defined all three parts outlined above. Pay close attention to the structure and where things are being placed.
The important thing to note here is the connection between the documents nested inside of type Query and the documents nested inside of resolvers.Query. The former defines that field as something we can query against and the resolver function is how we resolve that query.
Though it may seem like we're doing work twice here, one part is typing our query and one part is actually handling it. It is a bit more work than some data systems, but it gives us 100% clarity over what is and isn't happening.
parent the parent query, if one exists.
args any arguments passed to the query.
context a miscellaneous context object. In Pup, this contains a context.user property containing the logged in user if one exists.
Here, in response to our documents resolver function, we're saying "if there's a user with an _id, find all of the documents owned by that user and .fetch() them as an array. If there's not a user, return an empty array."
It's important to note that "the GraphQL part" stops once we're inside of our function. At that point, we can run whatever code we'd like to resolve the query—that's up to us. Here, we're using the Documents collection that's built-in to Pup and uses Meteor's MongoDB implementation.
Just the same, we could make an API call to a third-party service here and GraphQL could care less. It's only concern is that the data returned matches the type specified for the field on the type Query. In this case, an array of objects resembling the Document type.
We've purposefully ignored something here. On our Document type, we include a comments property set equal to an array of the Comment type defined just above it. Here, we're telling GraphQL that when documents are returned to us, an array of comments might be included.
To cut to the chase: we do not nest comments directly on documents in the database. Instead, we rely on GraphQL's ability to perform nested queries to do that work for us. Think of this as a sort of database join (if you're familiar with SQL).
The "magic" at play here takes place down in our resolvers object. At this point, we've told GraphQL to expect a possible comments array, but we haven't defined how to return or resolve those comments.
If we look close, our resolvers object has a Document property in addition to the Query property. On that Document property, we've defined a function called comments. Just like we see a few lines up, this is a resolver function. The difference is that here, we're telling GraphQL how to resolve a query for the comments field on the Document type.
This may seem confusing, but consider this: Query is a type defined in our schema and so is Document. On the resolvers object in our schema, then, we're telling GraphQL how to resolve for those types. Query is a type and so is Document. The functions we define within them resolve the fields we've defined on them.
Focusing back on our comments resolver function under Document, we can see that the first argument parent is actually being utilized here. In this context, because comments is being resolved as a field on a document, we expect parent to represent the document being returned.
Because we're querying for documents (plural), for each document returned, this comments resolver function will fire, passing the current document as the parent.
That's it! Now, we have a queryable field added to our schema, complete with a nested field.
This example has been adapted from /startup/server/api.js.
As we mentioned earlier, mutations are the CUD or "create, update, delete" part of GraphQL. While there's technically no limit on what code you can call with a mutation (at their core, mutations are known as RPCs or remote procedure calls which are just a means for invoking code on the server from a client), traditionally they're used for these purposes.
Like we saw above with queries, a similar process is followed for mutations. In order to fully implement a mutation, we need to have 3-4 things: a type of data being returned from the mutation, a field on the root Mutation type, and a resolver function for the mutation. Optionally, if your mutation accepts arguments, you may also need to define an input type.
Again, on the right, we've defined a simplified example of the schema in Pup. This time, we're focusing on the addDocument mutation.
This should look familiar. In fact, mutations are nearly identical in terms of the "parts" involved. The big difference here is the way we've defined our field on our root Mutation: addDocument(title: String, body: String): Document.
Notice that we define a mutation with a set of parentheses after its name which contains a set of arguments. Here, we expect title and body to be passed as String values from the client. Notice, too, that instead of expecting an array of documents to be returned from our resolver, we expect a single document.
This needs some clarification. Even though our intent with a mutation is to mutate or change some data, it's still good practice to return some value back to the client once that mutation is finished. The why of this depends on your product, but primarily, it's helpful for updating the client side cache with the new or changed data resulting from the mutation.
If we look at our resolvers.Mutation resolver function, we can see a similar idea being implemented to our query resolver function. Inside, we write the code—again, whatever we'd like—to resolve our mutation. In this case, our intent is to add a new document, so we call to the Documents.insert() method to create a new document in our MongoDB collection. Notice, too, that we put our .insert() call into a variable and expect back the _id of the new document.
This is important. Remember, we want to return our new document from our mutation, so at the bottom of our resolver function, we retrieve that new document using documentId and then return it from our Mutation's resolver function.
That's it! We've added a mutation to our schema and are ready to add new documents.
Subscriptions are a different beast compared to queries and mutations, but they needn't be confusing. In the simple terms, subscriptions are a way for a client to "subscribe" to some event on the server and when that even occurs, a message is published back to that client.
In GraphQL, a subscription is exactly this: a means for us to communicate something happening in our schema back to clients who've subscribed to that particular "something happening."
In Pup, an example is implemented using subscriptions to tell clients when a new comment is added to a document they're viewing. Because GraphQL is a pull system and not push, by default if a user is viewing a document and a comment is posted, they'd need to refresh to see the new comment. With subscriptions, we can enable the missing push functionality and send new comments to connected clients as they're created.
To illustrate this, we're going to use the same technique as above, but juxtapose a mutation along with a subscription. We want to do this because subscriptions are typically used in relation to data being mutated or changed in the app.
If we start by looking at our addComment mutation's resolver function, if we look close we can spot a call to context.pubsub.publish() immediately after Comments.insert(). This is where our subscriptions start to take shape.
Here, we take in the pubsub connection—provided for you on the context object by Pup and PupQL—and call the .publish() method, passing two things: the name of the event that's occurring and a payload of data to send along with the event. This is the pub or "publish" part of pubsub.
When this is called, we're sending a message over the pubsub connection. Pay close attention to the name we're using here commentAdded. This is the name of the event that we need to tell our subscription to listen for.
To define our subscription, next, down in the resolvers.Subscription block, we add a property commentAdded (this pairs with the name we used in our pubsub.publish() call and assign it a call to a function withFilter() imported from the apollo-server package. The idea here is that when a comment is added, we only want to notify clients who are subscribed to changes for a specific document.
As the first argument to withFilter() we pass a function which returns a call to context.pubsub.asyncIterator() passing the name of the event we want to listen for. Here, the .asyncIterator() part is listening for events being published with the name commentAdded. When it sees an event matching that name, it returns that event's payload from this function (again, the argument passed as the first function to withFilter().
This is where withFilter() actually comes into play. The first function is saying "hey! we saw an event with the name commentAdded" while the second function is saying "easy on the gas, nerd, let's make sure that's something we care about." The second function then takes in the payload which is the data published via the pubsub.publish() call above and compares it to the variables it's received from the client (more on this in the Using Subscriptions docs).
If the documentId on the comment being added matches the documentId passed from the client (i.e., the one the user is currently subscribing to new comments on) then the filter returns true and lets the event pass through to the client.
Boom! Notice that this is super granular. We're not just willy nilly opening up a WebSocket and pushing data all over the place. We're only sending what's asked for. This means that we can take advantage of real-time data without bogging down clients. S-C-O-R-E.