DEV Community

RSA
RSA

Posted on • Updated on

How to integrate GraphQL with SailsJS application

Introduction

This article is an attempt to explain how to integrate graphql with SailsJS application. For the last 3 years I am actively working on projects that are based on NodeJS technology stack. For server-side development, the platform of choice is SailsJS and for client-side development I use mainly Angular and Vue. Graphql being so powerful, I wanted to leverage its power in my projects so as to reduce and eliminate the shortcomings of the Waterline ORM, that drives the database operations, such as missing multi-level referential entity fetch, etc. I could not find any article on how to do it. After lot of trial and error, I have an implementation that is working properly with custom directives for authentication and authorization on graphql resources and I believe is release worthy now.

CAUTION: The goal of this article is to explain how to integrate GraphQL with SailsJS projects. It is not my intention to teach GraphQL here. There are many good articles and documentation available on net for the same.

Prerequisites

The following should be pre-installed on your PC/workstation

  • NodeJS - v10+
  • SailsJS CLI - latest version, globally installed

CAUTION: I will be working on a Linux machine so any commands that use paths will use the linux/unix style. If you work on Windows machine then change the paths accordingly.

Project

From now on I would address myself as we, I am considering the reader i.e., you as a member of the team. So learn and enjoy with me.

The example project that we will work on in this article will not use a web application rather it will be an API server only. For the frontend, we will use Postman for calling various graphql queries and mutations. We will not use the third operation supported by graphql that is subscriptions. It is left for you to try in your own projects, in case you need pub-sub functionality.

We will define 2 waterline models

  • Book
  • Author

And write the associated graphql schema, user security & access control custom directives, queries, mutations and resolvers to implement CRUD operations. Although user security and access control is not required for this example project but it is essential to learn how to do it in a real project, hence, we will implement this feature too.

Create a Sails project

In your terminal/command window type and execute the following command to create a minimal project.

sails new sails-graphql --without=session,views
Enter fullscreen mode Exit fullscreen mode

Now, we will install the graphql npm packages that are relevant for our project.

cd sails-graphql
npm install graphql graphql-tools express-graphql
Enter fullscreen mode Exit fullscreen mode

For database support, we will use the pre-configured sails-disk ORM adapter. Set the migrate property to alter in config/models.js before lifting the sails server.

Define SailsJS models

Create the following two models in api/models/ folder of your project.

  1. Book.js
  2. Author.js
/**
 * Book.js
 *
 * @description :: A model definition.  Represents a database table/collection/etc.
 * @docs        :: https://sailsjs.com/docs/concepts/models-and-orm/models
 */

module.exports = {

  attributes: {

    //  ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦  ╦╔═╗╔═╗
    //  ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
    //  ╩  ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝

    title: {
      type: 'string',
      required: true,
      unique: true
    },

    yearPublished: {
      type: 'string',
      required: true
    },

    genre: {
      type: 'string',
      isIn: ['ADVENTURE', 'COMICS', 'FANTASY', 'UNKNOWN'],
      defaultsTo: 'UNKNOWN'
    },

    //  ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
    //  ║╣ ║║║╠╩╗║╣  ║║╚═╗
    //  ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝


    //  ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
    //  ╠═╣╚═╗╚═╗║ ║║  ║╠═╣ ║ ║║ ║║║║╚═╗
    //  ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝

    author: {
      model: 'Author',
      required: true
    }

  }

};

Enter fullscreen mode Exit fullscreen mode
/**
 * Author.js
 *
 * @description :: A model definition.  Represents a database table/collection/etc.
 * @docs        :: https://sailsjs.com/docs/concepts/models-and-orm/models
 */

module.exports = {

  attributes: {

    //  ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦  ╦╔═╗╔═╗
    //  ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
    //  ╩  ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝

    name: {
      type: 'string',
      required: true,
      unique: true
    },

    country: {
      type: 'string',
      defaultsTo: 'UNKNOWN'
    },

    //  ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
    //  ║╣ ║║║╠╩╗║╣  ║║╚═╗
    //  ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝


    //  ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
    //  ╠═╣╚═╗╚═╗║ ║║  ║╠═╣ ║ ║║ ║║║║╚═╗
    //  ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝

    books: {
      collection: 'Book',
      via: 'author'
    }

  }

};

Enter fullscreen mode Exit fullscreen mode

Define GraphQL Schema, Policies and Helpers

Create the following folder structure where our various artifacts will live.

  api
    |
    -- graphql
        |
        -- helpers
        |
        -- policies
        |
        -- schemas
Enter fullscreen mode Exit fullscreen mode

Policies

Let us first define our policies and place the artifacts in the api/graphql/policies folder. We will implement JWT authentication and role-based authorization strategy, the sample code should be enhanced or totally changed as per your project requirement, the idea is to make you understand how and where to implement them. You are free to change to your own strategy. Create two files

  1. auth.js
  2. permission.js
/**
 * auth.js
 *
 * A simple policy that
 *  a) establishes identity of a user based on a jwt token
 *  b) allow access to resources based on role-based ACL
 *
 */

 const { checkPermission } = require('./permission');

module.exports = {
  _authenticate: async (context) => {

    let req = context.req;

    /* Uncomment this sample code and adapt to implement your own JWT authentication
    let message = 'Access denied. You need to be loggedin to access this resource.';

    if (
      !req ||
      !req.headers ||
      (!req.headers.authorization && !req.headers.Authorization)
    ) {
      return {
        errors: [
          {
            code: 'I_AUTHTOKEN_MISSING',
            message: message
          }
        ]
      };
    }

    let token = req.headers.authorization || req.headers.Authorization;
    // Check presence of Auth Token and decode
    if (!token) {
      // Otherwise, this request did not come from a logged-in user.
      return {
        errors: [
          {
            code: 'I_AUTHTOKEN_MISSING',
            message: message
          }
        ]
      };
    }

    if (!token.startsWith('Bearer ')) {
      // Otherwise, this request did not come from a logged-in user.
      return {
        errors: [
          {
            code: 'E_AUTHTYPE_INVALID',
            message: message
          }
        ]
      };
    }

    token = token.substring(7);
    let result = {};
    try {
      result = await TokenService.decode({token: token});
    } catch (err) {
      sails.log.error('auth._authenticate: Error encountered: ', err);
      return {
        errors: [
          {
            code: 'E_DECODE',
            message: message
          }
        ]
      };
    }

    const now = Date.now() / 1000;
    if (result.exp <= now) {
      sails.log.info(`auth._authenticate: Access denied for: [${result.userName}] as the Auth Token has expired.`);
      return {
        errors: [
          {
            code: 'I_TOKEN_EXPIRED',
            message: message
          }
        ]
      };
    }
    */

    // When you implement your own authentication mechanism, 
    // remove the hard-coded result variable below.
    let result = {
      id: 1,
      fullName: 'Test',
      emailAddress: 'test@test.test',
      isRoleAdmin: false,
      roleId: 1
    };

    // Set the user object in graphql object for reference in subsequent processing
    context.user = result;
    return result;
  }, // end _authenticate()

  _authorize: async (user, expectedScope) => {
    let isAllowed = false;

    const scopeSplit = expectedScope.toLowerCase().split(':');
    const resource = scopeSplit[0].trim();
    const permission = scopeSplit[1].trim();
    if (scopeSplit.length > 2) {
      if (scopeSplit[2] === 'admin') {
        if (user.isRoleAdmin) {
          isAllowed = await checkPermission(user.roleId, permission, resource);
        }
      }
    } else {
      isAllowed = await checkPermission(user.roleId, permission, resource);
    }

    if (!isAllowed) {
      sails.log.info('auth._authorize: Access denied for: ');
      sails.log.info('  User:', user.fullName, '(' + user.emailAddress + ')');
      sails.log.info('  Valid Resource:Scope is: ', expectedScope);
    }
    return isAllowed;
  } // end _authorize()

};
Enter fullscreen mode Exit fullscreen mode
/**
 * permission.js
 *
 * A simple policy for implementing RBAC
 *
 */

module.exports = {
  checkPermission: (roleId, permission, resource) => {
    console.log(`checkPermission() Role Id: ${roleId}, Permission: ${permission}, Resource: ${resource}`);

    // add your RBAC code here and return true for allow or false for disallow

    return true; // allow
  }
};
Enter fullscreen mode Exit fullscreen mode

The above code is simple and self explanatory. The auth.js defines two functions _authenticate that gets the JWT from the HTTP Request header and decodes it. The second _authorize checks for RBAC permissions on the said resource/artifact.
The permission.js defines a single function called checkPermission that is supposed to implement how you want to define your resource/artifact permission matrix for each role and then appropriately return true for allow access or false for deny access.

If you have used GraphQL before you may know that the standard libraries generate and send very cryptic and confusing error messages to the client. Therefore, to simplify and provide a consistent interface to the client, result and error objects will be sent in the body of the POST response.

Please pay attention to the following code fragment that returns an object for error instead of throwing GraphQLError.

      return {
        errors: [
          {
            code: 'E_AUTHTYPE_INVALID',
            message: message
          }
        ]
      };
Enter fullscreen mode Exit fullscreen mode

This way we can send rich and clear error message to the client.

Schema

Common Schema Artifacts

First we will define the common atrtifacts of our schema that will be used by each SailsJS model schema and place them in api/graphql/schemas/schema.js. A seperate schema file will be created for each model in our project. Finally we will import the sections of the model schemas in schema.js. Therefore, incomplete schema.js is given below for understanding the common artifacts.

/**
 * schema.js (Incomplete)
 */
const { makeExecutableSchema } = require('graphql-tools');
const { _authenticate, _authorize } = require('../policies/auth');

// Construct a schema using the GraphQL schema language
const typeDefs = `
  directive @authenticate on FIELD_DEFINITION | FIELD
  directive @authorize(scope: String!) on FIELD_DEFINITION | FIELD

  type Error {
    code: String!
    message: String!
    attrName: String
    row: Int
    moduleError: ModuleError
  }

  type ModuleError {
    code: String!
    message: String!
    attrNames: [String]
  }

  type ErrorResponse {
    errors: [Error]
  }

  # model types will be added here
  # TODO

  type Query {
    # model query declaration will be added here
    # TODO
  }

  type Mutation {
    # model mutation declaration will be added here
    # TODO
  }
`;

// Provide resolver functions for your schema fields
const resolvers = {
  Query: {
    # model query resolver code will be added here
    # TODO
  },

  Mutation: {
    # model mutation resolver code will be added here
    # TODO
  },

  # model references resolvers code will be added here
  # TODO
};

const directiveResolvers = {
  // Will be called when a @authenticate directive is applied to a field or field definition.
  async authenticate(resolve, parent, directiveArgs, context, info) {
    if (context.user === undefined) {
      user = await _authenticate(context);
      if (user.errors !== undefined) {
        return user; // user authentication failed
      }
    }
    return resolve();
  },

  // Will be called when a @authorize directive is applied to a field or field definition.
  async authorize(resolve, parent, directiveArgs, context, info) {
    if (!await _authorize(context.user, directiveArgs.scope)) {
      return {
        errors: [
          {
            code: 'E_NO_PERMISSION',
            message: 'Expected resource Authorization: ' + directiveArgs.scope
          }
        ]
      };
    }
    return resolve();
  }
};

// Get a GraphQL.js Schema object
module.exports.schema = makeExecutableSchema({
  typeDefs,
  resolvers,
  directiveResolvers
});

Enter fullscreen mode Exit fullscreen mode

Let us attempt to explain sections of this schema definition.

Custom Directives

We have declared two custom directives in the typeDefs section named @authenticate and @authorize.

  directive @authenticate on FIELD_DEFINITION | FIELD
  directive @authorize(scope: String!) on FIELD_DEFINITION | FIELD

Enter fullscreen mode Exit fullscreen mode

@authenticate has no arguments that means when you refer to it in your code you will not pass any parameters to it. The JWT is extracted from the HTTP request headers and the req object will be supplied by graphql runtime in the context variable. We can define what context is when we register graphql as a middleware in SailsJS.

@authorize has one argument named scope that is of String type. Notice that it has a trailing !, this means it is required (mandatory). You will pass the constraint to be checked, for example, book:read which translates to "if the logged in user has read access to book then allow access otherwise deny access". The structure of the constraint value is resource:constraint_type:admin. As you can see it has 3 parts delimited by a colon, first is the resource/artifact name, second is the constraint and the third is optional and is fixed as admin to declare that only the role administrator can have access to the resource and constraint type in question. We have implemented four constraint types viz. read, add, update and delete.

NOTE: For this example project, we have a scalar constraint but it is possible to enhance the functionality to say passing an array of constraints.

Global Schema Types
  type Error {
    code: String!
    message: String!
    attrName: String
    row: Int
    moduleError: ModuleError
  }

  type ModuleError {
    code: String!
    message: String!
    attrNames: [String]
  }

  type ErrorResponse {
    errors: [Error]
  }
Enter fullscreen mode Exit fullscreen mode

We have defined a global error response type ErrorResponse that is an array of Error type objects. We will return this response type for all our application errors. Error type fields are explained below:

  • code - application specific error classifiers (mandatory)
  • message - application specific error message (mandatory)
  • attrName - name of field / attribute that has erroneous value (optional)
  • row - row number of the attribute if the input is an array (optional)
  • moduleError - this is a special object that holds the error message generated by sails/waterline for any framework related exceptions (optional)
Custom Directive Resolvers

This section of the code defines the functions for each custom directive declared before in the schema.

const directiveResolvers = {
  // Will be called when a @authenticate directive is applied to a field or field definition.
  async authenticate(resolve, parent, directiveArgs, context, info) {
    if (context.user === undefined) {
      user = await _authenticate(context);
      if (user.errors !== undefined) {
        return user; // user authentication failed
      }
    }
    return resolve();
  },

  // Will be called when a @authorize directive is applied to a field or field definition.
  async authorize(resolve, parent, directiveArgs, context, info) {
    if (!await _authorize(context.user, directiveArgs.scope)) {
      return {
        errors: [
          {
            code: 'E_NO_PERMISSION',
            message: 'Expected resource Authorization: ' + directiveArgs.scope
          }
        ]
      };
    }
    return resolve();
  }
};
Enter fullscreen mode Exit fullscreen mode

The code here is self explanatory. The only specific thing to learn is the function signature which is explained below:

  • resolve - It is the default field resolver that is coming from the graphql library
  • parent - It is the parent node's data object. If you need any value from the parent node then you can get it from here
  • directiveArgs - This is the object that contains your directive parameters. In our case @authorize(scope: "book:read") declaration will get passed as { scope: "book:read" }
  • context - This is the global graphql context and will contain whatever you set while registering the express-graphql middleware
  • info - This contains lot of information and AST of your query. Usually we do not use it. Refer to graphql documentation for a complete explanation

NOTE: Where you call the default resolve function in your custom code depends upon the functionality of your directive. In both our directives either we return an error or at the end return with a call to the default resolve function. However, there may be cases where you need the value of your current node then you will call the default resolve function first to get the value and then manipulate it as per the functionality of your directive. For example, @uppercase, here you will call the default resolve first and then convert the resultant value to uppercase and then return it.

Compile the declarative schema into executable one

This section explains how to compile the declarative schema to a state that the graphql runtime understands.

// Get a GraphQL.js Schema object
module.exports.schema = makeExecutableSchema({
  typeDefs,
  resolvers,
  directiveResolvers
});
Enter fullscreen mode Exit fullscreen mode

makeExecutableSchema comes from the graphql-tools library package. We pass only three parameters that are relevant to our project. You may have a look at the detailed number of parameters that it can accept at graphql-tools github page.

Author Schema

/**
 * AuthorSchema.js
 */
const { _getAuthor, _addAuthor, _updateAuthor, _deleteAuthor } = require('../helpers/AuthorHelper');
const { _getBook } = require('../helpers/BookHelper');

module.exports = {
  typeDefs: {
    types: `
      # model=Author
      type Author {
        # Unique identifier (Primary key in database for this model entity)
        id: Int!
        # Name
        name: String!
        # Country
        country: String
        # Books
        books: [Book] @authorize(scope: "book:read")
      }

      input AuthorInput {
        name: String
        country: String
      }

      # define unions
      union AuthorResponse = Author | ErrorResponse

    `, // end of types

    queries: `
      getAuthors(filter: String): [AuthorResponse] @authorize(scope: "author:read") @authenticate
      getAuthor(id: Int!): AuthorResponse @authorize(scope: "author:read") @authenticate
    `, // end of queries

    mutations: `
      addAuthor(data: AuthorInput!): AuthorResponse @authorize(scope: "author:add") @authenticate
      updateAuthor(id: Int!, data: AuthorInput!): AuthorResponse @authorize(scope: "author:update") @authenticate
      deleteAuthor(id: Int!): AuthorResponse @authorize(scope: "author:delete") @authenticate
    `, // end of mutations
  }, // end of typeDefs

  resolvers: {
    queries: {
      getAuthors: async (parent, args, context) => {
        const result = await _getAuthor({ where: args.filter });
        if (!(result instanceof Array)) {
          return [ result ];
        }
        if (result.length === 0) {
          return [ { errors: [ { code: 'I_INFO', message: 'No data matched your selection criteria'}]} ];
        }
        return result;
      },
      getAuthor: async (parent, args, context) => {
        return await _getAuthor(args);
      },
    },

    mutations: {
      addAuthor: async (parent, args, context) => {
        return await _addAuthor(args.data);
      },
      updateAuthor: async (parent, args, context) => {
        return await _updateAuthor(args.id, args.data);
      },
      deleteAuthor: async (parent, args, context) => {
        return await _deleteAuthor(args.id);
      },
    },

    references: {

      Author: {
        books: async (author, _, context) => {
          if (author === null) {
            return null;
          }
          const args = {
            where: {
              author: author.id
            }
          };
          const result = await _getBook(args);
          if (!(result instanceof Array)) {
            return [ result ];
          }
          return result;
        },

      },

      AuthorResponse: {
        __resolveType(obj, context, info) {
          if (obj.errors) {
            return 'ErrorResponse';
          } else {
            return 'Author';
          }
        },
      },

    } // end of references
  } // end of resolvers
};
Enter fullscreen mode Exit fullscreen mode

Let us dissect the author schema, the Author type mimics the attributes and properties of each attribute directly from your waterline model, it is 1-to-1 correspondence. The @authorize(scope: "book:read") directive on the collection of books seems ridiculous and I agree. I have declared it just to illustrate that it can be done to stop access to book collection owned by the author requested in your query. If you want to allow access to all and sundry then remove the directive declaration.

For mutations we need to explicitly define input type hence AuthorInput. One thing I want to highlight is that we have not made any field mandatory, This has been done deliberately to use the same input type for add as well as update mutations. For add, we need to pass all the fields where as for update, only selective fields will be passed. So, effectively I am bypassing the graphql validation rules and handling field validations in my schema resolver helper functions. Remember, I had mentioned, the errors thrown by graphql are very cryptic and to circumvent that we defined our own global error type. Alternatively, if you are not happy with this arrangement, you can define two input types, one for add with the mandatory fields marked and second for update without marking any field mandatory.

We have defined AuthorResponse as a union of two types to return either a valid Author object or an ErrorResponse. Therefore, we need to tell graphql runtime what kind of reply we will return so that the front-end application can interpret what kind of object has been received as result. The following code snippet implements the code that graphql will call to identify the object type of the response.

      AuthorResponse: {
        __resolveType(obj, context, info) {
          if (obj.errors) {
            return 'ErrorResponse';
          } else {
            return 'Author';
          }
        },
      },
Enter fullscreen mode Exit fullscreen mode

The argument obj is essentially the result that our query is returning. Recall that we return our application errors as { errors: [ {}, ...] }, hence we check the existence of errors key in the object, if it exists then we return ErrorResponse else we return Author.

The rest of the code for queries, mutationsdeclaration and implementation of the corresponding resolvers is pretty standard graphql, no need to explain. However, we will discuss an issue with multiple directive declaration on the same field in graphql-tools. Examine closely the following query declaration, do you see any problem/peculiarity?

getAuthors(filter: String): [AuthorResponse] @authorize(scope: "author:read") @authenticate
Enter fullscreen mode Exit fullscreen mode

To a sane person the order of directive declaration should be @authenticate @authorize(scope: "author:read") isn't it? First authenticate the user and then check for permissions. But in the code we have reversed them because graphql-tools scans them from LTR but executes them RTL. This bug was raised way back in February, 2018. Unfortunately, after two years it is still not fixed.

Examine the following code snippets.

getAuthors(filter: String): [AuthorResponse] @authorize(scope: "author:read") @authenticate
Enter fullscreen mode Exit fullscreen mode
      getAuthors: async (parent, args, context) => {
        const result = await _getAuthor({ where: args.filter });
        if (!(result instanceof Array)) {
          return [ result ];
        }
        if (result.length === 0) {
          return [ { errors: [ { code: 'I_INFO', message: 'No data matched your selection criteria'}]} ];
        }
        return result;
      },
Enter fullscreen mode Exit fullscreen mode

The first snippet declares the getAuthors and second implements it. The declaration says the function must return an array of AuthorResponse. Therefore the implementation checks the returned result from the helper function,

  • if it is not an array then it converts into an array. When will such a situation arrise? When the helper function returns an Error object which will certainly not be an array.
  • if the array is empty then it returns an array of Error object. As far as the helper function is concerned it will return an empty array, if no data matches for the filter passed but there are fields in Author type that are mandatory (id, name). So if we return an empty array, graphql runtime will throw an error.

Examine the following code snippet in the references.

      Author: {
        books: async (author, _, context) => {
          if (author === null) {
            return null;
          }
          const args = {
            where: {
              author: author.id
            }
          };
          const result = await _getBook(args);
          if (!(result instanceof Array)) {
            return [ result ];
          }
          return result;
        },

      },
Enter fullscreen mode Exit fullscreen mode

This is equivalent to a populate() call in SailsJS.
At present, we cannot get data from second level onwards using populate() and there are other shortcomings of populate() such as it does not allow selection of field lists.
The good thing about graphql is that it resolves each node of a query one-by-one starting from the root of the query, therefore, we can fetch data from multiple levels of references irrespective of the depth. Also, we can select data fields on each node as per the query request.

Book Schema

 /**
 * BookSchema.js
 */
const { _getBook, _addBook, _updateBook, _deleteBook } = require('../helpers/BookHelper');
const { _getAuthor } = require('../helpers/AuthorHelper');

module.exports = {
  typeDefs: {
    types: `
      # model=Book
      type Book {
        # Unique identifier (Primary key in database for this model entity)
        id: Int!
        # Title
        title: String!
        # Year Published
        yearPublished: String!
        # Genre
        genre: String
        # Author
        author: Author! @authorize(scope: "author:read")
      }

      input BookInput {
        title: String
        yearPublished: String
        genre: String
        authorId: Int
      }

      # define unions
      union BookResponse = Book | ErrorResponse

    `, // end of types

    queries: `
      getBooks(filter: String): [BookResponse] @authorize(scope: "book:read") @authenticate 
      getBook(id: Int!): BookResponse @authorize(scope: "book:read") @authenticate
    `, // end of queries

    mutations: `
      addBook(data: BookInput!): BookResponse @authorize(scope: "book:add") @authenticate
      updateBook(id: Int!, data: BookInput!): BookResponse @authorize(scope: "book:update") @authenticate
      deleteBook(id: Int!): BookResponse @authorize(scope: "book:delete") @authenticate
    `, // end of mutations
  }, // end of typeDefs

  resolvers: {
    queries: {
      getBooks: async (parent, args, context) => {
        const result = await _getBook({ where: args.filter });
        if (!(result instanceof Array)) {
          return [ result ];
        }
        if (result.length === 0) {
          return [ { errors: [ { code: 'I_INFO', message: 'No data matched your selection criteria'}]} ];
        }
        return result;
      },
      getBook: async (parent, args, context) => {
        return await _getBook(args);
      },
    },

    mutations: {
      addBook: async (parent, args, context) => {
        return await _addBook(args.data);
      },
      updateBook: async (parent, args, context) => {
        return await _updateBook(args.id, args.data);
      },
      deleteBook: async (parent, args, context) => {
        return await _deleteBook(args.id);
      },
    },

    references: {

      Book: {
        author: async (book, _, context) => {
          if (book === null) {
            return null;
          }
          const args = {
            id: book.author
          };
          return await _getAuthor(args);
        },

      },

      BookResponse: {
        __resolveType(obj, context, info) {
          if (obj.errors) {
            return 'ErrorResponse';
          } else {
            return 'Book';
          }
        },
      },

    } // end of references
  } // end of resolvers
};
Enter fullscreen mode Exit fullscreen mode

The Book schema is similar to Author schema, therefore, does not need any explanation.

Import the model schemas

Now, we will import the model schema artifacts in the main schema.js file.
Add the import of the models

const book = require('./BookSchema');
const author = require('./AuthorSchema');
Enter fullscreen mode Exit fullscreen mode

Now, import the model artifacts. Add the following code in the typeDefs variable.

  ${book.typeDefs.types}
  ${author.typeDefs.types}

  type Query {
    ${book.typeDefs.queries}
    ${author.typeDefs.queries}
  }

  type Mutation {
    ${book.typeDefs.mutations}
    ${author.typeDefs.mutations}
  }

Enter fullscreen mode Exit fullscreen mode

Add the model query, mutation and refrences resolvers to the resolvers variable.

const resolvers = {
  Query: {
    ...book.resolvers.queries,
    ...author.resolvers.queries
  },

  Mutation: {
    ...book.resolvers.mutations,
    ...author.resolvers.mutations
  },
  ...book.resolvers.references,
  ...author.resolvers.references
};
Enter fullscreen mode Exit fullscreen mode

So here is the complete code of schema.js.

/**
 * schema.js
 */
const { makeExecutableSchema } = require('graphql-tools');
const { _authenticate, _authorize } = require('../policies/auth');
const book = require('./BookSchema');
const author = require('./AuthorSchema');

// Construct a schema using the GraphQL schema language
const typeDefs = `
  directive @authenticate on FIELD_DEFINITION | FIELD
  directive @authorize(scope: String!) on FIELD_DEFINITION | FIELD

  type Error {
    code: String!
    message: String!
    attrName: String
    row: Int
    moduleError: ModuleError
  }

  type ModuleError {
    code: String!
    message: String!
    attrNames: [String]
  }

  type ErrorResponse {
    errors: [Error]
  }

  ${book.typeDefs.types}
  ${author.typeDefs.types}

  type Query {
    ${book.typeDefs.queries}
    ${author.typeDefs.queries}
  }

  type Mutation {
    ${book.typeDefs.mutations}
    ${author.typeDefs.mutations}
  }
`;

// Provide resolver functions for your schema fields
const resolvers = {
  Query: {
    ...book.resolvers.queries,
    ...author.resolvers.queries
  },

  Mutation: {
    ...book.resolvers.mutations,
    ...author.resolvers.mutations
  },
  ...book.resolvers.references,
  ...author.resolvers.references
};

const directiveResolvers = {
  // Will be called when a @authenticate directive is applied to a field or field definition.
  async authenticate(resolve, parent, directiveArgs, context, info) {
    if (context.user === undefined) {
      user = await _authenticate(context);
      if (user.errors !== undefined) {
        return user; // user authentication failed
      }
    }
    return resolve();
  },

  // Will be called when a @authorize directive is applied to a field or field definition.
  async authorize(resolve, parent, directiveArgs, context, info) {
    if (!await _authorize(context.user, directiveArgs.scope)) {
      return {
        errors: [
          {
            code: 'E_NO_PERMISSION',
            message: 'Expected resource Authorization: ' + directiveArgs.scope
          }
        ]
      };
    }
    return resolve();
  }
};

// Get a GraphQL.js Schema object
module.exports.schema = makeExecutableSchema({
  typeDefs,
  resolvers,
  directiveResolvers
});
Enter fullscreen mode Exit fullscreen mode

Helpers

Helper functions are equivalent to SailsJS's controller/actions that are called by the graphql resolvers to interact with the underlying database layer to implement CRUD operations. Each of the helper implements four functions and each function does its own input validations.

BookHelper

 /**
 * BookHelper.js
 *
 * @description :: Server-side actions for handling incoming requests.
 */

module.exports = {

  /*
   * @Function:     _addBook(input)
   * @Description:  Add one record of Book
   * @Params:       input - dictionary of fields to be added
   * @Return:       Book | ErrorResponse
   */
  _addBook: async (input) => {
    let validValuesArray = [];
    const title = input.title;
    const yearPublished = input.yearPublished;
    const genre = input.genre || 'UNKNOWN';
    const authorId = parseInt(input.authorId);

    let payLoad = {};

    // Validate user input

    if (title === undefined) {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: 'title',
            message: 'Title is required and should be of type "string"'
          }
        ]
      };
    }

    if (typeof title !== 'string') {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: 'title',
            message: 'Title should be of type "string"'
          }
        ]
      };
    }

    if (yearPublished === undefined) {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: 'yearPublished',
            message: 'Year Published is required and should be of type "string"'
          }
        ]
      };
    }

    if (typeof yearPublished !== 'string') {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: 'yearPublished',
            message: 'Year Published should be of type "string"'
          }
        ]
      };
    }

    if (genre === undefined) {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: 'genre',
            message: 'Genre is required and should be one of "\'ADVENTURE\', \'COMICS\', \'FANTASY\', \'UNKNOWN\'"'
          }
        ]
      };
    }

    if (typeof genre !== 'string') {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: 'genre',
            message: 'Genre should be of type "string"'
          }
        ]
      };
    }

    validValuesArray = ['ADVENTURE','COMICS','FANTASY','UNKNOWN'];
    if (validValuesArray.find((val) => genre === val) === undefined) {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: 'genre',
            message: 'Genre should be one of "\'ADVENTURE\', \'COMICS\', \'FANTASY\', \'UNKNOWN\'"'
          }
        ]
      };
    }

    if (authorId === undefined || Number.isNaN(authorId)) {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: 'authorId',
            message: 'Author Id is required and should be of type "integer"'
          }
        ]
      };
    }

    // All input validated, now set the payLoad values
    payLoad.title = title;
    payLoad.yearPublished = yearPublished;
    payLoad.genre = genre;
    payLoad.author = authorId;

    try {
      let result = null;
      // insert new record
      result = await Book.create(payLoad).fetch();

      // Success
      sails.log.debug(`BookHelper._addBook: Book successfully added:`, result);
      return result;
    } catch (err) {
      sails.log.debug('BookHelper._addBook: Exception encountered:', err);
      return {
        errors: [
          {
            code: 'E_API_ERROR',
            message: `Book add request failed.`,
            moduleError: {
              code: err.code || 'E_ERROR',
              attrNames: err.attrNames || [],
              message: err.message
            }
          }
        ]
      };
    } // end try {}
  }, // end _addBook()

  /*
   * @Function:     _updateBook(id, input)
   * @Description:  Update one record of Book
   * @Params:       id - Book Id
   *                input - dictionary of rest of fields to be updated
   * @Return:       Book | ErrorResponse
   */
  _updateBook: async (id, input) => {
    let validValuesArray = [];

    // for new or update record
    const title = input.title;
    const yearPublished = input.yearPublished;
    const genre = input.genre;
    const authorId = input.authorId ?  parseInt(input.authorId) : undefined;

    if (!id) {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: 'id',
            message: 'Id is required for updation.'
          }
        ]
      };
    }

    let valueNotSet = true;
    let payLoad = {};
    // now set the payLoad value(s)

    if (title !== undefined) {

      if (typeof title !== 'string') {
        return {
          errors: [
            {
              code: 'E_BAD_INPUT',
              attrName: 'title',
              message: 'Title should be of type "string"'
            }
          ]
        };
      }

      valueNotSet = false;
      payLoad.title = title;
    } // end if

    if (yearPublished !== undefined) {

      if (typeof yearPublished !== 'string') {
        return {
          errors: [
            {
              code: 'E_BAD_INPUT',
              attrName: 'yearPublished',
              message: 'Year Published should be of type "string"'
            }
          ]
        };
      }

      valueNotSet = false;
      payLoad.yearPublished = yearPublished;
    } // end if

    if (genre !== undefined) {

      if (typeof genre !== 'string') {
        return {
          errors: [
            {
              code: 'E_BAD_INPUT',
              attrName: 'genre',
              message: 'Genre should be of type "string"'
            }
          ]
        };
      }

      validValuesArray = ['ADVENTURE','COMICS','FANTASY','UNKNOWN'];
      if (validValuesArray.find((val) => genre === val) === undefined) {
        return {
          errors: [
            {
              code: 'E_BAD_INPUT',
              attrName: 'genre',
              message: 'Genre should be one of "\'ADVENTURE\', \'COMICS\', \'FANTASY\', \'UNKNOWN\'"'
            }
          ]
        };
      }

      valueNotSet = false;
      payLoad.genre = genre;
    } // end if

    if (!(authorId === undefined || Number.isNaN(authorId))) {

      valueNotSet = false;
      payLoad.author = authorId;
    } // end if

    if (valueNotSet) {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: '',
            message: 'No value(s) sent for updation.'
          }
        ]
      };
    }

    try {
      let result = await Book.updateOne()
        .set(payLoad)
        .where({
          id: id
        }); // .fetch() not required for updateOne() as it always returns the updated record or undefined if not found

      // Success
      result = result || { errors: [ { code: 'I_INFO', message: `No Book exists with the requested Id: ${id}`} ] };
      sails.log.debug(`BookHelper._updateBook: Book successfully updated:`, result);
      return result;
    } catch (err) {
      sails.log.debug('BookHelper._updateBook: Exception encountered:', err);
      return {
        errors: [
          {
            code: 'E_API_ERROR',
            message: `Book update request failed.`,
            moduleError: {
              code: err.code || 'E_ERROR',
              attrNames: err.attrNames || [],
              message: err.message
            }
          }
        ]
      };
    } // end try {}
  }, // end _updateBook()

  /*
   * @Function:     _deleteBook(id)
   * @Description:  Delete one record of Book
   * @Params:       id - Book Id
   * @Return:       Book | ErrorResponse
   */
  _deleteBook: async (id) => {
    if (!id) {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: 'id',
            message: 'Id is required for deletion.'
          }
        ]
      };
    }

    try {
      let result = null;

      result = await Book.destroyOne({id});
      // Success
      result = result || { errors: [ { code: 'I_INFO', message: `No Book exists with the requested Id: ${id}`} ] };
      sails.log.debug(`BookHelper._deleteBook: Book successfully deleted:`, result);
      return result;
    } catch (err) {
      sails.log.debug('BookHelper._deleteBook: Exception encountered:', err);
      return {
        errors: [
          {
            code: 'E_API_ERROR',
            message: `Book delete request failed.`,
            moduleError: {
              code: err.code || 'E_ERROR',
              attrNames: err.attrNames || [],
              message: err.message
            }
          }
        ]
      };
    } // end try {}
  }, // end _deleteBook()

  /*
   * @Function:     _getBook(input)
   * @Description:  Fetch one or more record(s) of Book
   * @Params:       input - dictionary with either Book Id or a filter criteria
   * @Return:       Book | [Book] | ErrorResponse
   */
  _getBook: async (input) => {
    const id = input.id;
    let where = input.where || {};

    if (typeof where === 'string') {
      try {
        where = JSON.parse(where);
      } catch(err) {
        return {
          errors: [
            {
              code: 'E_BAD_INPUT',
              attrName: 'where',
              message: 'Where clause should be a valid JSON object.'
            }
          ]
        };
      } // end try
    }

    if (id) {
      where.id = id;
    }

    try {
      // Now fetch the record(s) from database
      let result = await Book.find().where(where);

      if (id) {
        if (result.length > 0) {
          result = result[0];
        } else {
          result = { errors: [ { code: 'I_INFO', message: `No Book exists with the requested Id: ${id}`} ] };
        }
      }

      // Success
      sails.log.debug(`BookHelper._getBook: Book(s) successfully retrieved:`, result);
      return result;
    } catch(err) {
      sails.log.debug('BookHelper._getBook: Exception encountered:', err);
      return {
        errors: [
          {
            code: 'E_API_ERROR',
            message: 'Book fetch request failed.',
            moduleError: {
              code: err.code || 'E_ERROR',
              attrNames: err.attrNames || [],
              message: err.message
            }
          }
        ]
      };
    } // end try {}
  }, // end _getBook()
};
Enter fullscreen mode Exit fullscreen mode

AuthorHelper

/**
 * AuthorHelper.js
 *
 * @description :: Server-side actions for handling incoming requests.
 */

module.exports = {

  /*
   * @Function:     _addAuthor(input)
   * @Description:  Add one record of Author
   * @Params:       input - dictionary of fields to be added
   * @Return:       Author | ErrorResponse
   */
  _addAuthor: async (input) => {
    const name = input.name;
    const country = input.country || 'UNKNOWN';
    let payLoad = {};

    // Validate user input

    if (name === undefined) {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: 'name',
            message: 'Name is required and should be of type "string"'
          }
        ]
      };
    }

    if (typeof name !== 'string') {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: 'name',
            message: 'Name should be of type "string"'
          }
        ]
      };
    }

    if (country === undefined) {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: 'country',
            message: 'Country is required and should be of type "string"'
          }
        ]
      };
    }

    if (typeof country !== 'string') {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: 'country',
            message: 'Country should be of type "string"'
          }
        ]
      };
    }

    // All input validated, now set the payLoad values
    payLoad.name = name;
    payLoad.country = country;

    try {
      // insert new record
      let result = await Author.create(payLoad).fetch();

      // Success
      sails.log.debug(`AuthorHelper._addAuthor: Author successfully added:`, result);
      return result;
    } catch (err) {
      sails.log.debug('AuthorHelper._addAuthor: Exception encountered:', err);
      return {
        errors: [
          {
            code: 'E_API_ERROR',
            message: `Author add request failed.`,
            moduleError: {
              code: err.code || 'E_ERROR',
              attrNames: err.attrNames || [],
              message: err.message
            }
          }
        ]
      };
    } // end try {}
  }, // end _addAuthor()

  /*
   * @Function:     _updateAuthor(id, input)
   * @Description:  Update one record of Author
   * @Params:       id - Author Id
   *                input - dictionary of rest of fields to be updated
   * @Return:       Author | ErrorResponse
   */
  _updateAuthor: async (id, input) => {
    const name = input.name;
    const country = input.country;

    if (!id) {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: 'id',
            message: 'Id is required for updation.'
          }
        ]
      };
    }

    let valueNotSet = true;
    let payLoad = {};
    // now set the payLoad value(s)
    if (name !== undefined) {

      if (typeof name !== 'string') {
        return {
          errors: [
            {
              code: 'E_BAD_INPUT',
              attrName: 'name',
              message: 'Name should be of type "string"'
            }
          ]
        };
      }

      valueNotSet = false;
      payLoad.name = name;
    } // end if

    if (country !== undefined) {

      if (typeof country !== 'string') {
        return {
          errors: [
            {
              code: 'E_BAD_INPUT',
              attrName: 'country',
              message: 'Country should be of type "string"'
            }
          ]
        };
      }

      valueNotSet = false;
      payLoad.country = country;
    } // end if

    if (valueNotSet) {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: '',
            message: 'No value(s) sent for updation.'
          }
        ]
      };
    }

    try {
      let result = await Author.updateOne()
        .set(payLoad)
        .where({
          id: id
        }); // .fetch() not required for updateOne() as it always returns the updated record or undefined if not found

      // Success
      result = result || { errors: [ { code: 'I_INFO', message: `No Author exists with the requested Id: ${id}`} ] };
      sails.log.debug(`AuthorHelper._updateAuthor: Author successfully updated:`, result);
      return result;
    } catch (err) {
      sails.log.debug('AuthorHelper._updateAuthor: Exception encountered:', err);
      return {
        errors: [
          {
            code: 'E_API_ERROR',
            message: `Author update request failed.`,
            moduleError: {
              code: err.code || 'E_ERROR',
              attrNames: err.attrNames || [],
              message: err.message
            }
          }
        ]
      };
    } // end try {}
  }, // end _updateAuthor()

  /*
   * @Function:     _deleteAuthor(id)
   * @Description:  Delete one record of Author
   * @Params:       id - Author Id
   * @Return:       Author | ErrorResponse
   */
  _deleteAuthor: async (id) => {
    if (!id) {
      return {
        errors: [
          {
            code: 'E_BAD_INPUT',
            attrName: 'id',
            message: 'Id is required for deletion.'
          }
        ]
      };
    }

    try {
      let result = await Author.destroyOne({id});

      // Success
      result = result || { errors: [ { code: 'I_INFO', message: `No Author exists with the requested Id: ${id}`} ] };
      sails.log.debug(`AuthorHelper._deleteAuthor: Author successfully deleted:`, result);
      return result;
    } catch (err) {
      sails.log.debug('AuthorHelper._deleteAuthor: Exception encountered:', err);
      return {
        errors: [
          {
            code: 'E_API_ERROR',
            message: `Author delete request failed.`,
            moduleError: {
              code: err.code || 'E_ERROR',
              attrNames: err.attrNames || [],
              message: err.message
            }
          }
        ]
      };
    } // end try {}
  }, // end _deleteAuthor()

  /*
   * @Function:     _getAuthor(input)
   * @Description:  Fetch one or more record(s) of Author
   * @Params:       input - dictionary with either Author Id or a filter criteria
   * @Return:       Author | [Author] | ErrorResponse
   */
  _getAuthor: async (input) => {
    const id = input.id;
    let where = input.where || {};

    if (typeof where === 'string') {
      try {
        where = JSON.parse(where);
      } catch(err) {
        return {
          errors: [
            {
              code: 'E_BAD_INPUT',
              attrName: 'where',
              message: 'Where clause should be a valid JSON object.'
            }
          ]
        };
      } // end try
    }

    if (id) {
      where.id = id;
    }

    try {
      // Now fetch the record(s) from database
      let result = await Author.find().where(where);

      if (id) {
        if (result.length > 0) {
          result = result[0];
        } else {
          result = { errors: [ { code: 'I_INFO', message: `No Author exists with the requested Id: ${id}`} ] };
        }
      }

      // Success
      sails.log.debug(`AuthorHelper._getAuthor: Author(s) successfully retrieved:`, result);
      return result;
    } catch(err) {
      sails.log.debug('AuthorHelper._getAuthor: Exception encountered:', err);
      return {
        errors: [
          {
            code: 'E_API_ERROR',
            message: 'Author fetch request failed.',
            moduleError: {
              code: err.code || 'E_ERROR',
              attrNames: err.attrNames || [],
              message: err.message
            }
          }
        ]
      };
    } // end try {}
  }, // end _getAuthor()
};
Enter fullscreen mode Exit fullscreen mode

Register GraphQL middleware in Sails

Finally, having completed the groundwork, we are ready to register express-graphql middleware in Sails application. The best candidate to do this is config/bootstrap.js file. It gets executed when Sails load all hooks.

/**
 * Bootstrap
 * (sails.config.bootstrap)
 *
 * An asynchronous bootstrap function that runs just before your Sails app gets lifted.
 * > Need more flexibility?  You can also do this by creating a hook.
 *
 * For more information on bootstrapping your app, check out:
 * https://sailsjs.com/config/bootstrap
 */
const graphqlHTTP = require('express-graphql');
const { schema } = require('../api/graphql/schemas/schema');

module.exports.bootstrap = async function(done) {
  sails.hooks.http.app.use('/graphql',
    graphqlHTTP((req, res) => ({
      schema: schema,
      context: { req },
      graphiql: false
    }))
  );

  // Don't forget to trigger `done()` when this bootstrap function's logic is finished.
  // (otherwise your server will never lift, since it's waiting on the bootstrap)
  return done();

};
Enter fullscreen mode Exit fullscreen mode

Please pay attention to the context parameter. We are passing an object with one value in it i.e., HTTP Request object. You can add more key-value pairs as per your project/application needs. GraphQL will pass this object literally to all resolvers and directives.

How to invoke the GraphQL endpoint

We will discuss how to invoke the endpoint using Postman and Axios.

Postman Application

Example 1

We will demostrate how to add an author using Postman.

Query
mutation ($input: AuthorInput!) {
    addAuthor(data: $input) {
        ... on Author {
            name
            country
        }
        ... on ErrorResponse {
            errors {
                code
                message
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
Variables
{
    "input": 

    {
            "name": "J. K. Rowling"
    }
}
Enter fullscreen mode Exit fullscreen mode
Output
{
  "data": {
    "addAuthor": {
      "name": "J. K. Rowling",
      "country": "UNKNOWN"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
Screenshot of Postman

screenshot1

Example 2

We will demonstrate an error condition thrown while adding a book. We will send a wrong genre. Notice that our BookHelper returns the error instead of graphql.

Query
mutation ($input: BookInput!) {
    addBook(data: $input) {
        ... on Book {
            title
            yearPublished
            genre
        }
        ... on ErrorResponse {
            errors {
                code
                message
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
Variables
{
    "input": 

    {
            "title": "Harry Potter and the Philosopher's Stone",
            "yearPublished": "1998",
            "genre": "FICTION",
            "authorId": 1
    }
}
Enter fullscreen mode Exit fullscreen mode
Output
{
  "data": {
    "addBook": {
      "errors": [
        {
          "code": "E_BAD_INPUT",
          "message": "Genre should be one of \"'ADVENTURE', 'COMICS', 'FANTASY', 'UNKNOWN'\""
        }
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
Screenshot of Postman

screenshot2

Example 3

We will demonstrate a query on book and author.

Query
query ($id: Int!) {
    getBook(id: $id) {
        ... on Book {
            title
            yearPublished
            genre
            author {
                name
            }
        }
        ... on ErrorResponse {
            errors {
                code
                message
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
Variables
{
    "id": 1 
}
Enter fullscreen mode Exit fullscreen mode
Output
{
  "data": {
    "getBook": {
      "title": "Harry Potter and the Philosopher's Stone",
      "yearPublished": "1998",
      "genre": "FANTASY",
      "author": {
        "name": "J. K. Rowling"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
Screenshot of Postman

screenshot3

Front-end applications

We will provide examples of code using axios to execute graphql queries. If you use some other package to place your HTTP requests then adapt the example code to your package of choice.

Example 1

Example of a query

Query
this.$axios({
  url: '/graphql',
  method: 'POST',
  data: {
    query: `query ($filter: String) {
      getBooks(filter: $filter) {
        ... on Book {
            title
            yearPublished
            genre
            author {
                name
                country
            }
        }
        ... on ErrorResponse {
            errors {
                code
                message
            }
        }
      }
    }
    `,
    variables: {
      filter: JSON.stringify({
        genre: this.genre  // genre = 'FANTASY'
      })
    }
  }
}).then((response) => {
  let resp = response.data.data.getBooks
  if (resp.length > 0) {
    if (resp[0].errors) {
      // api threw an array of error objects
      const err = {
        response: {
          data: resp[0].errors[0]
        }
      }
      console.log(err)
    } else {
      // success
      console.log(resp)
    }
  }
}).catch((err) => {
  console.log(err)
})
Enter fullscreen mode Exit fullscreen mode
Output
{
  "data": {
    "getBooks": [
      {
        "title": "Harry Potter and the Philosopher's Stone",
        "yearPublished": "1998",
        "genre": "FANTASY",
        "author": {
          "name": "J. K. Rowling",
          "country": "UNKNOWN"
        }
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Example 2

Example of a mutation

Query
this.$axios({
  url: '/graphql',
  method: 'POST',
  data: {
    query: `mutation ($id: Int!, $data: AuthorInput!) {
      updateAuthor(id: $id, data: $data) {
        ... on Author {
            name
            country
        }
        ... on ErrorResponse {
            errors {
              code
              message
            }
        }
      }
    }
    `,
    variables: {
      filter: JSON.stringify({
        id: this.id, // id = 1
        data: { 
          country: this.country // country = 'United Kingdom'
        }
      })
    }
  }
}).then((response) => {
  let resp = response.data.data.updateAuthor
  if (resp.length > 0) {
    if (resp[0].errors) {
      // api threw an array of error objects
      const err = {
        response: {
          data: resp[0].errors[0]
        }
      }
      console.log(err)
    } else {
      // success
      console.log(resp)
    }
  }
}).catch((err) => {
  console.log(err)
})
Enter fullscreen mode Exit fullscreen mode
Output
{
  "data": {
    "updateAuthor": {
      "name": "J. K. Rowling",
      "country": "United Kingdom"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Piece of Advice

The graphql runtime error messages are very vague when you develop the schema of your project. It will not pinpoint exactly where you have an error in your schema definition. It will simply spit out the expected token and what it found while parsing your schema. So to make your life a bit easier, I would suggest to add the following console.log() in the file node_modules/graphql/language/parser.js line# 95. This will give you better insight into your schema for taking remedial action.

...
  _proto.parseName = function parseName() {
    var token = this.expectToken(_tokenKind.TokenKind.NAME);

    console.log(`Line: ${this.loc(token).startToken.line}, Column: ${this.loc(token).startToken.column}, Value: ${this.loc(token).startToken.value}`);

    return {
      kind: _kinds.Kind.NAME,
      value: token.value,
      loc: this.loc(token)
    };
  } // Implements the parsing rules in the Document section.
...
Enter fullscreen mode Exit fullscreen mode

CAUTION: Please add this console.log() in development environment only. It will clutter your sails server log.

Closing words

I would recommend and encourage readers to learn GraphQL. We have touched upon a subset of GraphQL schema features. GraphQL provides three types of operations - queries, mutations and subscriptions. We have seen first two in action, I leave it to the reader to try subscriptions in case your application needs pub-sub type of interaction.

The complete project is available on Github.

Please write your comments, good or bad, whatever you feel like.
Thanks for your time. Hope you enjoyed!!

Top comments (7)

Collapse
 
vrajasekhar1 profile image
vrajasekhar1

Hi, when I try to use the following query, I get an error: "Cannot return null for non-nullable field Book.title". How to resolve this?

query ($id: Int!) {
    getAuthor(id: $id) {
        ... on Author {
          name     
          books {
            title
          }
        }
        ... on ErrorResponse {
            errors {
                code
                message
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
vrajasekhar1 profile image
vrajasekhar1 • Edited

Issue is resolved now. I figured out a few minor javascript issues with using typeof on Array and also returning an array using [result]. There are different ways to achieve these. Also, _getbook, _getauthor need to be called with await and corresponding arrow functions need to be async. With these changes, all issues resolved and its working fine now. Thanks for this wonderful write up. This helped me to learn GraphQL with Sails quickly.

Collapse
 
aflatoon2874 profile image
RSA

Thanks Raj for pointing out the missing await keywords in the book and author resolvers. Have fixed the github repo as well as this article. I am glad the article helped you. :)

Thread Thread
 
vrajasekhar1 profile image
vrajasekhar1

Also, what about "typeof result"? This never returns Array. So, what's the significance of using typeof result? Instead, should we use Array.isArray(result) or "result instanceof Array"? Or am I missing something? Also, "return [ result ]" does not work for me. Is there any other simpler alternative to handle Array conversion in such cases? Thanks again.

Thread Thread
 
aflatoon2874 profile image
RSA

Yes you are right, you need to use either forms to check for array. typeof does not return array that is why the check fails.

Thread Thread
 
vrajasekhar1 profile image
vrajasekhar1

Thank you. One request, would you be able to share similar write ups on the following topics?
1) How to handle subscriptions with GraphQL?
2) How to handle file uploads using GraphQL, specifically to one of GCP or AWS?
That will be really helpful. Thanks in advance.

Thread Thread
 
aflatoon2874 profile image
RSA

You don't need graphql for both use cases. Stick to sails built-in support of sockets for pub-sub use cases and skipper package for file uploads. Skipper packages are available for AWS and GCP on npmjs.