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.
Join-Monster
and knex
Query
and Mutation
Passport
, connect-session-knex
and express-session
graphql-shield
List of package version:
5.0.1
2.1.0
4.17.1
1.17.2
15.5.1
6.1.13
0.6.3
0.8.0
1.10.0
7.5.0
3.1.1
0.95.6
0.4.1
8.6.0
8.3.2
2.23.0
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.
You can skip this part if you know what purpose of these libraries and frameworks.
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 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 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.
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.
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.
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.
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.
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.
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 is a library of generating unique identifiers and values it generates are not human-readable. We use it for generating session id.
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 is a library for hash credential such user passwords or other important things. Here we use it to hash users' passwords.
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,
};
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,
};
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 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
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.
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,
};
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,
};
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,
};
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.
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,
};
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,
};
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.
In file server.js
, there are few steps need to do by order:
express-session
packageconnect-session-knex
package and apply session functions, which comes from express-session
to itdb
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
*/
});
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:
passport
package and passport
integrate sessiongraphql-passport
package and integrate graphql with passport
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
*/
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,
};
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;
}
);
graphql-shield
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,
};
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 useQueryRoot
andMutationRoot
.
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,
};
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
*/
To run the server at local environment, you can use commend:
nodemon src/server.js
// or npm run dev
Figure. GraphiQL example
This tutorial shows how to setup a graphql
api server basically using nodejs
and express
framework. I hope this article will save your time.