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
Now, we will install the graphql npm packages that are relevant for our project.
cd sails-graphql
npm install graphql graphql-tools express-graphql
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.
-
Book.js
-
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
}
}
};
/**
* 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'
}
}
};
Define GraphQL Schema, Policies and Helpers
Create the following folder structure where our various artifacts will live.
api
|
-- graphql
|
-- helpers
|
-- policies
|
-- schemas
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
auth.js
-
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()
};
/**
* 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
}
};
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
}
]
};
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
});
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
@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]
}
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();
}
};
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 theexpress-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
});
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
};
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';
}
},
},
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
, mutations
declaration 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
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
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;
},
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;
},
},
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
};
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');
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}
}
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
};
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
});
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()
};
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()
};
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();
};
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
}
}
}
}
Variables
{
"input":
{
"name": "J. K. Rowling"
}
}
Output
{
"data": {
"addAuthor": {
"name": "J. K. Rowling",
"country": "UNKNOWN"
}
}
}
Screenshot of Postman
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
}
}
}
}
Variables
{
"input":
{
"title": "Harry Potter and the Philosopher's Stone",
"yearPublished": "1998",
"genre": "FICTION",
"authorId": 1
}
}
Output
{
"data": {
"addBook": {
"errors": [
{
"code": "E_BAD_INPUT",
"message": "Genre should be one of \"'ADVENTURE', 'COMICS', 'FANTASY', 'UNKNOWN'\""
}
]
}
}
}
Screenshot of Postman
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
}
}
}
}
Variables
{
"id": 1
}
Output
{
"data": {
"getBook": {
"title": "Harry Potter and the Philosopher's Stone",
"yearPublished": "1998",
"genre": "FANTASY",
"author": {
"name": "J. K. Rowling"
}
}
}
}
Screenshot of Postman
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)
})
Output
{
"data": {
"getBooks": [
{
"title": "Harry Potter and the Philosopher's Stone",
"yearPublished": "1998",
"genre": "FANTASY",
"author": {
"name": "J. K. Rowling",
"country": "UNKNOWN"
}
}
]
}
}
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)
})
Output
{
"data": {
"updateAuthor": {
"name": "J. K. Rowling",
"country": "United Kingdom"
}
}
}
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.
...
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)
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?
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.
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. :)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.
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.
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.
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.