Custom directives with express-graphql [ Part-2 ]

Custom directives with express-graphql [ Part-2 ]

Implementing custom directives in graphql using express-graphql and graphql-directive

Β·

18 min read

Defining graphql schema

We defined simple structure for our photos app in part-1 of this blog.

πŸ“ Final code: Link

🌐 Live preview: Link

In this post we will implement the graphql schema and the corrosponding resolvers. So without wasting any time let's start. According to the folder structure we defined in part-1 , the directory structure in the graphql folder will look something like this:

|-src
|--graphql
|---resolvers
|----comments
|-----comments.ts
|-----index.ts
|----likes
|-----likes.ts
|-----index.ts
|----posts
|-----posts.ts
|-----index.ts
|----users
|-----users.ts
|-----index.ts
|----index.ts
|----utils.ts
|---schemas
|----index.ts

The graphql schema for the app will look something like this:

/* src/graphql/schema/index.ts */
import { buildSchema } from "graphql"

const schema = buildSchema(`

input PostInput {
    image: String
    title: String!
    description: String
    creatorId: ID!
}

type Post {
    _id: ID!
    image: String
    title: String!
    description: String
    creator: User!
    commentList: [Comment!]
    likeList: [Like!]
}

input CommentInput {
    text: String!
    postId: ID!
    creatorId: ID!
}

type Comment {
    _id: ID!
    text: String!
    post: Post!
    creator: User!
}

input LikeInput {
    postId: ID!
    creatorId: ID!
}

type Like {
    _id: ID!
    post: Post!
    creator: User!
}

enum Role {
    AUTH_USER
    ADMIN
    MODERATOR
}

input UserInput {
    username: String!
    email: String!
    password: String!
}

type User {
    _id: ID!
    username: String! 
    email: String!
    password: String
    role: Role!
    postList: [Post!]
    commentList: [Comment!]
    likeList: [Like!]
}

type AuthData {
    userId: ID!
    token: String!
    tokenExpiration: Int!
}

type RootQuery {
  listUsers: [User!] 
  getUserById(_id: ID!): User!
  listPosts: [Post!]
  getPostById(_id: ID!): Post!
  listComments: [Comment!]
  getCommentById(_id: ID!): Comment!
  listLikes: [Like!]
  getLikeById(_id: ID!): Like!
  login(usernameOrEmail: String!, password: String!): AuthData!
}

type RootMutation {
  createUser(user: UserInput): User
  deleteUser(_id: ID!): String 
  createPost(post: PostInput): Post 
  deletePost(_id: ID!): String 
  createComment(comment: CommentInput): Comment
  deleteComment(_id: ID!): String 
  createLike(like: LikeInput): Like
  deleteLike(_id: ID!): String
  assignRole(role: String! , assignedBy: ID!, assignedUser: ID!): User 
}

schema {
  query: RootQuery
  mutation: RootMutation
}
`)

export default schema

Obviously the queries and mutations that I have implemented are what I thought are enough for this blog post. You can always update the features or even add new ones. There is so much scope for scaling in projects like these. Also the schema is getting a bit too long. So later on in this post or in the third part we will split it into seperate files (schema splitting).

πŸ‘‰ I might make a few changes to this schema (specifically in the user schema) when I will integrate the server with frontend. But the overall there won't be any change in the overall logic. All of those changes will be reflected in the final code repository. In case of any problem , make sure to check with the final code.

πŸ“Œ The code till now can be found here: Link

Implementing resolvers

πŸ‘‰ Please note that while implementing resolvers, we will have quite a few functions and also some reusable ones. I have created utils files (as shown above in directory structure), that will contain all the reusable functions that will be shared among different resolvers. This is a very usefull and will make our code super reusable !!πŸ”₯

It is important to visualize what our resolvers will look like and how they will be structured.

First we will start by adding all resolvers related to users. These will be as follows:

  1. createUser
  2. login
  3. listUsers
  4. getUserById
  5. assignRole
  6. deleteUser

Resolvers for posts , comments and likes will be very much similar structure wise. Ofcourse , their functionality will differ. We will handle these in a bit. For now let's start with createUser & login resolvers which will provide registeration and login functionality!

Creating the utilities file πŸš€

import { UserType } from "./users"

export const transformUser = (user: UserType) => {
  if (!user)
    return {
      _id: "deleted_user",
      email: "deleted_user",
      username: "deleted_user",
      role: "deleted_user",
      password: null,
      postList: [], // return empty array as user has been deleted!
      commentList: [], // return empty array as user has been deleted!
      likeList: [], // return empty array as user has been deleted!
      /* functionality can be increased so as all user's comments, likes or posts(for admins)
       could be deleted before the user itself is deleted from database  */
    }
  return {
    _id: user._id,
    username: user.username,
    email: user.email,
    role: user.role,
    password: null,
    postList: [], // return empty array for now. we will add posts functionality in a bit
    commentList: [], // return empty array for now. we will add comments functionality in a bit
    likeList: [], // return empty array for now. we will add likes functionality in a bit
  }
}

What is the use of transform functions in utils file?

After our resolvers are done with their operation , the final data returned by them is passed to transform functions which inturn return the data in a formatted way which can be returned by the resolver and which is expected by the schema( or as I should say : specified in our schema). We have a couple of transform functions such as transformUser , transformPost , transformLike and transformComment.

Creating createUser & login resolver

import User, { Role } from "../../../models/User"
import { transformUser } from "../utils"
import bcrypt from "bcrypt"
import jwt from "jsonwebtoken"

interface UserArgsType {
  user: UserType
}

export interface UserType {
  _id?: string
  username: string
  email: string
  password: string
  role: Role
}

interface UserLoginArgs {
  usernameOrEmail: string
  password: string
}

export const createUser = async (args: UserArgsType) => {
  const userInDb = await User.findOne({
    email: args.user.email,
    username: args.user.username,
  })

  if (userInDb) {
    throw new Error(
      "user with same username or email already exists in database"
    )
  }

  // encrypt password before saving to database
  const hashedPassword = await bcrypt.hash(args.user.password, 12)

  const user = new User({
    username: args.user.username,
    email: args.user.email,
    password: hashedPassword,
  })

  try {
    const result = await user.save()
    return transformUser(result)
  } catch (error) {
    throw error
  }
}

export const login = async (args: UserLoginArgs) => {
  const login = checkIfValIsUsernameOrEmail(args.usernameOrEmail)

  if (login.type === "email") {
    const emailCheck = await User.findOne({
      email: login.email,
    })

    if (!emailCheck) {
      throw new Error(`No account found with email ${login.email}`)
    }

    console.log("emailCheck", emailCheck)

    const data = passwordCheck(args, emailCheck)
    return data
  } else {
    console.log(login.username)
    const usernameCheck = await User.findOne({
      username: login.username,
    })

    console.log(usernameCheck)

    if (!usernameCheck) {
      throw new Error(`No account found with username ${login.username}`)
    }

    const data = passwordCheck(args, usernameCheck)
    return data
  }
}

// compare password entered by user to the one in the database
const passwordCheck = async (args: UserLoginArgs, user: UserType) => {
  const isEqual = await bcrypt.compare(args.password, user.password)
  if (!isEqual) {
    throw new Error("Password is incorrect!")
  }
  const token = jwt.sign(
    { userId: user._id, email: user.email, role: user.role },
    process.env.JWT_SECRET_KEY as string,
    {
      expiresIn: "1h",
    }
  )
  return { userId: user._id, token: token, tokenExpiration: 1 }
}

// checkIfValIsUsernameOrEmail function checks weather input value entered by user during the
// time of login is either username or email. User can login with either one of the 2!
const checkIfValIsUsernameOrEmail = (usernameOrEmail: string) => {
  // Check if email
  if (/\@/.test(usernameOrEmail)) {
    //its email address
    // your code goes here
    return {
      type: "email",
      email: usernameOrEmail,
    }
  } else {
    //its username
    // your code goes here
    return {
      type: "username",
      username: usernameOrEmail,
    }
  }
}

Adding the root resolver

We create an index.ts file under our resolvers root directory as shown before and the following code in it:

import * as usersResolver from "./users"

const rootResolver: any = {
  ...usersResolver,
}

export default rootResolver

Updating the root index.ts file(entry point)

Now we add the graphql code to our index.ts file at the entry point that will basically join our resolvers to the schema that we defined. This way the arguments defined in our schema will enter into our resolvers! We will use express-graphql for this. The index.ts file till now looks like this:

import * as dotenv from "dotenv"
dotenv.config()
import express, { Express } from "express"
import mongoose from "mongoose"
import { graphqlHTTP } from "express-graphql"
const app: Express = express()
import schema from "./graphql/schemas"
import rootResolver from "./graphql/resolvers"

const PORT =
  process.env.NODE_ENV === "production"
    ? process.env.PORT
    : process.env.PORT_DEV

const MONGO_URI = process.env.MONGO_URI as string

app.use(
  "/graphql",
  graphqlHTTP({
    schema: schema,
    rootValue: rootResolver,
    graphiql: true,
  })
)

mongoose
  .connect(MONGO_URI, { useNewUrlParser: true, useUnifiedTopology: true })
  .then(() => {
    console.log("connected to mongodb...")
    app.listen(PORT, () => {
      console.log(`server started on port ${PORT}`)
    })
  })
  .catch(err => {
    console.log(err)
  })

πŸ“Œ The code till now can be found here: Link

Start the project and go to http://localhost:4000/graphql. This is the playground where we will test our queries and mutations. Later I will add a proper frontend using react-js. Let's test also with the queries and mutations that I have already written:

After running the createUser mutation above we have a user in our mongodb database as below:

1.png

listUsers resolver is responsible for listing all registered users in our database.

/* listUsers resolver */
export const listUsers = async () => {
  try {
    const users = await User.find()
    return users.map((user: UserType) => {
      return transformUser(user)
    })
  } catch (error) {
    throw error
  }
}

getUserById resolver is responsible for listing any specific user from database with the help of an user-id.

/* getUserById resolver*/
export const getUserById = async (args: { _id: string }) => {
  try {
    const user = await User.findById(args._id)
    return transformUser(user)
  } catch (error) {
    throw error
  }
}

deleteUser resolver is responsible for deleting a user.

/* deleteUser resolver */
export const deleteUser = async (_id: string) => {
  try {
    await User.findByIdAndDelete(_id)
    return `user account removed successfully`
  } catch (error) {
    throw error
  }
}

assignRole resolver is responsible for assigning a specific role to any registered user. By default AUTH_USER is the default role. Also note that only Admin can update a user's role as per our requirements.

/* assignRole resolver */
export const assignRole = async (args: {
  role: Role
  assignedBy: string
  assignedUser: string
}) => {
  try {
    const assigny = await User.findById(args.assignedBy)
    if (!assigny) {
      throw new Error("The user who is assigning role is not present in db")
    }

    const userToBeAssigned = await User.findById(args.assignedUser)
    if (userToBeAssigned._doc.role === "ADMIN")
      throw new Error(`User is already assigned ${args.role} role`)

    const updatedUser = await User.updateOne(
      { _id: args.assignedUser },
      { $set: { role: args.role } }
    )

    const result = await User.findById(args.assignedUser)
    return transformUser(result)
  } catch (error) {
    throw error
  }
}

First let's test listUsers and getUserById:

πŸ‘‰ Note that for now there is no authorisation. Anyone can authorize any role to anyone. Also in case of posts and comments, anyone can create posts or add comments. We will take care of authorization in part-3

Now Let's assign a new role to a user. I create a new user with the name of jane doe. We will assign a role of MODERATOR to jane doe. For now let's assume John doe was already an ADMIN. Later we will add authorization logic to check weather a person assigning a role to someone himself is ADMIN or not!

Now finally let's delete a user. Let's delete Jane Doe:

Seems like, all of our user resolvers works till now!πŸ˜ƒ

πŸ“Œ The code till now can be found here: Link

Let's first update the utils file where we add a few utility functions that will be used by all resolvers related to posts.

/* utils */

import User from "../../models/User"
import Post from "../../models/Post"
import { UserType } from "./users"
import { PostType } from "./posts"

export const transformUser = (user: UserType) => {
  if (!user)
    return {
      _id: "deleted_user",
      email: "deleted_user",
      username: "deleted_user",
      role: "deleted_user",
      password: null,
      postList: [], // return empty array as user has been deleted!
      commentList: [], // return empty array as user has been deleted!
      likeList: [], // return empty array as user has been deleted!
      /* functionality can be increased so as all user's comments, likes or posts(for admins)
       could be deleted before the user itself is deleted from database  */
    }
  return {
    _id: user._id,
    username: user.username,
    email: user.email,
    role: user.role,
    password: null,
    postList: postsByCreatorId.bind(this, user._id!),
    commentList: [], // return empty array for now. we will add comments functionality in a bit
    likeList: [], // return empty array for now. we will add likes functionality in a bit
  }
}

export const transformPost = (post: PostType) => {
  return {
    _id: post._id,
    image: post.image,
    title: post.title,
    description: post.description,
    creator: singleUser.bind(this, post.creatorId),
    commentList: [], // return empty array for now. we will add comments functionality in a bit
    likeList: [], // return empty array for now. we will add likes functionality in a bit
  }
}

/* singleUser utility func to retreive single user by Id*/
export const singleUser = async (userId: string) => {
  try {
    const user = await User.findById(userId)
    return transformUser(user)
  } catch (error) {
    throw error
  }
}

/* postsByCreatorId utility func to list all posts created by a single creator(user) */
export const postsByCreatorId = async (creatorId: string) => {
  try {
    const posts = await Post.find({ creatorId: creatorId })
    return posts.map((post: PostType) => {
      return transformPost(post)
    })
  } catch (error) {
    throw error
  }
}

Now we add all post resolvers:

createPost resolver is responsible for creating a new post.

/* createPost resolver */
export const createPost = async (args: PostArgsType) => {
  const post = new Post({
    image: args.post.image,
    title: args.post.title,
    description: args.post.description,
    creatorId: args.post.creatorId,
  })

  try {
    const result = await post.save()
    return transformPost(result)
  } catch (error) {
    throw error
  }
}

deletePost resolver is responsible for deleting a post.

/* deletePost resolver */
export const deletePost = async (_id: string) => {
  try {
    await Post.findByIdAndDelete(_id)
    return `post removed successfully`
  } catch (error) {
    throw error
  }
}

getPostById is responsible for retreiving a single post.

/* getPostById resolver */
export const getPostById = async (args: { _id: string }) => {
  try {
    const post = await Post.findById(args._id)
    return transformPost(post)
  } catch (error) {
    throw error
  }
}

listPosts resolver is responsible for listing all posts

/* listPosts resolver*/
export const listPosts = async () => {
  try {
    const posts = await Post.find()
    return posts.map((post: PostType) => {
      return transformPost(post)
    })
  } catch (error) {
    throw error
  }
}

Updating the root resolver

import * as usersResolver from "./users"
import * as postsResolver from "./posts"

const rootResolver: any = {
  ...usersResolver,
  ...postsResolver,
}

export default rootResolver

Now we test the post resolvers. Let's say John Doe was creating, listing and deleting a few posts. Again assuming he is an ADMIN for now, he can do such operations.

πŸ‘‰ Do notice that now we can also fetch the details of the user who created a post since we have already created all resolvers related to users. Similary when we create resolvers of comments and likes, we will also be able to fetch all comments and likes associated with each of the posts. Similar relationship will also be there for comments and likes. For eg. we will be able to fetch each like and the corrosponding user who created the like as well as the post on which like was added. You can imagine such nesting will keep on happening. Over here the power of graphql begins to shine!

We have successfully added posts resolver. Now since you have the idea how resolvers are being added, I would now add the comments and likes resolvers quickly. The structure and logic is pretty much similar to that of posts. You can try that on your own. Again everything is defined already in graphql schema. You can refer to it whenever you are stuck and design resolvers according to that.

πŸ“Œ The code till now can be found here: Link

Adding comments resolvers

First updating the utils file, we will need a couple of new utiltiy functions:

export const transformUser = (user: UserType) => {
  if (!user)
    return {
      _id: "deleted_user",
      email: "deleted_user",
      username: "deleted_user",
      role: "deleted_user",
      password: null,
      postList: [], // return empty array as user has been deleted!
      commentList: [], // return empty array as user has been deleted!
      likeList: [], // return empty array as user has been deleted!
      /* functionality can be increased so as all user's comments, likes or posts(for admins)
       could be deleted before the user itself is deleted from database  */
    }
  return {
    _id: user._id,
    username: user.username,
    email: user.email,
    role: user.role,
    password: null,
    postList: postsByCreatorId.bind(this, user._id!),
    commentList: commentsByCreatorId.bind(this, user._id!),
    likeList: [], // return empty array for now. we will add likes functionality in a bit
  }
}
export const transformPost = (post: PostType) => {
  return {
    _id: post._id,
    image: post.image,
    title: post.title,
    description: post.description,
    creator: singleUser.bind(this, post.creatorId),
    commentList: commentsByPostId.bind(this, post._id!),
    likeList: [], // return empty array for now. we will add likes functionality in a bit
  }
}
export const transformComment = (comment: CommentType) => {
  return {
    _id: comment._id,
    text: comment.text,
    post: singlePost.bind(this, comment.postId),
    creator: singleUser.bind(this, comment.creatorId),
  }
}
/* singlePost utility func to get post using post-id*/
export const singlePost = async (postId: string) => {
  try {
    const post = await Post.findById(postId)
    return transformPost(post)
  } catch (error) {
    throw error
  }
}
/* commentsByPostId utility func to get all comments on a single post */
export const commentsByPostId = async (postId: string) => {
  try {
    const comments = await Comment.find({ postId: postId })
    return comments.map((comment: CommentType) => {
      return transformComment(comment)
    })
  } catch (error) {
    throw error
  }
}
/* commentsByCreatorId utility func to get all comments of a single user */
export const commentsByCreatorId = async (creatorId: string) => {
  try {
    const comments = await Comment.find({ creatorId: creatorId })
    return comments.map((comment: CommentType) => {
      return transformComment(comment)
    })
  } catch (error) {
    throw error
  }
}

Now all the resolvers related to comments will be like this:

import Comment from "../../../models/Comment"
import { transformComment } from "../utils"

export interface CommentType {
  _id: string
  text: string
  postId: string
  creatorId: string
}

interface CommentArgsType {
  comment: CommentType
}

/* listComments resolver to list all comments */
export const listComments = async () => {
  try {
    const comments = await Comment.find()
    return comments.map((comment: CommentType) => {
      return transformComment(comment)
    })
  } catch (error) {
    throw error
  }
}

/* getCommentById resolver to get single comment by it's id */
export const getCommentById = async (args: { _id: string }) => {
  try {
    const comment = await Comment.findById(args._id)
    return transformComment(comment)
  } catch (error) {
    throw error
  }
}

/* createComment  resolver to create a comment */
export const createComment = async (args: CommentArgsType) => {
  const comment = new Comment({
    text: args.comment.text,
    postId: args.comment.postId,
    creatorId: args.comment.creatorId,
  })

  try {
    const result = await comment.save()
    return transformComment(result)
  } catch (error) {
    throw error
  }
}

/* deleteComment  resolver to delete a single comment using it's id */
export const deleteComment = async (_id: string) => {
  try {
    await Comment.findByIdAndDelete(_id)
    return `comment removed successfully`
  } catch (error) {
    throw error
  }
}

Updating the root resolver file again:

import * as usersResolver from "./users"
import * as postsResolver from "./posts"
import * as commentsResolver from "./comments"

const rootResolver: any = {
  ...usersResolver,
  ...postsResolver,
  ...commentsResolver,
}

export default rootResolver

Now let's test the comments resolver. For this I have already created 2 new users and 2 new posts. Then I will add 1 comment to each of the posts.

πŸ“ŒThe code till now can be found here: Link

Adding likes resolvers

First updating the utils file, we will need 2 new functions:

export const transformPost = (post: PostType) => {
  return {
    _id: post._id,
    image: post.image,
    title: post.title,
    description: post.description,
    creator: singleUser.bind(this, post.creatorId),
    commentList: commentsByPostId.bind(this, post._id!),
    likeList: likesByPostId.bind(this, post._id!),
  }
}
export const transformComment = (comment: CommentType) => {
  return {
    _id: comment._id,
    text: comment.text,
    post: singlePost.bind(this, comment.postId),
    creator: singleUser.bind(this, comment.creatorId),
  }
}
export const transformLike = (like: LikeType) => {
  return {
    _id: like._id,
    post: singlePost.bind(this, like.postId),
    creator: singleUser.bind(this, like.creatorId),
  }
}
/* likesByPostId utility func to list all likes related to any single post using post-id */
export const likesByPostId = async (postId: string) => {
  try {
    const likes = await Like.find({ postId: postId })
    return likes.map((like: any) => {
      return transformLike(like)
    })
  } catch (error) {
    throw error
  }
}
/* likesByCreatorId utility func to list all likes related to any single user using user-id */
export const likesByCreatorId = async (creatorId: string) => {
  try {
    const likes = await Like.find({ creatorId: creatorId })
    return likes.map((like: LikeType) => {
      return transformLike(like)
    })
  } catch (error) {
    throw error
  }
}

Now all the resolvers related to likes will be like this:

import Like from "../../../models/Like"
import { transformLike } from "../utils"

export interface LikeType {
  _id?: string
  postId: string
  creatorId: string
}

interface LikeArgsType {
  like: LikeType
}

/* listLikes resolver to list all likes */
export const listLikes = async () => {
  try {
    const likes = await Like.find()
    return likes.map((like: LikeType) => {
      return transformLike(like)
    })
  } catch (error) {
    throw error
  }
}

/* getLikeById resolver to retreive a single like using a like-id */
export const getLikeById = async (args: { _id: string }) => {
  try {
    const like = await Like.findById(args._id)
    return transformLike(like)
  } catch (error) {
    throw error
  }
}

/* createLike resolver for adding a like to the post */
export const createLike = async (args: LikeArgsType) => {
  const like = new Like({
    postId: args.like.postId,
    creatorId: args.like.creatorId,
  })

  try {
    const result = await like.save()
    return transformLike(result)
  } catch (error) {
    throw error
  }
}

/* deleteLike resolver for removing a like*/
export const deleteLike = async (_id: string) => {
  try {
    await Like.findByIdAndDelete(_id)
    return `like removed successfully`
  } catch (error) {
    throw error
  }
}

Updating the root resolver file again:

import * as usersResolver from "./users"
import * as postsResolver from "./posts"
import * as commentsResolver from "./comments"
import * as likesResolver from "./likes"

const rootResolver: any = {
  ...usersResolver,
  ...postsResolver,
  ...commentsResolver,
  ...likesResolver,
}

export default rootResolver

I won't be testing all likes resolver because the implementation is very much like comments. You can try this part on your own.

πŸ“Œ The code till now can be found here: Link

If you have made it this far then congratulations.. we have completed the base of our complete! Now in next part we will implement custom directives and add the frontend!

πŸ‘‰ Note: In the final code repository there is a client-redux folder which also consists of frontend with client graphql implementation using redux. Although , it is important to note that, functionalities such live as refetching of queries , implementaion of custom fetch and error policies is not implemented in this project.

πŸ“ Final code: Link

🌐 Live preview: Link

You can view Part 3 of the series here: part 3 ->

Β