Next x Nest – GraphQL for REST API developers – Part 2
Abhik Banerjee
Posted on December 19, 2023
It's been quite a while since I last published. Sorry for being absent. I decided to dedicate some time to personal development. But now I feel like I need to complete this series I had started. So I’m back with yet another article in the Next x Nest series. This one would be a continuation of the last article. In part 1 of the “GraphQL for REST API developers” there were a couple of topics and resolvers left out in favour of length. Let’s complete those. Compared to the last article, this one would be shorter and after this, the frontend side of things would begin.
Nuances of using a Code-first Approach
But before that, I feel it's important to discuss a few things that are relevant to taking a “code-first” approach to GraphQL API development. These become more significant in the context of NestJS. These are:
- Code-first approach is easier to relate for a REST API developer but less intuitive than the original GraphQL model.
- The use of the Code first approach allows us to easily place code sanitization (Nestjs transform and validation decorators) in the data transfer object layer. This is the layer that receives the input from the incoming traffic and processes it in objects which are passed to our handlers/controllers.
- Circular dependency can catch you from behind if you are not careful designing in NestJS using this approach.
- Contrary to popular belief or confusion of newbies, authentication and authorization can be easily implemented in GraphQL. It is easier still when using NestJS for GraphQL.
I am not advocating for NestJS here. I am always in favour of opinionated frameworks as they offer a way to reduce the “chaos in codebase”. I would prefer a framework that forces things to be done in a certain way rather than relying on “community-adopted” best practices. Because at the end of the day, while the latter seems natural, it is often laborious to ingrain in new learners. I have been part of projects where the “previous team” did things in a specific way which was not best practice and that had to be carried forward because it would have upset the timeline to do a refactor. This is why I would advocate for the use of frameworks rather than just libraries.
Remaining Resolvers
Returning to our little project, the modules that remain are the board and board user modules. So without further ado…
Board
Before we dive into the module, resolver, and service code, we need a moment to discuss the Entity
. A board will be represented by a class which we will call Board
(no surprises there). This class will be annotated by the @ObjectType()
decorator which will tell GraphQL to use this class to generate the properties of a Board in the GraphQL schema.
import { ObjectType, Field, ID, GraphQLISODateTime } from '@nestjs/graphql';
import { BoardUser } from 'src/board-user/entities/board-user.entity';
import { Column } from 'src/column/entities/column.entity';
import { User } from 'src/user/entities/user.entity';
@ObjectType()
export class Board {
@Field(() => ID)
id: string;
@Field(() => GraphQLISODateTime)
createdAt: string;
@Field(() => GraphQLISODateTime)
updatedAt: string;
@Field(() => String)
boardName: string;
@Field(() => String)
boardDescription: string;
@Field(() => User)
createdByUser: User;
@Field(() => [Column], { nullable: 'itemsAndList' })
columns: Column[];
@Field(() => [BoardUser], { nullable: 'itemsAndList' })
boardUsers: BoardUser[];
}
Now, as can be seen from the above, albeit fields createdByUser
, columns
and boardUsers
most of the properties are GraphQL scalars. These custom types will need to have resolvers. We will be needing resolvers as you will see in the later section. This is also where the need for importing other modules comes into play.
Module
Now the BoardModule
will be using the BoardUserModule
and ColumnModule
. These would also be using BoardModule
to provide a way for the frontend to query data about Board from the Columns and Board User resolvers. In the code below, we set up the BoardModule
with forwardRef()
to the aforementioned modules.
import { Module, forwardRef } from '@nestjs/common';
import { BoardService } from './board.service';
import { BoardResolver } from './board.resolver';
import { BoardUserModule } from 'src/board-user/board-user.module';
import { ColumnModule } from 'src/column/column.module';
import { UserModule } from 'src/user/user.module';
@Module({
imports: [
forwardRef(() => BoardUserModule),
forwardRef(() => ColumnModule),
forwardRef(() => UserModule),
],
providers: [BoardResolver, BoardService],
exports: [BoardService],
})
export class BoardModule {}
Resolver
Now, for the resolver, we will need the DTOs in place. These DTOs are for mutations. Specifically, a DTO for board creation and another for board updating. In the DTO below, we decorate the DTO class with the @InputType()
tag. This tells NestJS to use it as a GraphQL input type in the schema.
Keep in mind that
@InputType()
and@ObjectType()
cannot be placed on the same class.
import { InputType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@InputType()
export class CreateBoardInput {
@IsNotEmpty()
@IsString()
@Field(() => String)
boardName:string
@IsNotEmpty()
@IsString()
@Field(() => String)
boardDescription:string
}
In the above DTO, we place the validations that the mutation must have a board name and description. You might be wondering “What about the access controls?” – we design it in such a manner that the authenticated user sending the mutation is the board creator. The ID of the user is automatically decoded and picked up by the resolver as you will see in the code for the resolver. This creator is the first board user as well.
For updating a board, we design it in such a way that only the board name and/or board description may be changed. The mutation must provide the board ID. The actual logic for the update is in the service which we will discuss in the next section. The code for the update DTO is given below.
import { IsNotEmpty, IsUUID } from 'class-validator';
import { CreateBoardInput } from './create-board.input';
import { InputType, Field, Int, PartialType } from '@nestjs/graphql';
@InputType()
export class UpdateBoardInput extends PartialType(CreateBoardInput) {
@IsNotEmpty()
@IsUUID()
@Field(() => String)
id: string;
}
Finally, moving to the Resolver, we have 3 mutations – one to create called createBoard
, another to update called updateBoard
and one to remove the board given the id
called removeBoard
. These mutation names are automatically derived from the functions and as you can see by the @Mutation()
annotations above their respective functions, they return the created, updated and deleted boards respectively. Pay close attention to how these mutations also pick up the authenticated user details using the @GraphQLUser()
tag we discussed in the previous articles.
import {
Resolver,
Query,
Mutation,
Args,
Int,
ResolveField,
Parent,
} from '@nestjs/graphql';
import { BoardService } from './board.service';
import { Board } from './entities/board.entity';
import { CreateBoardInput } from './dto/create-board.input';
import { UpdateBoardInput } from './dto/update-board.input';
import { GraphQLUser } from 'src/decorators';
import { UserJwt } from 'src/auth/dto/user-jwt.dto';
import { BoardUserService } from 'src/board-user/board-user.service';
import { BoardUser } from 'src/board-user/entities/board-user.entity';
import { Column } from 'src/column/entities/column.entity';
import { ColumnService } from 'src/column/column.service';
import { User } from 'src/user/entities/user.entity';
import { UserService } from 'src/user/user.service';
@Resolver(() => Board)
export class BoardResolver {
constructor(
private readonly userService: UserService,
private readonly boardService: BoardService,
private readonly boardUserService: BoardUserService,
private readonly columnService: ColumnService
) {}
@Mutation(() => Board)
createBoard(
@Args('createBoardInput') createBoardInput: CreateBoardInput,
@GraphQLUser() user: UserJwt,
) {
return this.boardService.create(createBoardInput, user.sub);
}
@Query(() => [Board], { name: 'boards' })
findAll(@GraphQLUser() user: UserJwt) {
return this.boardService.findAll({
boardUsers: {
some: {
userId: user.sub,
},
},
});
}
@Query(() => Board, { name: 'board' })
findOne(
@Args('id', { type: () => String }) id: string,
@GraphQLUser() user: UserJwt,
) {
return this.boardService.findOne(id, user.sub);
}
@Mutation(() => Board)
updateBoard(
@Args('updateBoardInput') updateBoardInput: UpdateBoardInput,
@GraphQLUser() user: UserJwt,
) {
return this.boardService.update(
updateBoardInput.id,
updateBoardInput,
user.sub,
);
}
@Mutation(() => Board)
removeBoard(
@Args('id', { type: () => String }) id: string,
@GraphQLUser() user: UserJwt,
) {
return this.boardService.remove(id, user.sub);
}
@ResolveField('boardUsers', (returns) => [BoardUser])
async boardUsersResolver(@Parent() board) {
const { id } = board;
return this.boardUserService.findAll(undefined, id);
}
@ResolveField('columns', (returns) => [Column])
async columnsResolver(@Parent() board) {
const { id } = board;
return this.columnService.findAll({boardId: id});
}
@ResolveField(undefined, (returns) => User)
async createdByUser(@Parent() board) {
const {createdBy} = board
return this.userService.findOne(createdBy)
}
}
As can be seen from above, we have two queries (analogous to get
and get-all
routes in REST API paradigm). We make sure to pick the authenticated user and utilize the board user details to only return board(s) details the current user is a part of.
Finally, we have the Field Resolvers. These are special functions which utilize the services of the imported modules to provide details about custom fields defined in the Entity. The first argument to @ResolveField()
tag is the name of the field it is supposed to resolve. If not provided (i.e, passed undefined
) then NestJS takes it that the name of the field and the name of the method are same. The second arg is the return type. The standard format is to write it that way ((returns) => <returned value>
).
Whenever our board entity is getting resolved and the request asks for the custom fields, the specific board details will be passed via the argument decorated by @Parent()
. This will help us resolve the field details particular the specific board. In the above resolvers, we utilize the service methods from the imported modules to resolve the fields.
Service
Last but not least, the BoardService
. Keep in mind that in this application we are using two different databases. One is Supabase – which is used to maintain our user details. Another is a Postgres Instance hosted on Render. This is being used to maintain the rest of the entity details. In this service, we need to use the Prisma Client which points to our Render Postgres DB as shown by the PrismaRenderService
below.
import { Injectable } from '@nestjs/common';
import { CreateBoardInput } from './dto/create-board.input';
import { UpdateBoardInput } from './dto/update-board.input';
import { PrismaRenderService } from 'src/prisma-render/prisma-render.service';
import { Prisma } from '@prisma/render';
@Injectable()
export class BoardService {
constructor(private prisma: PrismaRenderService) {}
create(createBoardInput: CreateBoardInput, userId: string) {
return this.prisma.board.create({
data: {
...createBoardInput,
createdBy: userId,
boardUsers: {
create: {
userId,
},
},
},
});
}
findAll(where: Prisma.BoardWhereInput) {
return this.prisma.board.findMany({
where
});
}
findOne(id: string, userId?: string) {
const filter: Prisma.BoardWhereUniqueInput = {id}
if(userId) filter.boardUsers = { some: { userId } }
return this.prisma.board.findUniqueOrThrow({
where: filter,
});
}
update(
id: string,
{ id: _id, ...updateBoardInput }: UpdateBoardInput,
createdBy: string,
) {
return this.prisma.board.update({
where: {
id,
createdBy,
},
data: updateBoardInput,
});
}
remove(id: string, createdBy: string) {
return this.prisma.board.delete({
where: {
id,
createdBy,
},
});
}
}
The code in the service file is pretty straightforward. This intuitively shows that under the hood, the business logic stays similar regardless of the data presentation layer. We have our methods which act as a wrapper over our Prisma methods to perform CRUD operations relating to the Board entity.
BoardUser
The reason BoardUser
module is special is because, under the hood, we have a different DB instance for storing user profiles. So, in the DB instance storing info related to boards (including users allowed to access a board), we cannot establish a relationship to the user rows. In the second DB, we can only have a string type attribute which will have the UUIDs of the users.
It is at the business logic layer (NestJs resolver and service layers) that we will tie these together. This will give the user a seamless feel as if all the data is coming from a single DB instance. Between this module and the board and user modules, we would have covered almost all there is to GraphQL in NestJS and in general.
Before diving into the module code, let’s have a look at the BoardUser
entity below. As you can see, we only have two custom fields – one denoting a user entity (User
) called user
and another denoting the board that the user has access to (Board
entity type).
import { Field, ID, ObjectType } from "@nestjs/graphql";
import { Board } from "src/board/entities/board.entity";
import { User } from "src/user/entities/user.entity";
@ObjectType()
export class BoardUser {
@Field(() => User)
user: User;
@Field(() => Board)
board: Board
}
Now the design can vary. You might choose to have a design such that a BoardUser
entity represents a one-to-many relationship (one user and all the boards the user has access to). But here, for simplicity’s sake, we go with the above design. Feel free to modify it yourself.
Module
import { Module, forwardRef } from '@nestjs/common';
import { BoardUserService } from './board-user.service';
import { BoardUserResolver } from './board-user.resolver';
import { BoardModule } from 'src/board/board.module';
import { UserModule } from 'src/user/user.module';
@Module({
imports: [forwardRef(() => BoardModule), UserModule],
providers: [BoardUserResolver, BoardUserService],
exports: [BoardUserService]
})
export class BoardUserModule {}
The code for the BoardUserModule
is pretty simple. We just need to use the BoardModule
and the UserModule
. From the usage of forwardRef()
, you can easily guess that we have imported BoardUserModule
in BoardModule
but not in UserModule
.
Resolver
The resolver is the more interesting part of it. This is where we will need to resolve the custom fields. But at the same time, we will need to have CRUD operations in form of mutations and queries for the BoardUser
entity itself.
import {
Resolver,
Query,
Mutation,
Args,
ResolveField,
Parent,
} from '@nestjs/graphql';
import { BoardUserService } from './board-user.service';
import { AddBoardUserInput } from './dto/add-board-user.input';
import { RemoveBoardUserInput } from './dto/remove-board-user.input';
import { BoardService } from 'src/board/board.service';
import { BoardUser } from './entities/board-user.entity';
import { GraphQLUser } from 'src/decorators';
import { UserJwt } from 'src/auth/dto/user-jwt.dto';
import { UserService } from 'src/user/user.service';
@Resolver(() => BoardUser)
export class BoardUserResolver {
constructor(
private readonly boardUserService: BoardUserService,
private readonly boardService: BoardService,
private readonly userService: UserService,
) {}
@Mutation(() => BoardUser, { name: 'addBoardUser' })
create(
@Args('addBoardUserInput') addBoardUserInput: AddBoardUserInput,
@GraphQLUser() user: UserJwt,
) {
return this.boardUserService.create(addBoardUserInput, user.sub);
}
@Query(() => [BoardUser], { name: 'boardUsers' })
findAll() {
return this.boardUserService.findAll();
}
@Mutation(() => BoardUser, { name: 'removeBoardUser' })
remove(
@Args('removeBoardUser') removeBoardUserInput: RemoveBoardUserInput,
@GraphQLUser() user: UserJwt,
) {
return this.boardUserService.remove(removeBoardUserInput, user.sub);
}
@ResolveField()
async board(@Parent() boardUser) {
const { boardId } = boardUser;
console.log('Board User ID', boardUser);
return this.boardService.findOne(boardId);
}
@ResolveField()
async user(@Parent() boardUser) {
const { userId } = boardUser;
return this.userService.findOne(userId);
}
}
In the above, we have 2 mutation resolvers for mutations called called addBoardUser
and removeBoardUser
. The mutation names should convey their function. Note how we are also taking in the authenticated user details for these mutations. These would be used in the actual business layer for authorization checks.
We have a query resolver for boardUsers
query which returns all the BoardUser
entities stored in the DB. Then we have the field resolvers annotated by @ResolveField()
tag. In these field resolvers, note how we do not pass any names. So, the names of the functions are taken as the names of the fields they are supposed to resolve. We also extract boardId
and userId
to find the particular Board
and User
needed using the BoardService
and the UserService
.
As for the DTOs, we have two for adding and removing BoardUser
entries as shown below. Note how in the AddBoardUserInput
DTO, we take in userId
and boardId
. Everything else we do behind the scenes. So, between the resolver and service, we find the user and the board we need to add the user to.
import { Field, InputType } from "@nestjs/graphql";
import { IsNotEmpty, IsUUID } from "class-validator";
@InputType()
export class AddBoardUserInput {
@IsNotEmpty()
@IsUUID()
@Field(() => String)
userId: string;
@IsNotEmpty()
@IsUUID()
@Field(() => String)
boardId: string;
}
Lastly, for the RemoveBoardUserInput
DTO, we need the same details as above. But for better semantics, we go ahead a step and create a separate DTO. As shown below, the DTO inherits from AddBoardUserInput
DTO. Since we do not wrap the AddBoardUserInput
DTO with PartialType()
or OmitType()
, you can understand that the GraphQL API user needs to mandatorily provide the userId
and the boardId
.
import { InputType } from '@nestjs/graphql';
import { AddBoardUserInput } from './add-board-user.input';
@InputType()
export class RemoveBoardUserInput extends AddBoardUserInput {
}
Service
Lastly, we need to take a look at the service. This design might seem a bit peculiar. When creating a new BoardUser
using the create
method, we first check if the user adding the new user is supposed to have access to the board. You can go ahead and have a finer access control. But this is the depth we are going to go to avoid making the whole thing complex.
import { ForbiddenException, Injectable } from '@nestjs/common';
import { RemoveBoardUserInput } from './dto/remove-board-user.input';
import { AddBoardUserInput } from './dto/add-board-user.input';
import { PrismaRenderService } from 'src/prisma-render/prisma-render.service';
import { Prisma } from '@prisma/render';
@Injectable()
export class BoardUserService {
constructor(private prisma: PrismaRenderService) {}
async create({ userId, boardId }: AddBoardUserInput, existingUserId: string) {
try {
await this.prisma.boardUser.findUniqueOrThrow({
where: {
userId_boardId: {
userId: existingUserId,
boardId,
},
},
});
} catch (error) {
if (error.code === 'P2025')
throw new ForbiddenException('User not authorized');
}
return this.prisma.boardUser.create({
data: {
userId,
board: {
connect: {
id: boardId,
},
},
},
});
}
findAll(userId?: string, boardId?: string) {
var filter: Prisma.BoardUserWhereInput = {}
if (userId) filter = {...filter, userId}
if(boardId) filter = {...filter, boardId}
return this.prisma.boardUser.findMany({
where: filter
});
}
findAllUsingColumn(userId: string, columnId: string) {
return this.prisma.boardUser.findMany({
where: {
userId,
board: {
is: {
columns: {
some: {
id: columnId
}
}
}
}
}
})
}
findAllUsingCard(userId: string, cardId: string) {
return this.prisma.boardUser.findMany({
where: {
userId,
board: {
is: {
columns: {
some: {
cards: {
some: {
id: cardId
}
}
}
}
}
}
}
})
}
async remove(
{ userId, boardId }: RemoveBoardUserInput,
existingUserId: string,
) {
try {
await this.prisma.boardUser.findUniqueOrThrow({
where: {
userId_boardId: {
userId: existingUserId,
boardId,
},
},
});
} catch (error) {
if (error.code === 'P2025')
throw new ForbiddenException('User not authorized');
}
return this.prisma.boardUser.delete({
where: { userId_boardId: { userId, boardId } },
});
}
}
After that, we have the findAll
method which takes in optional arguments of userId
and boardId
and returns all the board users who match the filter. The interesting thing to note here is that this turns into a findOne
when both arguments are specified.
After that, we have a couple of helper methods to find board users by resolving from Column
and Card
. These are needed in ColumnModule
and CardModule
respectively. Given the columnId
and cardId
respectively, these find if the user (denoted by userId
) is supposed to have access to them.
Last but not least, we have the remove
method. Here, we make sure that only an existing user can remove another user from the board. Again, you can go even finer-grained in your particular design. This concludes the BoardUserModule
as a whole
What’s Next?
With this, we have covered the important modules that could have helped us understand how our backend would be working. Next up we will be delving into frontend design – so back to NextJS it is. I might take a couple of articles break before that because I would love to cover Chainlink CCIP before coming back into this.
So, until then, make sure to follow me to keep updated on the latest articles and WAGMI!
Posted on December 19, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.