preloader
心得

Tutorial of Basic GraphQL API Service Setup

Tutorial of Basic GraphQL API Service Setup

I had been heard that GraphQL is a great tool for web frontend engineer because frontend engineer can query any form of data for variable needs of mobile-web and app. I want to explore GraphQL how to use, which ecosystem is becoming matured.

In this article, we will go through it, and please following indexes. You can learn how to setup basic GraphQL API server without throttling.

Article Index

  • Introduction of libraries and environment we use
  • Build helper functions Part 1: DB, custom error message and status code, and error function.
  • Example models: Node, User and Role with library: Join-Monster and knex
  • Build Query and Mutation
  • Server setting of middleware fundamental for login : Passport, connect-session-knex and express-session
  • Build helper functions Part 2: Role and Permission
  • Server setting of API guard: graphql-shield
  • Conclusion

 

List of package version:

  • bcrypt 5.0.1
  • connect-session-knex 2.1.0
  • express 4.17.1
  • express-session 1.17.2
  • graphql 15.5.1
  • graphql-middleware 6.1.13
  • graphql-passport 0.6.3
  • graphql-relay 0.8.0
  • graphql-scalars 1.10.0
  • graphql-shield 7.5.0
  • join-monster 3.1.1
  • knex 0.95.6
  • passport 0.4.1
  • pg 8.6.0
  • uuid 8.3.2
  • date-fns 2.23.0
  • dotenv 10.0.0

Notice: This below content is not including description of production-ready graphql API server. Production-ready graphql API server also needs mechanism of security such as data cache, API rate-limiting and login recaptcha, ……etc.

 


Introduction of libraries and environment

You can skip this part if you know what purpose of these libraries and frameworks.

NodeJS

NodeJS logo
Figure. NodeJS logo

NodeJS is a runtime environment that js files can run at server-side. In this tutorial, we use it as fundamental part for running our graphql API service.

 

Express

Express is a web framework based on NodeJS for server-side. We use it because its middleware ecosystem is comprehensive, we want to use some middlewares.

 

Express-GraphQL

Express-graphql is a middleware that supporting connect styled framework for creating a server of graphql http. We use it with express to launch a graphql server.

 

GraphQL, GraphQL-Scalars

GraphQL logo
Figure. GraphQL logo

GraphQL is a query language for APIs and runtime for quering parameters flexibly, which is comparing to RESTful API. We use is as core part of backend API service.

GraphQL-Scalars logo
Figure. GraphQL-Scalars logo

GraphQL-Scalars is a library, which is providing common data type being used type of response with graphql. We use it to define models' type and relate things.

 

GraphQL-Shield

GraphQL-Shield logo
Figure. GraphQL-Shield logo

GraphQL-Shield is a permission framework for graphql and it can be used to check whether incoming requests are authorized or not. We use it for guarding API with role-permission models.

 

Knex

Knex logo
Figure. Knex logo

Knex is a library that generating SQL statements using js and accessing database. It can generate SQL statements of postgresql, mysql, ……etc. You can consider it as a kind of ORM tools. We use it for accessing content in database.

 

Join-Monster

Join-Monster logo
Figure. Join-Monster logo

Join-Monster is a library of query builder for transforming statements of graphql to SQL. We use it at data models, relation mappings of tables such as one-to-many and many-to-many and batch query.

 

Express-session, connect-session-knex, passport, graphql-passport

Passport.js logo
Figure. Passport.js logo

Express-session is a module of management session with express framework and it provides functions for accessing and evaluate sessions of requests at server-side. We use it to integrate other modules/libraries for automatically managing sessions and guarding APIs.
Connect-session-knex is a library based on knex for storing sessions with database and it can work with express-session package and databases such as postgresql, mysql, and mssql, ……etc. We use it to manage session with a table in a database and it handles operation of CRUD about sessions.
Passport is an authentication middleware of nodejs and it’s compatible with express framework. We use it to authenticate users, generate a session if an user is logined and get user information from request by deserialize. It can also work with graphql-passport package for getting user information from incoming request under scenarios of mutating content in database.
GraphQL-passport is a library of authenticating users coworking with passport package under scenarios of graphql’s query and mutation. It provides passport functionalities to authenticate users using graphql’s context. We use it and passport package to evaluate and provide user information with context variable of graphql for every API entry. This is helpful especially under some scenarios such as preventing an normal-level user trying to delete other’s data if checking user info inside an incoming request first and making sure to-be-deleted data is not belong to he/she.

 

UUID

UUID is a library of generating unique identifiers and values it generates are not human-readable. We use it for generating session id.

 

DotEnv

DotEnv logo
Figure. DotEnv logo

DotEnv is a library of loading value from file: .env to runtime environment. We use it to load important settings to server such as database connection.

 

Bcrypt

Bcrypt is a library for hash credential such user passwords or other important things. Here we use it to hash users' passwords.

 


Build helper functions Part 1

DB access helper

src/helpers/db.js content is used for accessing database with related settings and it will be exported as common function.

// src/helpers/db.js
require("dotenv").config();

const db = require("knex")({
  client: "pg",
  version: "10",
  connection: {
    host: process.env.DB_HOST,
    port: process.env.DB_PORT,
    user: process.env.DB_ACCOUNT,
    password: process.env.DB_PASSWORD,
    database: process.env.DB_DBNAME,
  },
});

module.exports = {
  db,
};

 

Custom error message and status code

src/helpers/constants.js contains constants and customized message of error status.[9] These constants and status code will be used as response content, which indicates what happened a client needs to know. You can place your constants where you want as long as it follows a consistant design

// src/helper/constants.js
const errorName = {
  UNAUTHORIZED: "UNAUTHORIZED",
  NO_MATCHED_USER: "no matched user",
  WRONG_PASSWORD: "wrong password",
  NOT_FOUND_RESOURCE: "no found resource",
  ALREADY_REGISTERED_USER: "ALREADY_REGISTERED_USER",
  CREATE_ROLE_ERROR: "CREATE_ROLE_ERROR",
  UNKNOWN_ERROR: "UNKNOWN_ERROR",
};

const errorType = {
  UNAUTHORIZED: {
    message: "Authentication is needed to get requested response",
    statusCode: 401,
  },
  "no matched user": {
    message: "No existed user",
    statusCode: 404,
  },
  "wrong password": {
    message: "Password is wrong",
    statusCode: 400,
  },
  "no found resource": {
    message: "Not found resource you want, please check request again",
    statusCode: 404,
  },
  ALREADY_REGISTERED_USER: {
    message: "User with same account and email already existed",
    statusCode: 400,
  },
  CREATE_ROLE_ERROR: {
    message: "Cannot create a new role, please contact system admin.",
    statusCode: 500,
  },
  UNKNOWN_ERROR: {
    message: "It happens unknown error, please contact system admin.",
    statusCode: 500,
  },
};

module.exports = {
  errorName,
  erorrType,
};

 

Inquiry error code function

src/helpers/error.js content has getErrorCode() as a common function for retrieving custom error message and status code if .[9]

// src/helpers/error.js
const { errorType } = require("./constants");

const getErrorCode = (errorName) => {
  return errorType[errorName];
};

module.exports = {
  getErrorCode,
};

 

.env file content

.env file contains settings and credentials for running graphql API service. You should put your setting into it.

Notice: You should add this file to .gitignore for excluding from scope of git commit.

.env:

DB_HOST=YOUR_HOST
DB_PORT=YOUR_PORT
DB_DBNAME=YOUR_DBNAME
DB_ACCOUNT=YOUR_ACCOUNT
DB_PASSWORD=YOUR_PASSWORD
SESSION_SECRET=['YOUR_SECRET_1', 'YOUR_SECRET_2']
PASSWORD_SALT_ROUND=YOUR_SALT_ROUND

 


Example models

We want to models have common fields such as id, created_at, updated_at and deleted, so we create Node as an interface. An interface can be used in concrete models. We will use Node at models of User and Role.

Notice: Code description are added to comment-form.

Node

This is declared as interface type.

// src/model/Node.js
const graphql = require("graphql");
const gScalarType = require("graphql-scalars");

const Node = new graphql.GraphQLInterfaceType({
  // here declare as interface type
  name: "Node",
  fields: () => ({
    id: {
      type: graphql.GraphQLID,
    },
    created_at: {
      type: gScalarType.GraphQLDateTime,
    },
    updated_at: {
      type: gScalarType.GraphQLDateTime,
    },
    deleted: {
      type: graphql.GraphQLBoolean,
    },
  }),
});

module.exports = {
  Node,
};

 

User

I expect an user can login with account and password so this model must have these two fields. By the way, we prepare a field email as contact information. As mentioned before, we want models has common fields so User model will use Node interface.
This model is also mapping a table, which is named users, using joinMonster satement by sqlTable and mapping concrete fields using sqlColumn inside joinMonster statement.

As expecting to query roles within users using graphql power, so I add roles as a field in User model.

// src/model/User.js
// This is excerpted code

const graphql = require("graphql");
const gScalarType = require("graphql-scalars");
const joinMonster = require("join-monster");
const { Node } = require("./Node");

// declare as concrete model
const User = new graphql.GraphQLObjectType({
  name: "User",
  interfaces: [Node], // here use Node with attribute: interfaces.
  extensions: {
    // Use joinMonster by extensions way.
    joinMonster: {
      sqlTable: "users", // mapping to actual table in database
      uniqueKey: "id", // indicate which key using attribute: uniqueKey
    },
  },
  fields: () => {
    const { Role } = require("./Role"); // use in roles field and this can fix circular import.
    return {
      id: {
        type: graphql.GraphQLID,
        extensions: {
          joinMonster: {
            sqlColumn: "id", // mapping to actual field: id in table: users. This follows the interface: Node
          },
        },
      },
      email: {
        type: gScalarType.GraphQLEmailAddress, // verify by package: graphql-scalars
        extensions: {
          joinMonster: {
            sqlColumn: "email", // mapping to actual field: email in table: users
          },
        },
      },
      account: {
        type: graphql.GraphQLString,
        extensions: {
          joinMonster: {
            sqlColumn: "account", // mapping to actual field: account in table: users
          },
        },
      },
      password: {
        type: graphql.GraphQLString,
        extensions: {
          joinMonster: {
            sqlColumn: "password", // mapping to actual field: password in table: users
          },
        },
      },
      created_at: {
        type: gScalarType.GraphQLDateTime,
        extensions: {
          joinMonster: {
            sqlColumn: "created_at", // mapping to actual field: created_at in table: users. This follows the interface: Node
          },
        },
      },
      updated_at: {
        type: gScalarType.GraphQLDateTime,
        extensions: {
          joinMonster: {
            sqlColumn: "updated_at", // mapping to actual field: updated_at in table: users. This follows the interface: Node
          },
        },
      },
      deleted: {
        type: graphql.GraphQLBoolean,
        extensions: {
          joinMonster: {
            sqlColumn: "deleted", // mapping to actual field: created_at in table: users. This follows the interface: Node
          },
        },
      },
      roles: {
        type: new graphql.GraphQLList(Role), // declare this field's type is list of Role model.
        args: {
          limit: { type: graphql.GraphQLInt },
          offset: { type: graphql.GraphQLInt },
        },
        description: "some roles",
        extensions: {
          joinMonster: {
            // use joinMonster to build many-to-many mapping
            junction: {
              sqlTable: "user_role_rel", // use actual table: user_role_rel in database
              sqlJoins: [
                // This is many-to-many relationship using join-monster
                (userTable, userRoleRelTable) =>
                  `${userTable}.id = ${userRoleRelTable}.uid`, // left join table: users.id to table: user_role_rel.uid
                (userRoleRelTable, roleTable) =>
                  `${userRoleRelTable}.rid = ${roleTable}.id`, // left join table: user_role_rel.rid to table: role.id
              ],
            },
            orderBy: {
              // listing content by field: created_at descendent order
              created_at: "desc",
            },
          },
        },
      },
    };
  },
});

module.exports = {
  User,
};

 

Role

I expect Role model must have name and created_by fields for indicating role’s name and is created by which user, so its fields contain name and created_by field. It’s also a concrete model and should have common fields so I set it to use interface: Node.

I also want to know which users has a dedicate role, so I wish can query users under Role model by adding a field: users to this.

// src/model/Role.js
// This is excerpted code

const graphql = require("graphql");
const gScalarType = require("graphql-scalars");
const { Node } = require("./Node");

// declare as concrete model
const Role = new graphql.GraphQLObjectType({
  name: "Role",
  interface: [Node], // here use Node with attribute: interfaces.
  extensions: {
    // Use joinMonster by extensions way.
    joinMonster: {
      sqlTable: "roles", // mapping to actual table in database
      uniqueKey: "id", // indicate which key using attribute: uniqueKey
    },
  },
  fields: () => {
    const { User } = require("./User"); // use in users field and this can fix circular import.
    return {
      id: {
        type: graphql.GraphQLID,
        extensions: {
          joinMonster: {
            sqlColumn: "id", // mapping to actual field: id in table: roles. This follows the interface: Node
          },
        },
      },
      name: {
        type: graphql.GraphQLString,
        extensions: {
          joinMonster: {
            sqlColumn: "name", // mapping to actual field: name in table: roles
          },
        },
      },
      created_by: {
        // This field indicates a role was created by which user.
        type: graphql.GraphQLID,
        extensions: {
          joinMonster: {
            sqlColumn: "created_by", // mapping to actual field: created_by in table: roles
          },
        },
      },
      updated_by: {
        // This field indicates a role was updated by which user.
        type: graphql.GraphQLID,
        extensions: {
          joinMonster: {
            sqlColumn: "updated_by", // mapping to actual field: updated_by in table: roles
          },
        },
      },
      created_at: {
        type: gScalarType.GraphQLDateTime,
        extensions: {
          joinMonster: {
            sqlColumn: "created_at", // mapping to actual field: created_at in table: roles. This follows the interface: Node
          },
        },
      },
      updated_at: {
        type: gScalarType.GraphQLDateTime,
        extensions: {
          joinMonster: {
            sqlColumn: "updated_at", // mapping to actual field: updated_at in table: roles. This follows the interface: Node
          },
        },
      },
      deleted: {
        type: graphql.GraphQLBoolean,
        extensions: {
          joinMonster: {
            sqlColumn: "deleted", // mapping to actual field: deleted in table: roles. This follows the interface: Node
          },
        },
      },
      users: {
        type: new graphql.GraphQLList(User),
        // declare this field's type is list of User model.
        args: {
          limit: { type: graphql.GraphQLInt },
          offset: { type: graphql.GraphQLInt },
        },
        description: "some users",
        extensions: {
          joinMonster: {
            // use joinMonster to build many-to-many mapping
            junction: {
              sqlTable: "user_role_rel", // use actual table: user_role_rel in database
              sqlJoins: [
                (roleTable, userRoleRelTable) =>
                  `${roleTable}.id = ${userRoleRelTable}.rid`, // left join table: roles.id to table: user_role_rel.rid
                (userRoleRelTable, userTable) =>
                  `${userRoleRelTable}.uid = ${userTable}.id`, // left join table: user_role_rel.uid to table: users.id
              ],
            },
            orderBy: {
              // listing content by field: created_at descendent order
              created_at: "desc",
            },
          },
        },
      },
    };
  },
});

module.exports = {
  Role,
};

 


Build Query and Mutation

Query is the place where query entries situated in graphql and only used for query purpose. It can be defined as a concrete object type of graphql and contains many entries for different models. Its behavior is just like read operation in C.R.U.D. abbreviate for create-read-update-delete.

Mutation is the place where operation entries such as create, update, and delete towards related models in graphql. It can be defined as a concrete object type of graphql too, whose is just liked Query.

I use QueryRoot here as Query’s name and MutationRoot as Mutation’s name. These two names will be used at section of graphql-shield so you should refer them here carefully.

Query

We can use Query.js to query users and further query roles info under users based on many-to-many join mapping from above.

// src/model/Query.js
// This is excerpted code.

const graphql = require("graphql");
const { default: joinMonster } = require("join-monster");
const { User } = require("./User");
const { db } = require("../helpers/db");

// declare as concrete model
const QueryRoot = new graphql.GraphQLObjectType({
  name: "QueryRoot",
  fields: () => ({
    currentUser: {
      // return user info from current incoming request
      type: User, // return type is user info
      resolve: async (parent, args, context) => {
        return await context.getUser(); // use passport context to get user info from current request
      },
    },
    users: {
      // return list of users
      type: graphql.GraphQLList(User),
      args: {
        limit: { type: graphql.GraphQLInt },
        /*
         * act as one of query parameters for
         * limiting how many records in one page
         */
        offset: { type: graphql.GraphQLInt },
        /**
         * act as one of query parameters for
         * listing record from where.
         */
      },
      extensions: {
        joinMonster: {
          /**
           * use extension: joinMonster.
           * use its orderBy for field: created_at descendantly
           */
          orderBy: {
            created_at: "desc",
          },
        },
      },
      resolve: (parent, { limit = 10, offset = 0 }, context, resolveInfo) => {
        /**
         * use joinMonster() and raw SQL it generates to integrate
         * parameters of limit and offset.
         * And then use knex to execute it.
         * If you want to know more, refer this link https://join-monster.readthedocs.io/en/v0.9.9/call-function/
         */
        return joinMonster(
          resolveInfo,
          {},
          (sql) => {
            let limitStr = "",
              offsetStr = "";
            if (typeof limit !== "undefined") {
              limitStr = `LIMIT ${limit}`;
            }
            if (typeof offset !== "undefined") {
              offsetStr = `OFFSET ${offset}`;
            }

            return db.raw(`${sql} ${limitStr} ${offsetStr}`);
          },
          { dialect: "pg" }
        );
      },
    },
  }),
});

module.exports = {
  QueryRoot,
};

Mutation

Operations of create, update and deletion can be put at Mutation. Below contains login, signup, createUser, updateUser, createRole, createUserRoleRel, updateUserRoleRel, …etc. Many description are been placed in form of code comment, please read them carefully.

// src/model/Mutation.js
// This is excerpted code
require("dotenv").config();
const graphql = require("graphql");
const gScalarType = require("graphql-scalars");
const { User } = require("./User");
const bcrypt = require("bcrypt");
const saltRounds = process.env.PASSWORD_SALT_ROUND;
const addDays = require("date-fns/addDays");
const { errorName } = require("../helpers/constants");
const { db } = require("../helpers/db");
const { Role } = require("./Role");
const { UserRoleRel } = require("./UserRoleRel");

// declare as concrete model
const MutationRoot = new graphql.GraphQLObjectType({
  name: "MutationRoot",
  fields: () => ({
    login: {
      /**
       * Use email ans password as arguments for login
       */
      type: User,
      args: {
        email: { type: graphql.GraphQLNonNull(graphql.GraphQLString) },
        password: { type: graphql.GraphQLNonNull(graphql.GraphQLString) },
      },
      resolve: async (parent, { email, password }, context) => {
        /**
         * use context in graphql and passport in nodejs
         * to do session-based login
         * with passport's graphql-local strategy.
         * This will generate a record in table
         */

        // find a matched record in table
        const { user } = await context.authenticate("graphql-local", {
          email,
          password,
        });

        // generate a record in table about session.
        await context.login(user);

        // return user in response
        return user;
      },
    },
    logout: {
      type: graphql.GraphQLBoolean,
      resolve: async (parent, args, context) => {
        /**
         * use session within request of context
         * to destroy session in table
         */
        context.req.session.destroy();

        /**
         * use logout of context
         * built by buildContext of
         * graphql-passport package
         */
        await context.logout();

        // return true in response
        return true;
      },
    },
    signup: {
      type: User,
      args: {
        /**
         *  use email, account and password to signup
         */
        email: { type: graphql.GraphQLNonNull(graphql.GraphQLString) },
        account: { type: graphql.GraphQLNonNull(graphql.GraphQLString) },
        password: { type: graphql.GraphQLNonNull(graphql.GraphQLString) },
      },
      resolve: async (parent, args, context) => {
        /**
         * First, checking whether account and email
         * are exist in table or not.
         * Get first one from result.
         */
        let existingUser = await db(`users`)
          .where("account", args.account)
          .andWhere("email", args.email)
          .andWhere("deleted", false)
          .returning("*");
        [existingUser] = [...existingUser];

        /**
         * Do bcrypt with saltRound
         * and password from arguments
         */
        const encryptedPassword = bcrypt.hashSync(
          args.password,
          parseInt(saltRounds, 10)
        );
        args.password = encryptedPassword;

        if (existingUser) {
          /**
           * If there exists one user,
           * throws error.
           * Notice: you should change to fit
           * content in constants.js
           */
          throw new Error(errorName.ALREADY_REGISTERED_USER);
        }

        let newOne = await db(`users`).insert(args).returning("*");

        // newOne: [{"id":"xxxxxxxx-b286-4d2b-a66c-33b825a7d125","email":"[email protected]","account":"xxxx","password":"xxxxx","created_at":"2021-07-23T07:58:16.158Z","updated_at":"2021-07-23T07:58:16.158Z","deleted":false}]

        // get it from array
        [newOne] = [...newOne];

        // login and create session in table
        await context.login(newOne);

        // return created user in http response
        return newOne;
      },
    },
    updateUser: {
      /**
       * Update user information of the user
       * from incoming request within graphql's context
       * Notice: User cannot update others information.
       */
      type: User,
      args: {
        account: { type: graphql.GraphQLString },
        email: { type: graphql.GraphQLString },
        password: { type: graphql.GraphQLString },
        deleted: { type: graphql.GraphQLBoolean },
      },
      resolve: async (
        parent,
        { account, email, password, deleted },
        context
      ) => {
        let user = await context.getUser();

        if (!user) {
          /**
           * If it cannot extract user from context,
           * throw unknown error
           */
          throw new Error(errorName.UNKNOWN_ERROR);
        }

        /**
         * If a incoming request has a parameter named password, and it's not undefined then we encrypt it with bcrypt algorithm
         */
        let encryptedPassword = "";
        if (typeof password !== "undefined") {
          encryptedPassword = bcrypt.hashSync(
            password,
            parseInt(saltRounds, 10)
          );
        }

        /**
         * If one of parameters in request are not undefined
         * such as account, email, password and deleted,
         * we put non-undefined of them to updateObj
         * and then update it to table record.
         * Finally, return updated result in http response.
         * If all of them are undefined,
         * we just return null.
         */

        let updateObj = {};
        if (typeof account !== "undefined") {
          updateObj["account"] = account;
        }
        if (typeof email !== "undefined") {
          updateObj["email"] = email;
        }
        if (encryptedPassword !== "") {
          updateObj["password"] = encryptedPassword;
        }
        if (typeof deleted !== "undefined") {
          updateObj["deleted"] = deleted;
        }

        if (updateObj === {}) {
          return null;
        }

        let updatedOne = await db(`users`)
          .where("id", user.id)
          .update(updateObj)
          .returning("*");
        [updatedOne] = [...updatedOne];

        return updatedOne;
      },
    },
    createRole: {
      /**
       * Create role by providing role name.
       *
       */
      type: Role,
      args: {
        name: { type: graphql.GraphQLNonNull(graphql.GraphQLString) },
      },
      resolve: async (parent, { name }, context) => {
        try {
          /**
           * Before creating role,
           * let check whether it already exists
           * in table.
           * If it exists, throw error with  duplicate role
           * If it does not exist, add it to table and
           * return created one in http response
           * */
          let existingRole = await db(`roles`)
            .where(`name`, name)
            .returning("*");

          if (existingRole.length > 0) {
            return new Error(errorName.DUPLICATE_ROLE);
          }

          let newOne = await db(`roles`)
            .insert({ name, created_by: context.req.user.id })
            .returning("*");
          [newOne] = [...newOne];
          return newOne;
        } catch (error) {
          /**
           * Warning: You should create your
           * CREATE_ROLE_ERROR in constants.js
           */
          return new Error(errorName.CREATE_ROLE_ERROR);
        }
      },
    },
    createUserRoleRel: {
      /**
       * Create relationship between user and role
       */
      type: UserRoleRel,
      args: {
        uid: { type: graphql.GraphQLNonNull(gScalarType.GraphQLGUID) },
        rid: { type: graphql.GraphQLNonNull(gScalarType.GraphQLGUID) },
      },
      resolve: async (parent, { uid, rid }, context) => {
        /**
         * Create relationship record
         * between user and role.
         * After creating successfully,
         * return created one in http response
         */
        let record = await db(`user_role_rel`)
          .insert({
            uid,
            rid,
            created_by: context.req.user.id,
          })
          .returning("*");

        [record] = [...record];

        return record;
      },
    },
    deleteUserRoleRel: {
      /**
       * Delete relationship record between
       * user and role using way of
       * soft delete by
       * given user id and role id.
       */
      type: UserRoleRel,
      args: {
        uid: { type: graphql.GraphQLNonNull(gScalarType.GraphQLGUID) },
        rid: { type: graphql.GraphQLNonNull(gScalarType.GraphQLGUID) },
      },
      resolve: async (parent, { uid, rid }, context) => {
        /**
         * Delete relationship record between
         * user and role.
         * After successfully deletion,
         * return deleted one's information
         */
        let record = await db(`user_role_rel`)
          .where(`uid`, uid)
          .andWhere(`rid`, rid)
          .update({
            deleted: true,
            updated_by: context.req.user.id,
            updated_at: new Date().now(),
          })
          .returning("*");

        [record] = [...record];

        return record;
      },
    },
  }),
});

module.exports = {
  MutationRoot,
};

 


Server setting of middleware fundamental for login: Passport, connect-session-knex and express-session

This section will help you build session-based graphql API server without API guard. I will use server.js file to present how to setup a graphql API server at local and many descriptions are placed at code comment.

session setting

In file server.js, there are few steps need to do by order:

  • import express-session package
  • import connect-session-knex package and apply session functions, which comes from express-session to it
  • import db helper you’ve done above
// src/server.js
// This is excerpted code
const express = require("express");
const session = require("express-session");
const KnexSessionStore = require("connect-session-knex")(session);
const { db } = require("./helper/db");

const sessionStoreDB = KnexSessionStore({
  knex: db,
  table: "sessions",
  /**
   * You can define your own name
   * for storing sessions in table
   */
});

 

Passport setting and integrate with session

After doing this section, the graphql API server still cannot start to run because above metioned models are not known by express. Please be patient and I will reveal it at last part of this tutorial.

In file server.js, there are steps about integrating passport with session:

  • import passport package and passport integrate session
  • import graphql-passport package and integrate graphql with passport
  • import uuid package for generating identifier
// src/server.js
// This is excerpted code
const passport = require("passport");
const { buildContext, GraphQLLocalStrategy } = require("graphql-passport");
const { v4: uuidv4 } = require("uuid");

/**
 * For passport register and unregister user
 * to session by serializeUser()
 * and deserializeUser()
 * Start
 */
passport.serializeUser((user, done) => {
  done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
  // use id to find matched user
  let matchedUser = await db("users").where("id", id).returning("*");

  // get first one
  [matchedUser] = [...matchedUser];

  /**
   * return matchedUser using passport's way
   * by done function, which is passed via parameter way.
   */
  done(null, matchedUser);
});
/**
 * For passport register and unregister user
 * to session by serializeUser()
 * and deserializeUser()
 * End
 */

/**
 * For passport integrate graphql
 * using graphql-passport
 * Start
 */
passport.use(
  new GraphQLLocalStrategy(
    {
      /**
       * passReqToCallback is true if you
       * want to use request object
       * in latter async function,
       * and req will appear as first parameter
       * in that function.
       * Below example is set it to true,
       * but not use request object in async function.
       */
      passReqToCallback: true,
    },
    async (req, email, password, done) => {
      /**
       * Find matched user via email.
       * If there is no matched, return error with done().
       * If there is found one, further compare
       * passwords between founded and one within request
       * by bcrypt's compareSync().
       * If the compare result is true, return
       * null and matchedUser with done().
       *
       */
      let matchedUser = await db(`users`).where("email", email).returning("*");
      [matchedUser] = [...matchedUser];
      let error = null;
      if (matchedUser === undefined) {
        error = new Error(errorName.NO_MATCHED_USER);
        return done(error);
      }

      let compareResult = bcrypt.compareSync(password, matchedUser.password);

      error =
        matchedUser && compareResult
          ? null
          : new Error(errorName.WRONG_PASSWORD);

      done(error, matchedUser);
    }
  )
);
/**
 * For passport integrate graphql
 * using graphql-passport
 * End
 */

/**
 * For express integrating passport
 * and session start
 */

// user express as server-running fundamental
const app = express();

/**
 * apply session, session-store, uuidv4()
 * to express-server
 */
app.use(
  session({
    resave: false,
    saveUninitialized: false,
    secret: process.env.SESSION_SECRET,
    unset: "destroy",
    store: sessionStoreDB,
    cookie: {
      maxAge: 1000 * 60 * 60 * 7,
    },
    genid: () => uuidv4(),
  })
);

/**
 * apply passport setting to express-server
 */
app.use(passport.initialize());

/**
 * apply passport setting with session to
 * express-server
 */
app.use(passport.session());

/**
 * For express integrating passport
 * and session end
 */

 


Build helper funtion Part 2: Role and Permission

Helper function for Role

getRolesOfUser() is used for getting roles of an user by an user identifier. I use it during scenario of checking permissions of an incoming request.

// helpers/util.js
// This is excerpted code

const { db } = require("./db");
const { errorName } = require("./constants");

const getRolesOfUser = async (userId) => {
  /**
   * If the parameter: userId is null or undefined,
   * and then return null directly.
   * Use the parameter: userId to find
   * relationship records.
   * If there exist records,
   * get information of relate roles
   * from table and take them as result to return.
   * If there is no records, return error
   * of not found.
   * If it happens any unexpected error,
   * return unknown error.
   */

  if (userId === null || typeof userId === "undefined") {
    return null;
  }

  let listOfRoleIds = null,
    listOfRoles = null;

  try {
    let aryOfRoleIds = await db
      .select("rid as id")
      .table("user_role_rel")
      .where("uid", userId)
      .andWhere("deleted", false);

    if (aryOfRoleIds.length === 0) {
      return new Error(errorName.NOT_FOUND_RESOURCE);
    }

    listOfRoleIds = [];
    for (const { id } of aryOfRoleIds) {
      listOfRoleIds.push(id);
    }

    listOfRoles = await db("roles")
      .whereIn("id", listOfRoleIds)
      .where("deleted", false)
      .returning("*");
    if (listOfRoles.length === 0) {
      return new Error(errorName.NOT_FOUND_RESOURCE);
    }
  } catch (error) {
    return new Error(errorName.UNKNOWN_ERROR);
  }

  return listOfRoles;
};

module.exports = {
  getRolesOfUser,
};

 

Helper function for Permission

isAuthenticated is powered with packages of graphql-shield and graphql-passport. Its cache uses power of graphql-shield so it will cache requests preventing from unnecessary loads.

isAdmin is to check whether roles of an user contains admin or not. Those logic are implemented by my idea including leverage getRolesOfUser().

// helper/permission.js
// This is excerpted code
const { rule } = require("graphql-shield");

const { getRolesOfUser } = require("../util");
const { errorName } = require("../constants");

/**
 * is authenticated
 */
const isAuthenticated = rule({ cache: "contextual" })(
  async (parent, args, ctx, info) => {
    /**
     * Use context's isAuthenticated() and
     * this isAuthenticated() comes from
     * graphql-passport package.
     */
    return await ctx.isAuthenticated();
  }
);

/**
 * is admin
 * Determine an incoming request containing
 * an user, which has a role named admin
 */
const isAdmin = rule({ cache: "contextual" })(
  async (parent, args, ctx, info) => {
    /**
     * Using user's id inside incoming
     * request, which is passed through
     * graphql's context, to check it has
     * role: admin.
     * The program will iterate role
     * collection that user has for finding
     * "admin".
     * If it finds one of roles is admin,
     * return true. Otherwise, return
     * error of not found resource
     */

    let foundResult = null;

    try {
      const roles = await getRolesOfUser(ctx.req.user.id);

      if (roles == "") {
        return new Error(errorName.NOT_FOUND_RESOURCE);
      }

      for (const { name } of roles) {
        if (name.toLowerCase() === "admin") {
          foundResult = "admin";
          break;
        }
      }
    } catch (error) {
      return new Error(errorName.NOT_FOUND_RESOURCE);
    }

    return foundResult != null;
  }
);

 


Server setting of API guard: graphql-shield

Permissions with API entries

Now, I will provide guards to APIs using package: graphql-shield. Below code shows how to add a guard to an API entry. allow means always bypass checking, and means must meet all of conditions, or means just meet one of conditions and not means must achieve unfited conditions.

You need to set allowExternalErrors to true for sending back error message and code to browsers or requesting clients. After setting allowExternalErrors to true, graphql-shield will return customized error you have defined to requesting clients or browsers. You should also define customFormatErrorFn in server.js to use those customized errors.

In production environment, you should set debug to false. Otherwise, it’s recommended to set debug to true for debugging at local environment.

// helper/permission.js
// This is excerpted code
const { shield, and, or, not, allow } = require("graphql-shield");

/**
 * Use shield() to declare guards of API and
 * then assign to the variable: permissions.
 *
 * The variable: permissions will be applied
 * to middleware with schema of graphql
 * by package: graphql-middleware.
 */
const permissions = shield(
  {
    QueryRoot: {
      /*
       * user information within incoming
       * request must be authenticated
       */
      currentUser: isAuthenticated,
      /**
       * User information within incoming
       * request must be authenticated and
       * user's role must contain admin
       */
      users: and(isAuthenticated, isAdmin),
    },
    MutationRoot: {
      login: allow, // bypass check for all incoming request
      logout: isAuthenticated,
      /**
       * Only not authenticated incoming request
       * can use this API entry
       */
      signup: not(isAuthenticated),
      updateUser: isAuthenticated,
      createRole: and(isAuthenticated, isAdmin),
      createUserRoleRel: and(isAuthenticated, isAdmin),
      deleteUserRoleRel: and(isAuthenticated, isAdmin),
    },
  },
  {
    /**
     * If setting debug to false, it means
     * deactivates debugging mode of graphql-shield.
     * If setting debug to true, it means activate debugging mode of graphql-shield.
     * During debug mode is activated,
     * graphql-shield will capture error message throwed within every resolve()
     *
     */
    debug: false,
    /**
     * If setting allowExternalErrors to true,
     * graphql-shield will return custom errors
     * for API. This means it will reply
     * custom errors in requesting client.
     * Errors, whose we have defined in
     * constants.js, used at permissions.js
     * and related resolve(), will be response
     * to browsers so these behaviors
     * become same as usage of normal APIs.
     *
     * If you want to return customized
     * error message, please use "return"
     * statement with related error in
     * related resolve().
     */
    allowExternalErrors: true,
  }
);

module.exports = {
  permissions,
};

 

Apply permission to schema using middleware

First, I define schema for Query and Mutation.

Notice: You must use the exact name same as defined in name attribute of the relatd model. Here I use QueryRoot and MutationRoot.

Second, I apply permissions, which is defined and exported at permission.js, to schema using applyMiddleware() and then generated new schema. The new schema will be used with graphql and express at server.js.

// src/schema/index.js
// This is excerpted code

const { QueryRoot } = require("../model/Query");
const { MutationRoot } = require("../model/Mutation");
const graphql = require("graphql");
const { applyMiddleware } = require("graphql-middleware");
const { permissions } = require("../helpers/permission");

/**
 * Define schema for Query and Mutation with
 * related names. Here we use QueryRoot and
 * MutationRoot.
 */
let schema = new graphql.GraphQLSchema({
  /**
   * Notice: name must same as one
   * declared within GraphQLObjectType about
   * Query
   */
  query: QueryRoot,
  /**
   *
   * Notice: name must same as one
   * declared within GraphQLObjectType about
   * Mutation
   */
  mutation: MutationRoot,
});

schema = applyMiddleware(schema, permissions);

module.exports = {
  schema,
};

 

Final server setting

This sub-section is critical and the final part of setting server.

Here I import schema, which had been applied with permission to server.js, for guarding API entries. I have turned on support graphiql by setting it to true and this helps a frontend developer to understand what can be query and how to use.

Second, I use buildContext to pass context information to every resolve() and conditions, which are built by graphql-shield, of graphql.

Third, here I use customFormatErrorFn() for replying response with custom error messages and codes to browsers or requesting clients. customFormatErrorFn() leverages power of getErrorCode().

// src/server.js
// This is excerpted code

const { schema } = require("./schema/index");
const { buildContext } = require("graphql-passport");
const { getErrorCode } = require("./helpers/error");

/**
 * ...
 * ...(skip previous content)
 * ...
 */

/**
 * For express integrating graphql
 * start
 */
app.use(
  "/api",
  graphqlHTTP(function (req, res, params) {
    return {
      schema, // guarding API entries
      graphiql: true,
      context: buildContext({ req, res }),
      customFormatErrorFn: (err) => {
        /**
         * respond custom error message
         */
        const error = getErrorCode(err.message);
        return { message: error.message, statusCode: error.statusCode };
      },
    };
  })
);
app.listen(4000);

/**
 * For express integrating graphql
 * end
 */

 

Run the server at local

To run the server at local environment, you can use commend:

nodemon src/server.js
// or npm run dev

 

Figure. GraphiQL example

 


Conclusion

This tutorial shows how to setup a graphql api server basically using nodejs and express framework. I hope this article will save your time.


Reference link:

  1. Express-GraphQL@github
  2. Join-Monster document
  3. Bcrypt document
  4. GraphQL
  5. GraphQL-Scalars
  6. Express-session@github
  7. Graphql-passport@github
  8. Tutorial about authentication of GraphQL and passport
  9. Tutorial of custom error codes with graphql
  10. GraphQL-Shield docs