Next x Nest – GraphQL for REST API developers - Part 1

Next x Nest – GraphQL for REST API developers - Part 1

In the past articles in this series, we have explored the frontend and backend side of authentication. If you have been following those, then right about now you’d also have a fully functional frontend in NextJS which uses Next Auth to facilitate Authentication with Google OAuth 2.0 and communicates with a backend built using NestJS which also has a separate but interoperable authentication system using Passport and Google OAuth.

At this stage, we are ready to flesh out our backend. As informed previously, we will be building our backend in GraphQL. So, even though the authN was done with REST APIs, the remainder of the backend will be exposed as GraphQL API. Why was this design decision made? Well, GraphQL allows for flexibility and customization on the frontend. It is one of those things that almost every seasoned backend dev is aware of and likes but tries to sometimes avoid. Since I have a knack for article articles on such things which are often “avoided” and because the Kanban board we are building will require this level of flexibility, the choice of GraphQL with Nest was made.

Just to confirm again, we will be using NestJS to make our lives easier. This article will be aimed at devs who have a good understanding of REST APIs and a basic understanding of NestJS. Keep in mind that NestJS offers 2 ways of using GraphQL

  1. Code-first Approach – Here you use the entities in NestJS to create the schema in a format that would be familiar to someone who has written DTOs in NestJS. NestJS takes these entities and stitches a GraphQL schema. The developer need not really worry about touch Schema Definition Language.

  2. Schema-first Approach – Here you specify the Schema in individual modules first. NestJS then combines these schemas and creates a singular schema definition language file. From this file, NestJS then generates types and entities to be used in the project.

There is a third approach where you can use both paradigms at once in the same project but it is complicated and can lead to exposure of multiple GraphQL endpoints and redundancies in schema definition if not used in a microservice-based architecture. We will explore this paradigm as well once we have explored the above two.

In this article, we will explore the “Code-first Approach”. This is geared towards REST API devs and makes them feel more at home. Apart from this, we will also explore how to use 2 different databases with Prisma in the same NestJS project and manage things like migrations and Prisma Studio between them.

Housekeeping

Before we begin, you will need to provision a new PostgreSQL (different from the Supabase one we have been using thus far). For this tutorial, we will use Render. Render offers free managed PostgreSQL instances for a period of 90 days. If you haven’t created a Render account yet, head over and sign up. You will be redirected to your Render Dashboard. Click on “New” in the Appbar and select PostgreSQL DB. You will see something similar to the image shown below. Fill in the fields and click on Create.

Render Postgres Isntance

Once the creation is complete, move over to the Info tab of your instance. Here you will get the External Connection URL. As expected, this will be used to connect from your NestJS project using Prisma. Also, make sure that the Access Control is set to 0.0.0.0/0 as shown in the below image. This means anyone on the internet with the Connection string will be able to access the DB instance. This is why it's paramount to keep your connection string a secret. Storing it in a .env file is a step in that direction.

Render Postgres Instance Info

To use GraphQL in a NestJs project we need to install NestJs’ Apollo and GraphQL packages. For these, run npx i @nestjs/apollo @nestjs/graphql. Once this finishes, move over to your app.module.ts. Here we will import the GraphQL config module and make it generally available. To do this, inside the imports array, add the following:

// Add the following to the imports array
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      include: [CardModule, BoardModule, BoardUserModule, ColumnModule],
      autoSchemaFile: join(process.cwd(), 'src/graphql-schema/schema.gql'),
    }),

Using Multiple Prisma Schema in a single project

We have set up a Supabase instance and connected it to our NestJS project. That will be used only for Auth. For all other purposes, we will use another PostgreSQL instance. This also means we will have 2 separate schema.prisma files and Prisma clients.

This article by @micalevisk offers great insight into how to use different versions of the same module in a NestJS project. In our case, we will be using different Prisma Client Modules in the same Nest project. Remove traces of the Prisma module we developed in our last article from our project and then run the following commands:

  1. nest new module prisma-render followed by nest new service prisma-render - This will create the first Prisma Module which we will use to connect to our Render Postgres DB instance.

  2. nest new module prisma-supabase followed by nest new service prisma-supabase - This will create the Module which we will use to connect to our Supabase Postgres instance.

Prisma Supabase Module

Our PrismaSupabaseModule will be a global module and it will export the service of the module. This will enable other modules like AuthModule and UserModule to utilize it with ease. The code below for our prisma-supabase.module.ts file does exactly that:

// prisma-supabase.module.ts
import { Global, Module } from '@nestjs/common';
import { PrismaSupabaseService } from './prisma-supabase.service';

@Global()
@Module({
  providers: [PrismaSupabaseService],
  exports: [PrismaSupabaseService]
})
export class PrismaSupabaseModule {}

Before moving to the service, we need to make certain changes to our schema file. Firstly, we need to rename it. As a good practice, we will call our Supabase schema file supabase.schema.prisma. We need not touch the schema definition in the file. Just a little change in the generator configuration and we are daisy!

Val Kilmer Daisy

Our dependencies are being taken care of by Turbo and the NestJS & NextJS dependencies are both stored globally. The client our Prisma will generate will also need to be put in that location for easy path resolution. For that reason, we will need to change the output path from default to inside a special folder in the node_modules as shown below.

// generator for supabase.schema.prisma
generator client {
  provider = "prisma-client-js"
  output = "../../../node_modules/@prisma/supabase"
}

Now with this change, we need to run npx prisma generate –schema=./prisma/supabase.schema.prisma. Note how we specify the name of the schema file since there is no default schema.prisma file for Prisma to pick up automatically. This will generate our Supabase Prisma Client inside the Prisma dependency of the global node_modules. We can now move to create our Supabase service.

Inside our prisma-supabase.service.ts file of our prisma-supabase module, we will need to import the PrismaClient from @prisma/supabase instead of our default @prisma/client. This is because our Supabase-specific Prisma client is now generated inside our custom location. The remainder of the code will remain similar to the steps we took in our previous article when we created our Prisma module. So hopefully the code below for our prisma-supabase.service.ts needs no further explanations.

// prisma-supabase.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaClient } from '@prisma/supabase'; //NOTE HERE

@Injectable()
export class PrismaSupabaseService extends PrismaClient {
  constructor(configService: ConfigService) {
    super({
      datasources: {
        db: {
          url: configService.get('SUPABASE_DATABASE_URL'),
        },
      },
    });
  }

}

Finally, we will change the imports of our AuthModule which needs to use this PrismaSupabaseModule. Inside auth.service.ts we will change to PrismaSupabaseService instead of our old PrismaService (which no longer exists). The minor changes are shown below:

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { GoogleLoginUserDto } from './dto/google-login.dto';
import { PrismaSupabaseService } from 'src/prisma-supabase/prisma-supabase.service'; // NOTE HERE

@Injectable()
export class AuthService {
  constructor(
    private jwtService: JwtService,
    private configService: ConfigService,
    private prisma: PrismaSupabaseService, // NOTE HERE
  ) {}

/*
*
* Remainder of the service remains same
*
/

}

Prisma Render Module

Next up, we will need modifications to our PrismaRenderModule. But before that, we need to discuss the Schema for our project. Given below is the Schema for our Kanban Board. These are the contents of our render.schema.prisma file that we need to create inside our prisma folder where our supabase.schema.prisma file is stored. Explanations follow.

generator client {
  provider = "prisma-client-js"
  output   = "../../../node_modules/@prisma/render"
}

datasource db {
  provider = "postgresql"
  url      = env("RENDER_DATABASE_URL")
}

model Board {
  id        String   @id @default(uuid())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  boardName        String
  boardDescription String
  createdBy        String

  columns   Column[]
  boardUsers BoardUser[]
}

model BoardUser {
  userId String
  board  Board  @relation(fields: [boardId], references: [id], onDelete: Cascade)

  boardId String

  @@unique([userId, boardId])
}

model Column {
  id        String   @id @default(uuid())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  columnName        String
  columnDescription String

  board   Board  @relation(fields: [boardId], references: [id])
  boardId String

  cards Card[]
}

model Card {
  id        String   @id @default(uuid())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  cardNumber      Int
  cardName        String
  cardDescription String
  createdBy       String
  assignedTo      String
  storyPoints     Int
  priority        Priority
  status          Status
  startDate       DateTime
  endDate         DateTime

  comments Comment[]
  column   Column    @relation(fields: [columnId], references: [id])
  columnId String
}

model Comment {
  id        String   @id @default(uuid())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  createdBy String
  body      String
  card      Card   @relation(fields: [cardId], references: [id])
  cardId    String
}

enum Priority {
  CRITICAL
  HIGH
  MEDIUM
  LOW
}

enum Status {
  TODO
  IN_PROGRESS
  DONE
}

Just like our Supabase client, we will generate our Render client in a custom location right alongside our Supabase client. For this, we change the output location of our generator. We will be connecting to our Render DB instance using the RENDER_DATABASE_URL env variable. So, keep in mind to include it in the .env file and in the Environment Variable Schema check inside ConfigModule imported in app.module.ts.

We have 5 models in our Kanban board project:

  1. Board - Represents a Kanban board. Will have a name, description a list of users who have access to the board. We also record the creator of the board for admin functions.

  2. BoardUser - Represents the table which records user access to a specific board. We are not having RBAC here. We assume all users in this table have equal rights and rank lower than the board creator.

  3. Column - This model represents the columns inside our board. While the default ones a board might have are “Backlog”, “To-Do”, “Under Progress” and “Done”, we are not enforcing them. So, every new board has 0 columns. The users need to create these columns by themselves. A column will have a list of Cards.

  4. Card - These represent the user stories or tasks. A card will have a globally unique ID, a number that will be unique in a board, name, description, story points, assigned to user ID, start & end date, a status (enum which can be TODO, IN_PROGRESS and DONE) and a priority (enum which can be CRITICAL, HIGH, MEDIUM and LOW). A card will have a list of comments.

  5. Comment - Comments that a board user might leave on a card. This can range from a PR created for the tasks to any notes for other users. A Comment will always have a corresponding Card.

Keep in mind that we are not going full gaga over the functionality. The aim of this mini-project is to demonstrate the various moving parts in a Nest + Next project ecosystem. As such, if you want you may include your touches to the mini-project.

Now we will not be running the generate command for Prisma this time around. Here, we have created our schema but our Render Postgres Instance does not contain that. We are going to migrate it. To migrate to a specific DB when you are using multiple Prisma Schema files, we need to specify the schema file to use for the migration using the --schema flag. In our case, we need to run npx prisma migrate dev –schema=./prisma/render.schema.prisma. This will run the migration and generate the client in the custom location we have specified in the above schema.

Finally, inside prisma-render.service.ts, we need to follow the steps we have done for our Supabase client and import the newly generated Render Prisma Client. The code below exactly does that.

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaClient } from '@prisma/render'; // NOTE HERE

@Injectable()
export class PrismaRenderService extends PrismaClient {
  constructor(configService: ConfigService) {
    super({
      datasources: {
        db: {
          url: configService.get('RENDER_DATABASE_URL'),
        },
      },
    });
  }

}

GraphQL Resolvers

Now with the schema above, you’d expect to create separate modules and write resolvers. In fact, that’s what we need to do. But for the sake of keeping the article short, I am going to cover the 3 more important types of resolvers. The remainder will be inside the GitHub repo on the main branch.

Resolvers in GraphQL are your REST API endpoint counterpart. There are no “GET”, “POST”, “PUT” or “DELETE” verbs to work with in GraphQL. You create “resolvers” for your data model (basically those tables or mongodb collections). These resolvers are then invoked from the frontend. Inside the resolvers, you specify which attributes (table column or document field in case of mongodb) of the model you want to return while coding. Typically, all the attributes that may be required are sent as response of a resolver. On the frontend, a query is written using the resolver which basically tells your GraphQL API which parts or attributes from the resolver response the frontend wants. Only those attributes are sent back. That’s how the whole system works.

We will cover the UserModule, BoardModule and BoardUserModule. Between these three, most of the important points can be covered. Before we get started, let’s generate the three modules. Consecutively run the following. Keep in mind that you need to tell NestJs to generate CRUD endpoints and when asked, select “GraphQL (Code First)”.

  1. nest g resource user - This will generate our UserModule. This module will use the Supabase Prisma Client and act as common ground for resolving user info from other resolvers.

  2. nest g resource board - This will generate our BoardModule. This module will use Render Prisma Client under the hood.

  3. nest g resource board-user - This will generate our BoardUserModule. While this will primarily use the Render Prisma Client, it will use the UserModule to resolve user details of a board user.

Circular Dependency

Circular dependency is a dreaded problem in the programming world. But unfortunately (or fortunately), circular dependencies are quite common in GraphQL. This will be clear as we go on. For now, remember that NestJS offers an easy way out of it using forwardRef().

Basically, you take the imports arrays of the two modules having circular dependencies on one another. Inside the imports array, you wrap the imported module with forwardRef() as shown below. You do this on both modules. This solves the issue. NestJs will now use the ref of the module to solve the circular dependency.

// Inside board.module.ts
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 { UserModule } from 'src/user/user.module';

@Module({
  imports: [
    forwardRef(() => BoardUserModule), //NOTE HERE
    forwardRef(() => UserModule) //NOTE HERE
  ],
  providers: [BoardResolver, BoardService],
  exports: [BoardService],
})
export class BoardModule {}

Likewise importing on the other side (import in the BoardUserModule as well):

import { Module, forwardRef } from '@nestjs/common';
import { UserService } from './user.service';
import { UserResolver } from './user.resolver';
import { BoardModule } from 'src/board/board.module';

@Module({
  imports: [forwardRef(() => BoardModule)], // NOTE HERE
  providers: [UserResolver, UserService],
  exports: [UserService],
})
export class UserModule {}

User Module

While you might think the first thing, we need to define in the module is the module file itself, the entity file is more important. There are also the DTOs we need to define in the module as we will see in the later modules. In this module, the User data is just read so we won’t need any DTOs here as there are no creation or updation happening.

User Entity

In NestJS, the entity has an @ObjectType() decorator. This tells the NestJS GraphQL engine that what it is reading needs to be provided when this entity is “resolved”. The entity itself is represented by a class and its properties closely mirror the DB model and parts of the DB model you want to expose (or how you want to expose them). Take for instance the below User Entity class:

import { ObjectType, Field, ID, GraphQLISODateTime } from '@nestjs/graphql';
import { Board } from 'src/board/entities/board.entity';

@ObjectType()
export class User {
  @Field(() => ID)
  id: string;

  @Field(() => String)
  name: string;

  @Field(() => String)
  email: string;

  @Field(() => GraphQLISODateTime)
  emailVerified: Date;

  @Field(() => String)
  image: string;

  @Field(() => [Board])
  boardsCreated: [Board]
}

In the above entity class, we say that User has an id. This id is of type string. In GraphQL, ID is actually a datatype. Every property in an entity needs to be decorated by the @Field() decorator. This @Field() decorator tells GraphQL the type of the property and whether it can be nullable or not. By default, nothing is nullable.

We say that the user will have a name, email, and an image link. These will be string type (denoted by their type inside the class and inside the @Field() decorator). The property will be a Date type. GraphQL has an inbuilt type of GraphQLISODateTime to represent such Date formats as shown by the corresponding field decorator.

Lastly, we say that when a user is resolved, it must also show the boards that the user has created with the boardsCreated property. Note how we specify another entity as the type inside the corresponding @Field() decorator. This is a very handy way to represent the Board inside other types.

Throwback to when I said that the entity object closely mirrors the DB and what/how you want to expose the data.

We don’t have a Board model in our User model. The User model is described in our Supabase instance while the Board model is in our Render instance. When we write our business logic using our resolver files and service files, we will create a resolver for the board inside our user module. On the frontend, anyone querying the API will not notice the difference in DB storage. Thus, we have two different Database instances’ data being exposed through GraphQL queries. This will also allow the frontend user to write complicated queries which involve both models.

We can go a few steps further and add fields like comments, cardCreated and cardsAssigned. But the main point here is to help you understand how resolving a field works. Once you understand that, feel free to go wild with it.

Module

Our Module here is plain and simple. Since we need to resolve the boards field in User entity, we need to include BoardService which comes from BoardModule. But we also need to make sure to avoid Circular dependency. So, forwardRef() comes in handy. In the code below, we do exactly that.

// user.module.ts

import { Module, forwardRef } from '@nestjs/common';
import { UserService } from './user.service';
import { UserResolver } from './user.resolver';
import { BoardModule } from 'src/board/board.module';

@Module({
  imports: [forwardRef(() => BoardModule)],
  providers: [UserResolver, UserService],
  exports: [UserService],
})
export class UserModule {}

Resolver

Now, a resolver is the REST controller counterpart in GraphQL. It stands to reason that it will invoke methods in the service file of the module to handle requests and send back responses. In GraphQL, a “query” is any read-type request, and a “mutation” is a write-type request. So, any creation, updation or deletion would be a “mutation”. We need to define these queries and mutations as shown in the code below. Explanations follow.

// user.resolver.ts

import {
  Resolver,
  Query,
  Mutation,
  Args,
  Int,
  ResolveField,
  Parent,
} from '@nestjs/graphql';
import { UserService } from './user.service';
import { User } from './entities/user.entity';
import { GraphQLUser } from 'src/decorators';
import { Board } from 'src/board/entities/board.entity';
import { BoardService } from 'src/board/board.service';

@Resolver(() => User)
export class UserResolver {
  constructor(
    private readonly userService: UserService,
    private readonly boardService: BoardService,
  ) {}

  @Query(() => [User], { name: 'users' })
  findAll() {
    return this.userService.findAll({});
  }

  @Query(() => User, { name: 'user' })
  findOne(@Args('id', { type: () => String }) id: string) {
    return this.userService.findOne(id);
  }

  @Mutation(() => User)
  removeUser(@GraphQLUser('sub') id: string) {
    return this.userService.remove(id);
  }

  @ResolveField('boardsCreated', (returns) => [Board])
  getBoardsCreatedByUser(@Parent() user: User) {
    const { id } = user;
    return this.boardService.findAll({createdBy: id});
  }
}

In the constructor at lines 19-20, we use the shorthand to define the services we are going to invoke. We are going to need the underlying UserService and the BoardService which comes from BoardModule which we imported into the module.

The first query that we define at line 23 is the counterpart of “GET all” route in a REST API. We say that it will return a list of User ’s. The second option to the @Query() decorator is the name of the query. So, to get a list of all users, the users query will be invoked and the fields required will be passed into it.

Second, we have the “GET by ID” REST counterpart GraphQL query at line 28. As you might have guessed, it is supposed to return a single User. To get the user, one would have to use the user(id) query and pass in the list of attributes required.

We won’t be needing a “PUT” or “PATCH” counterpart here. Will be exploring it in the next module. So, we jump to the first mutation at line 33. Note how we do not pass a {name: <mutation name>} here. Keep in mind that we do not have that, NestJS will use the function name as the name of the mutation. The same goes for the queries. This “DELETE by ID” REST API counterpart tells us that when this mutation is invoked, we will take the user ID (which is in the sub field of the JWT) and remove the user.

Lastly, we have the @ResolveField() decorator. This is used to resolve any outside fields which may exist in the entity. In our case, that’s the boardsCreated field in the User entity. This is a powerful feature because now, GraphQL can resolve the board and one can pass in the attributes needed from the boards and get back only those attributes. The @Parent() decorator is used to get the user whose created boards we want to access. The user argument in the parameter list of the getBoardsCreatedByUser() function receives the user details. We have defined a findAll() function inside BoardService which is then invoked to filter out boards by the createdBy field on the Board model.

Service

We will make short work of our user service. We just need three functions in the service that will invoke Prisma API functions for our models defined in the supabase.schema.prisma file. As shown in the code below, we just the findAll, findOne, and remove functions. Note how we import the PrismaSupabaseService. We didn’t need to import the PrismaSupabaseModule because we had declared it as a global module.

// user.service.ts

import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/supabase';
import { PrismaSupabaseService } from 'src/prisma-supabase/prisma-supabase.service';

@Injectable()
export class UserService {
  constructor(private prisma: PrismaSupabaseService) {}
  findAll(where: Prisma.usersWhereInput) {
    return this.prisma.users.findMany({where});
  }

  findOne(id: string) {
    return this.prisma.users.findUniqueOrThrow({where: {id}});
  }

  remove(id: string) {
    return this.prisma.users.delete({where: {id}});
  }
}

In line 8, we define our findAll() function. This is a weak wrapper around Prisma’s model-specific findMany() function. This is more of a personal bias. This helps in importing this service and invoking the findAll with filters which would translate to the where clause of the Prisma API. It is a handy trick I like to use.

Next, we have the findOne() function which takes in an id and either returns the user with that ID or throws an exception. Of course, this translated to an Internal Server Error. So, it is always recommended to have NestJS Global Exception filters in place. I like to throw such an error from the service and have it handled in a custom Prisma Exception NestJS filter.

Lastly, we have the remove() function which takes in the id and returns the deleted user as a response. This concludes our service.

What’s Next?

Now I wanted to cover the board and board user modules in this article itself but that would make it 5k + words. That’s way too long for developers with the attention span of a goldfish. Being someone who has a similar attention span, I would never want to inflict that on a fellow developer. So, we will cover those modules and then some in the next part.

That's why I have to bring this article to an abrupt end. Keep in mind that the code discussed here has been open-sourced at the following repo: %[github.com/abhik-99/Trivial-Kanban]

Until then, keep building awesome stuff and WAGMI!

Did you find this article valuable?

Support Bored on the Edge by becoming a sponsor. Any amount is appreciated!