Pup

v2.x

GraphQLDefining a Schema

When we talk about implementing GraphQL in our application, there are three core parts involved:

  • 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.

In GraphQL, your schema is used to define:

  • 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.

In addition to these core conventions, your GraphQL schema can also be used to define "helper" types of data like Fragments and Enums.

How data flows in GraphQL

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 query request flows in GraphQL.How a request flows in GraphQL.

Types are used to define and enforce the "shape" of the data returned by your queries and mutations. For example, if we have an object like this in a collection called Documents in our database:

{
  _id: '123', 
  title: 'My document.',
  body: 'Just an example, friend.',
  revisions: 8,
}

we'd define a corresponding Type that looks like this:

type Document {
  _id: String
  title: String
  body: String
}

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.

So that's clear, here's an example GraphQL query that would be performed on the client:

{
  document(_id: "123") {
    _id
    title
  }
}

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.

Mapping this query to our schema, the flow would be:

  1. 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).
  2. Schema locates the matching resolver function with the same name under the resolvers.Query object and calls the corresponding function.
  3. Resolver returns some data.
  4. Data is passed through the type for validation/filtering.
  5. Data is returned to client.
Example Schema
import gql from 'graphql-tag';

const schema = {
  typeDefs: gql`
    type Document {
      _id: String
      title: String
      body: String
    }

    type Query {
      document(_id: String!): Document
    }

    type Mutation {
      insertDocument(title: String, body: String): Document
    }

    type Subscription {
      documentUpdated: Document
    }
  `,
  resolvers: {
    Query: {
      document: (parent, args) => {
        // Assuming args equals an object like { _id: '123' };
        return Documents.findOne(args);
      },
    },
    Mutation: {
      insertDocument: (parent, args) => {
        // Assuming args equals an object like { title: 'My document title', body: 'My document body.' };
        const documentId = Documents.insert(args);
        return { _id: documentId };
      },
    },
    Subscription: {
      documentUpdated: (root, args, context) => context.pubsub.asyncIterator('documentUpdated'), 
    },
  },
};

export default schema;

Types

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:"

{
  documents {
    _id
    title
  }
}

That resolver function, then, is defined in the resolvers.Query object on your schema.

Defining Types

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.

Additional Types

As suggested above, there are a few other data types supported by GraphQL:

  • 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 Document Type
type Document {
  _id: String
  isPublic: Boolean
  title: String
  createdAt: String
  updatedAt: String
  body: String
  owner: String
  comments: [Comment]
}
Example Input Type
input UserInput {
  _id: String,
  email: String,
  password: String,
  profile: ProfileInput,
  roles: [String],
  settings: [UserSettingInput] # From /api/UserSettings/types.js
}
Example Enum Type
enum AllowedSettingType {
  boolean
  string
  number
}

type UserSetting {
  _id: String
  isGDPR: Boolean
  key: String # What is the key value you'll access this setting with?
  label: String # The user-facing label for the setting.
  type: AllowedSettingType
  value: String
  lastUpdatedByUser: String
}

Queries

In order to successfully perform a query with GraphQL, there are three things that need to be added to your schema:

  • 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.

Pup uses imports to keep things tidy

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.

In the resolvers.Query part, the function passed to documents—again, known as a resolver function—takes in three arguments:

  • 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.

Querying nested fields

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.

Example Schema with a Query
import gql from 'graphql-tag';
import Documents from '../../api/Documents/Documents';
import Comments from '../../api/Comments/Comments';

const schema = {
  typeDefs: gql`
    type Comment {
      _id: String
      user: User
      documentId: String
      comment: String
      createdAt: String
    }

    type Document {
      _id: String
      isPublic: Boolean
      title: String
      createdAt: String
      updatedAt: String
      body: String
      owner: String
      comments: [Comment]
    }

    type Query {
      documents: [Document]
    }
  `,
  resolvers: {
    Query: {
      documents: (parent, args, context) =>
        context.user && context.user._id ? Documents.find({ owner: context.user._id }).fetch() : [],
    },
    Document: {
      comments: (parent}) => Comments.find({ documentId: parent._id }, { sort: { createdAt: 1 } }).fetch(),
    },
  },
};

export default schema;

Mutations

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.

Example Schema with a Mutation
import gql from 'graphql-tag';
import Documents from '../../api/Documents/Documents';

const schema = {
  typeDefs: gql`
    type Document {
      _id: String
      isPublic: Boolean
      title: String
      createdAt: String
      updatedAt: String
      body: String
      owner: String
      comments: [Comment]
    }

    type Mutation {
      addDocument(title: String, body: String): Document
    }
  `,
  resolvers: {
    Mutation: {
      addDocument: (root, args, context) => {
        if (!context.user) throw new Error('Sorry, you must be logged in to add a new document.');
        const date = new Date().toISOString();
        const documentId = Documents.insert({
          isPublic: args.isPublic || false,
          title: args.title || `Untitled Document #${Documents.find({ owner: context.user._id }).count() + 1}`,
          body: args.body ? sanitizeHtml(args.body) : 'This is my document. There are many like it, but this one is mine.',
          owner: context.user._id,
          createdAt: date,
          updatedAt: date,
        });
        const doc = Documents.findOne(documentId);
        return doc;
      }
    },
  },
};

export default schema;