Custom directives with express-graphql [ Part-3 ]

Custom directives with express-graphql [ Part-3 ]

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

Β·

13 min read

Till now we have the application structure fully setup. If you are directly coming over here to part 3 , I am assuming you have the base structure of your app completed, which includes setting up the express-graphql server , graphql-schema and corrosponding resolvers. If you haven't I highly recommend checking out part-1 and part-2. Otherwise, let's start!

πŸ“ Final code: Link

There are only 2 main things left:

  1. Authentication
  2. Authorisation

We will be using JWT authentication method for loging in the user.

Choosing a wise logic for authentication & authorisation

If you have worked with REST API, imo it is a bit easier to implement both of the functionalities. We can just create a middleware and apply the same middleware to each of the routes(get requests) that we either want to authenticate or on top of that authorize. But how will we implement that in graphql ? We have only a single route your-host-url/graphql. We are left with 2 choices.

  1. Either attach a special property to each incoming request and then check in each of the resolver functions(users, posts, comments, likes etc) weather the resolver should fulfill the request or not!
  2. Attatch custom resolver directly to each of our query , mutation or type definations in our graphql schema. Each custom resolver will have a seperate implementation logic where it will be checked weather the request should even reach the resolver or not?

Now both the methods can be used. But personally I would prefer to go with second one. I think this is a good approach because this approach has quite a lot of benefits. The most visible ones are:

  1. Code becomes much more modular. Write less code with same functionality!
  2. Attatching a special property to each incoming request and then check where request should be fullfilled or not inside the resolver is cumbersome. As you can imagine when projects become larger, the resolvers will also increase. Managing logic for authentication/authorisation becomes really tedious and it may happen that you may forget adding this logic in some resolvers. The first approach is good enough for smaller projects.
  3. Provides a REST like architecture. It kind of takes a middleware approach very similar to REST as you will see in a bit.

Installing graphql-directive package

If you haven't already, download the graphql-directive package.

yarn add graphql-directive
// or
npm i graphql-directive

πŸ‘‰ Please note the version number for 'graphql' package and to specifically use 14.6.0 as I was having a couple of problems with newer version of graphql package.

Adding types for graphql-directive package

Since no type are available for graphql-directive package that we installed, we create a graphql-directive.d.ts file in the root with content as follows:

declare module "graphql-directive"

So, how does graphql-directive package works?

To better understand take a carefull look at the following diagram:

custom-directive-workflow.png

πŸ‘‰ I wanted to give you a heads up that we define the graphql context in the root index.ts file of our server where we initialize our graphql-server. We will also update our code to define the context in a little bit. To give you a preview how that will look, it would be like this:

app.use("/graphql", (req, res) => {
  return graphqlHTTP({
    schema: schema,
    rootValue: rootResolver,
    graphiql: true,
    context: { req, res }, // we pass context here which will be received by aur directive resolvers
  })(req, res)
})

Adding graphql-directive resolvers

Folder structure for adding directives will be as follows:

-src
--graphql
---directives
----hasAuthorisation
----isAuthenticated
----upperCase
----index.ts
.
.

Now we will implement resolvers for our custom directives

/* hasAuthorisation */
import jwt from "jsonwebtoken"

export const hasAuthorisation = async (
  resolve: any,
  directiveArgs: any,
  obj: any,
  context: any,
  info: any
) => {
  const authHeader = context.req.get("authorization")
  console.log(authHeader)
  if (authHeader === undefined || context.authHeader === "")
    throw new Error(`you are unauthenticated`)

  const token = authHeader.split(" ")[1]
  if (!token || token === "") throw new Error("Token not found or has expired")

  let decodedToken: any
  try {
    decodedToken = jwt.verify(token, process.env.JWT_SECRET_KEY as string)
  } catch (error: any) {
    if (!token || token === "") throw new Error(error)
  }

  if (!decodedToken) throw new Error("Token verification failed")

  // check authorisation roles here
  const roleToCheckFrom = obj && obj.roles
  if (roleToCheckFrom.indexOf(decodedToken.role) === -1)
    throw new Error("Not Authorised")
  return resolve()
}
/* isAuthenticated */
import jwt from "jsonwebtoken"

export const isAuthenticated = async (
  resolve: any,
  directiveArgs: any,
  obj: any,
  context: any,
  info: any
) => {
  // console.log(context.req);
  const authHeader = context.req.get("Authorization")
  console.log(authHeader)
  if (authHeader === undefined || context.authHeader === "")
    throw new Error(`you are unauthenticated`)

  const token = authHeader.split(" ")[1]
  if (!token || token === "") throw new Error("Token not found or has expired")

  let decodedToken: any
  try {
    decodedToken = jwt.verify(token, process.env.JWT_SECRET_KEY as string)
  } catch (error: any) {
    if (!token || token === "") throw new Error(error)
  }

  if (!decodedToken) throw new Error("Token verification failed")

  return resolve()
}
/* upperCase */
export const upperCase = async (
  resolve: any,
  directiveArgs: any,
  obj: any,
  context: any,
  info: any
) => {
  const value = await resolve()
  return String(value).toUpperCase()
}

In the above custom directive resolvers, we are creating an asynchronous function that takes 5 arguments:

  1. resolve
  2. directiveArgs
  3. obj
  4. context
  5. info

So what do they all mean? I will copy a part of documentation written in the graphql-directive npm package below that exactly describes what all of these mean. But what I would suggest is logging out each of these in your console to see what output each of them give.

  1. resolve: Resolve is a function that returns the result of the directive field. For consistency, it always returns a promise resolved with the original field resolver.

  2. obj: The object that contains the result returned from the resolver on the parent field, or, in the case of a top-level Query field, the rootValue passed from the server configuration. This argument enables the nested nature of GraphQL queries.

  3. directiveArgs: An object with the arguments passed into the directive in the query or schema. For example, if the directive was called with @dateFormat(format: "DD/MM/YYYY"), the args object would be: { "format": "DD/MM/YYYY" }.

  4. context: This is an object shared by all resolvers in a particular query, and is used to contain per-request state, including authentication information, dataloader instances, and anything else that should be taken into account when resolving the query.

  5. info: This argument should only be used in advanced cases, but it contains information about the execution state of the query, including the field name, path to the field from the root, and more. It’s only documented in the GraphQL.js source code .

If you are not able to understand all of these arguments , then it's all fine because we will be using only 3 of these which are resolve , obj and context

In upperCase resolver we execute resolve() function and we receive the input value which needs to be converted into upperCase word.

context argument is an object that has all the properties that we passed earlier from our root index.ts file.

obj argument is an object that consists of all properties specified with the directive in our graphql schema. For eg. below in the schema where we declare our directives, with @hasAuthorisation directive , we use roles property as '@hasAuthorisation(roles: [ADMIN])' where roles is an array and can consist of multiple roles to check from like ADMIN , MODERATOR etc. we can then access roles through obj argument like obj.roles!

πŸ‘‰ Note that @hasAuthorisation directive also covers the logic for @isAuthenticated. So while using the @hasAuthorisation directive, even if the user is the correctly authorised one, if user is not authenticated or as I should say the request header is empty with no Authorization token , graphql will give error message of not authenticated!

/* ../graphql/directives/index.ts*/
export * from "./hasAuthorisation"
export * from "./isAuthenticated"
export * from "./upperCase"

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

Updating graphql-schema file to handle custom directives

import { buildSchema } from "graphql"
import { addDirectiveResolveFunctionsToSchema } from "graphql-directive"
import { upperCase, isAuthenticated, hasAuthorisation } from "../directives"

const schema = buildSchema(`

directive *@upperCase* on FIELD_DEFINITION | FIELD
directive *@isAuthenticated* on FIELD_DEFINITION | FIELD
directive *@hasAuthorisation(roles: [Role!])* on FIELD_DEFINITION | FIELD

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! @upperCase
    email: String!
    password: String
    role: Role!
    postList: [Post!]
    commentList: [Comment!]
    likeList: [Like!]
}

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

type RootQuery {
  listUsers: [User!] @hasAuthorisation(roles: [ADMIN])
  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 @hasAuthorisation(roles: [ADMIN])
  createPost(post: PostInput): Post @hasAuthorisation(roles: [ADMIN])
  deletePost(_id: ID!): String @hasAuthorisation(roles: [ADMIN,MODERATOR])
  createComment(comment: CommentInput): Comment
  deleteComment(_id: ID!): String @hasAuthorisation(roles: [ADMIN,MODERATOR])
  createLike(like: LikeInput): Like
  deleteLike(_id: ID!): String
  assignRole(role: String! , assignedBy: ID!, assignedUser: ID!): User @hasAuthorisation(roles: [ADMIN])
}

schema {
  query: RootQuery
  mutation: RootMutation
}
`)

addDirectiveResolveFunctionsToSchema(schema, {
  upperCase,
  isAuthenticated,
  hasAuthorisation,
})

export default schema

Updating entry src/index.ts file to pass graphql context to each of the directive-resolvers

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

app.use(cors())

app.use((req: Request, res: Response, next: NextFunction) => {
  res.setHeader("Access-Control-Allow-Origin", "*")
  res.setHeader("Access-Control-Allow-Methods", "POST,GET,OPTIONS")
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization")
  if (req.method === "OPTIONS") {
    return res.sendStatus(200)
  }
  next()
})

app.use("/graphql", (req, res) => {
  return graphqlHTTP({
    schema: schema,
    rootValue: rootResolver,
    graphiql: true,
    context: { req, res }, // we pass context here which will be received by aur directive resolvers
  })(req, res)
})

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

const MONGO_URI = process.env.MONGO_URI as string

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

Now let's test our custom directives. If you take a look at the updated schema, we have our directives placed at a few different places.

As told above in the note, @hasAuthorisation directive also covers the logic for @isAuthenticated directive. We will test the @hasAuthorisation for both authentication and authorization.

πŸ‘‰ We cannot use graphiql tool for testing these directives since we need to pass headers along with our requests which we want to be either authorized or authenticated. So we will use POSTMAN tool for this purpose.The syntax for setting up queries in POSTMAN is a bit weird, So watch the video below carefully!

Testing the @isAuthenticated & @hasAuthorisation directive

First we will send no headers. We will try to list all users. In this case we should get you are unauthenticated error message as specified in our @hasAuthorisation and @isAuthenticated custom-directive resolvers.

We get you are unauthenticated which means @isAuthenticated directive is correctly setup. It also means that @hasAuthorisation directive is also correctly setup as long as authentication is concerned!

Testing @hasAuthorisation directive

Now we will take care of authorization part in @hasAuthorisation. First we will log in. We get the JSON Web Token. Then we use the token and send it along the header and then our custom directive resolver handles authorization. We currently have no user who is an ADMIN or MODERATOR. So as per the schema and custom directives defined in it, if we try to get a list of all users, we should get Not Authorised.

Testing the @upperCase directive

Now to test @upperCase directive, we need to get the list of all users since each username has to be shown in uppercase as per our graphql schema. User's list can only be retrieved by an ADMIN. So I will manually update the user role of any one of the user's in our mongodb database to ADMIN. Lets update role of john doe to ADMIN:

Now since role of user john doe is updated to ADMIN, john should be able to retrieve the list of users successfully. We should also get usernames in uppercase which will confirm that our @upperCase directive is working fine:

So finally we get the response that we were looking for and hence this confirms that our custom directives are working as they should....πŸŽ‰πŸŽ‰πŸŽ‰πŸŽ‰πŸŽ‰

Splitting our graphql schema(optional)

Now our schema is getting a bit long. As the project grows, it is better to divide the schema based on different sub-queries,sub-mutations or sub-subscriptions. If you want to see how this is done a greater detail, watch this awesome video by ben awad: Link

Updated directory structure for schemas folder

-src
--graphql
---schemas
----users.ts
----posts.ts
----comments.ts
----likes.ts
----index.ts
.
.
/* ../graphql/schemas/users.ts */
export const types = `
type User {
    _id: ID!
    username: String! @upperCase
    email: String!
    password: String
    role: Role!
    postList: [Post!]
    commentList: [Comment!]
    likeList: [Like!]
} 

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

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

export const queries = `
listUsers: [User!] @hasAuthorisation(roles: [ADMIN])  
getUserById(_id: ID!): User! 
login(usernameOrEmail: String!, password: String!): AuthData!
`

export const mutations = `
createUser(user: UserInput): User
  deleteUser(_id: ID!): String @hasAuthorisation(roles: [ADMIN])
  assignRole(role: String! , assignedBy: ID!, assignedUser: ID!): User @hasAuthorisation(roles: [ADMIN])

`

export const subscriptions = ``
/* ../graphql/schemas/posts.ts */
export const types = `
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!]
}
`

export const queries = `
listPosts: [Post!]
getPostById(_id: ID!): Post!
`

export const mutations = `
createPost(post: PostInput): Post @hasAuthorisation(roles: [ADMIN])
  deletePost(_id: ID!): String @hasAuthorisation(roles: [ADMIN,MODERATOR])
`

export const subscriptions = ``
/* ../graphql/schemas/comments.ts */
export const types = `
input CommentInput {
    text: String!
    postId: ID!
    creatorId: ID!
}

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

export const queries = `
listComments: [Comment!]
getCommentById(_id: ID!): Comment! 
`

export const mutations = `
createComment(comment: CommentInput): Comment
  deleteComment(_id: ID!): String @hasAuthorisation(roles: [ADMIN,MODERATOR])
`

export const subscriptions = ``
/* ../graphql/schemas/likes.ts */
export const types = `
input LikeInput { 
    postId: ID!
    creatorId: ID!
}

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

export const queries = `
  listLikes: [Like!]
  getLikeById(_id: ID!): Like!
`

export const mutations = `
createLike(like: LikeInput): Like
  deleteLike(_id: ID!): String
`

export const subscriptions = ``
/* ../graphql/schemas/index.ts */
//
import { buildSchema } from "graphql"
import { addDirectiveResolveFunctionsToSchema } from "graphql-directive"
import { upperCase, isAuthenticated, hasAuthorisation } from "../directives"

// import comparted schemas
import * as postsGQLSchema from "./posts"
import * as commentsGQLSchema from "./comments"
import * as likesGQLSchema from "./likes"
import * as usersGQLSchema from "./users"

const types: string[] = []
const queries: string[] = []
const mutations: string[] = []
const subscriptions = []
const schemas = [
  postsGQLSchema,
  commentsGQLSchema,
  likesGQLSchema,
  usersGQLSchema,
]

schemas.forEach(schema => {
  types.push(schema.types)
  queries.push(schema.queries)
  mutations.push(schema.mutations)
  subscriptions.push(schema.subscriptions)
})

const schema = buildSchema(`

directive @upperCase on FIELD_DEFINITION | FIELD
directive @isAuthenticated on FIELD_DEFINITION | FIELD
directive @hasAuthorisation(roles: [Role!]) on FIELD_DEFINITION | FIELD

enum Role {
    AUTH_USER
    ADMIN
    MODERATOR
}

${types.join("\n")}


type RootQuery {
    ${queries.join("\n")}
}

type RootMutation {
${mutations.join("\n")}
}

schema {
  query: RootQuery
  mutation: RootMutation
}
`)

addDirectiveResolveFunctionsToSchema(schema, {
  upperCase,
  isAuthenticated,
  hasAuthorisation,
})

export default schema

I will leave the original schema in a reference.ts file so as to later reference in case needed.

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

React App frontend setup

I am using React as my frontend frameworks but you can easily use any other frontend framework such as Angular or svelte. Few other technologies that I am using on my frontend are:

πŸ‘‰ 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

Final points

Hope you learnt something useful from this project. In case any queries or doubts, you can comment down below and I will try to resolve them as much as I can. You can also join my discord channel if you want to reach out to me.

Β