Nest JS with Mongo DB Managing Relationships Part-4

Nest JS with Mongo DB Managing Relationships Part-4

banner nestjs

Blog Name Link
Part-1 Nest JS Building REST APIs using Mongo DB Part-1 https://tkssharma.com/building-rest-apis-with-nestjs-and-mongodb-part-1
Part-2 Nest JS APIs with mongoose Mongo DB Database Part-2 https://tkssharma.com/building-rest-apis-with-nestjs-and-mongodb-part-2
Part-3 Nest JS Building Auth Service with JWT Tokens Part-3 https://tkssharma.com/building-nestjs-auth-service-with-mongodb-part-3

In this post, I will demonstrate how to kickstart a simple RESTful APIs with NestJS from a newbie's viewpoint.

Github Link https://github.com/tkssharma/blogs/tree/master/nestjs-rest-apis-docs

What is NestJS?

As described in the Nestjs website, Nestjs is a progressive Node.js framework for building efficient, reliable and scalable server-side applications.

Nestjs combines the best programming practice and the cutting-edge techniques from the NodeJS communities.

  • A lot of NestJS concepts are heavily inspired by the effort of the popular frameworks in the world, esp. Angular .
  • Nestjs hides the complexities of web programming in NodeJS, it provides a common abstraction of the web request handling, you are free to choose Expressjs or Fastify as the background engine.
  • Nestjs provides a lot of third party project integrations, from database operations, such as Mongoose, TypeORM, etc. to Message Brokers, such as Redis, RabbitMQ, etc.

If you are new to Nestjs like me but has some experience of Angular , TypeDI or Spring WebMVC, bootstraping a Nestjs project is really a piece of cake.

Generating a NestJS project

Make sure you have installed the latest Nodejs.

npm i -g @nestjs/cli

When it is finished, there is a nest command available in the Path. The usage of nest is similar with ng (Angular CLI), type nest --help in the terminal to list help for all commands.

❯ nest --help
Usage: nest <command> [options]

Options:
  -v, --version                                   Output the current version.
  -h, --help                                      Output usage information.

Commands:
  new|n [options] [name]                          Generate Nest application.
  build [options] [app]                           Build Nest application.
  start [options] [app]                           Run Nest application.
  info|i                                          Display Nest project details.
  update|u [options]                              Update Nest dependencies.
  add [options] <library>                         Adds support for an external library to your project.
  generate|g [options] <schematic> [name] [path]  Generate a Nest element.
    Available schematics:
      ┌───────────────┬─────────────┐
      │ name          │ alias       │
      │ application   │ application │
      │ class         │ cl          │
      │ configuration │ config      │
      │ controller    │ co          │
      │ decorator     │ d           │
      │ filter        │ f           │
      │ gateway       │ ga          │
      │ guard         │ gu          │
      │ interceptor   │ in          │
      │ interface     │ interface   │
      │ middleware    │ mi          │
      │ module        │ mo          │
      │ pipe          │ pi          │
      │ provider      │ pr          │
      │ resolver      │ r           │
      │ service       │ s           │
      │ library       │ lib         │
      │ sub-app       │ app         │
      └───────────────┴─────────────┘

Now generate a Nestjs project via:

nest new nestjs-sample

Open it in your favorite IDEs, such as Intellij WebStorm or VSCode.

Exploring the project files

Expand the project root, you will see the following like tree nodes.

.
├── LICENSE
├── nest-cli.json
├── package.json
├── package-lock.json
├── README.md
├── src
│   ├── app.controller.spec.ts
│   ├── app.controller.ts
│   ├── app.module.ts
│   ├── app.service.ts
│   └── main.ts
├── test
│   ├── app.e2e-spec.ts
│   └── jest-e2e.json
├── tsconfig.build.json
└── tsconfig.json

The default structure of this project is very similar with the one generated by Angular CLI.

  • src/main.ts is the entry file of this application.
  • src/app* is the top level component in a nest application.
    • There is an app.module.ts is a Nestjs Module which is similar with Angular NgModule, and used to organize codes in the logic view.
    • The app.service.ts is an @Injectable component, similar with the service in Angular or Spring's Service, it is used for handling business logic. A service is annotated with @Injectable.
    • The app.controller.ts is the controller of MVC, to handle incoming request, and responds the handled result back to client. The annotatoin @Controller() is similar with Spring MVC's @Controller.
    • The app.controller.spec.ts is test file for app.controller.ts. Nestjs uses Jest as testing framework.
  • test folder is for storing e2e test files.

Dealing with model relations

We have added authentication in our application in the last post, you maybe have a question, can I add some fields to Post document and remember the user who created it and the one who updated it at the last time.

When I come to the Post model, and try to add fields to setup the auditors, I can not find a simple way to do this. After researching and consulting from the Nestjs channel in Discord, I was told that the @nestjs/mongoose can not deal with the relations between Documents.

There are some suggestions I got from the community.

  • Use Typegoose instead of @nestjs/mongoose, check the typegoose doc for more details. More effectively, there is a nestjs-typegoose to assist you to bridge typegoose to the Nestjs world.
  • Give up @nestjs/mongoose and turn back to use the raw mongoose APIs instead.

I have some experience of express and mongoose written in legacy ES5, so in this post I will try to switch to use the pure Mongoose API to replace the modeling codes we have done in the previous post. With the help of @types/mongoose, it is easy to apply static types on the mongoose schemas , documents and models.

Redefining the models with Mongoose API

We will follow the following steps to clean the codes of models one by one .

  1. Clean the document definition interface.
  2. Redefine the schema for related documents using Mongoose APIs.
  3. Define mongoose Models and provide them in the Nestjs IOC engine.
  4. Create a custom provider for connecting to Mongo using Mongoose APIs.
  5. Remove the @nestjs/mongoose dependency finally.

Firstly let's have a look at Post, in the post.model.ts, fill the following content:

import { Document, Schema, SchemaTypes } from 'mongoose';
import { User } from './user.model';

export interface Post extends Document {
  readonly title: string;
  readonly content: string;
  readonly createdBy?: Partial<User>;
  readonly updatedBy?: Partial<User>;
}

export const PostSchema = new Schema(
  {
    title: SchemaTypes.String,
    content: SchemaTypes.String,
    createdBy: { type: SchemaTypes.ObjectId, ref: 'User', required: false },
    updatedBy: { type: SchemaTypes.ObjectId, ref: 'User', required: false },
  },
  { timestamps: true },
);

The PostSchema is defined by type-safe way, all supports can be found in SchemeTypes while navigating it. The createdBy and updatedBy is a reference of User document. The { timestamps: true } will append createdAt and updatedAt to the document and fill these two fields the current timestamp automatically when saving and updating the documents.

Create a database.providers.ts file to declare the Post model. We also create a provider for Mongo connection.

import { PostSchema, Post } from './post.model';
import {
  DATABASE_CONNECTION,
  POST_MODEL
} from './database.constants';

export const databaseProviders = [
  {
    provide: DATABASE_CONNECTION,
    useFactory: (): Promise<typeof mongoose> =>
      connect('mongodb://localhost/blog', {
        useNewUrlParser: true,
        useUnifiedTopology: true,
      }),
  },
  {
    provide: POST_MODEL,
    useFactory: (connection: Connection) =>
      connection.model<Post>('Post', PostSchema, 'posts'),
    inject: [DATABASE_CONNECTION],
  },
//...
];

More info about creating custom providers, check the custom providers chapter of the official docs.

For the convenience of using the injection token, create a database.constant.ts file to define series of constants for further uses.

export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
export const POST_MODEL = 'POST_MODEL';
export const USER_MODEL = 'USER_MODEL';
export const COMMENT_MODEL = 'COMMENT_MODEL';

Create a database.module.ts file, and define a Module to collect the Mongoose related resources.

@Module({
  providers: [...databaseProviders],
  exports: [...databaseProviders],
})
export class DatabaseModule {}

To better organize the codes, move all model related codes into the database folder.

Import DatabaseModule in the AppModule.

@Module({
  imports: [
    DatabaseModule,
//...
})
export class AppModule {}

Now in the post.service.ts, change the injecting Model<Post> to the following.

 constructor(
    @Inject(POST_MODEL) private postModel: Model<Post>,
    //...
    ){...}

In the test, change the injection token from class name to the constant value we defined, eg.

module.get<Model<Post>>(POST_MODEL)

Similarly, update the user.model.ts and related codes.

//database/user.model.ts
export interface User extends Document {
  readonly username: string;
  readonly email: string;
  readonly password: string;
  readonly firstName?: string;
  readonly lastName?: string;
  readonly roles?: RoleType[];
}

const UserSchema = new Schema(
  {
    username: SchemaTypes.String,
    password: SchemaTypes.String,
    email: SchemaTypes.String,
    firstName: { type: SchemaTypes.String, required: false },
    lastName: { type: SchemaTypes.String, required: false },
    roles: [
      { type: SchemaTypes.String, enum: ['ADMIN', 'USER'], required: false },
    ],
    //   createdAt: { type: SchemaTypes.Date, required: false },
    //   updatedAt: { type: SchemaTypes.Date, required: false },
  },
  { timestamps: true },
);

UserSchema.virtual('name').get(function() {
  return `${this.firstName} ${this.lastName}`;
});

export const userModelFn = (conn: Connection) =>
  conn.model<User>('User', UserSchema, 'users');


//database/role-type.enum.ts
export enum RoleType {
  ADMIN = 'ADMIN',
  USER = 'USER',
}

//database/database.providers.ts
export const databaseProviders = [
    //...
  {
    provide: USER_MODEL,
    useFactory: (connection: Connection) => userModelFn(connection),
    inject: [DATABASE_CONNECTION],
  },
];

//user/user.service.ts
@Injectable()
export class UserService {
  constructor(@Inject(USER_MODEL) private userModel: Model<User>) {}
  //...	
}

Create another model Comment, as sub document of Post. A comment holds a reference of Post doc.

export interface Comment extends Document {
  readonly content: string;
  readonly post?: Partial<Post>;
  readonly createdBy?: Partial<User>;
  readonly updatedBy?: Partial<User>;
}

export const CommentSchema = new Schema(
  {
    content: SchemaTypes.String,
    post: { type: SchemaTypes.ObjectId, ref: 'Post', required: false },
    createdBy: { type: SchemaTypes.ObjectId, ref: 'User', required: false },
    updatedBy: { type: SchemaTypes.ObjectId, ref: 'User', required: false },
  },
  { timestamps: true },
);

Register it in databaseProviders.

export const databaseProviders = [
  //...
  {
    provide: COMMENT_MODEL,
    useFactory: (connection: Connection) =>
      connection.model<Post>('Comment', CommentSchema, 'comments'),
    inject: [DATABASE_CONNECTION],
  },
 ]

Update the PostService , add two methods.

//post/post.service.ts
export class PostService {
  constructor(
    @Inject(POST_MODEL) private postModel: Model<Post>,
    @Inject(COMMENT_MODEL) private commentModel: Model<Comment>
  ) {}
    //...
    //  actions for comments
  createCommentFor(id: string, data: CreateCommentDto): Observable<Comment> {
    const createdComment = this.commentModel.create({
      post: { _id: id },
      ...data,
      createdBy: { _id: this.req.user._id },
    });
    return from(createdComment);
  }

  commentsOf(id: string): Observable<Comment[]> {
    const comments = this.commentModel
      .find({
        post: { _id: id },
      })
      .select('-post')
      .exec();
    return from(comments);
  }
}    

The CreateCommentDto is a POJO to collect the data from request body.

//post/create-comment.dto.ts
export class CreateCommentDto {
  readonly content: string;
}

Open PostController, add two methods.

export class PostController {
  constructor(private postService: PostService) {}

  //...
  @Post(':id/comments')
  createCommentForPost(
    @Param('id') id: string,
    @Body() data: CreateCommentDto,
  ): Observable<Comment> {
    return this.postService.createCommentFor(id, data);
  }

  @Get(':id/comments')
  getAllCommentsOfPost(@Param('id') id: string): Observable<Comment[]> {
    return this.postService.commentsOf(id);
  }
}

In the last post, we created authentication, to protect the saving and updating operations, you can set JwtGuard on the methods of the controllers.

But if we want to control the access in details, we need to consider Authorization, most of time, it is simple to implement it by introducing RBAC.

Role based access control

Assume there are two roles defined in this application, USER and ADMIN. In fact, we have already defined an enum class to archive this purpose.

Nestjs provide a simple way to set metadata by decorator on methods.

import { SetMetadata } from '@nestjs/common';
import { RoleType } from '../database/role-type.enum';
import { HAS_ROLES_KEY } from './auth.constants';

export const HasRoles = (...args: RoleType[]) => SetMetadata(HAS_ROLES_KEY, args);

Create specific Guard to read the metadata and compare the user object in request and decide if allow user to access the controlled resources.

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const roles = this.reflector.get<RoleType[]>(
      HAS_ROLES_KEY,
      context.getHandler(),
    );
    if (!roles) {
      return true;
    }

    const { user }= context.switchToHttp().getRequest() as AuthenticatedRequest;
    return user.roles && user.roles.some(r => roles.includes(r));
  }
}

For example, we require a USER role to create a Post document.

export class PostController {
  constructor(private postService: PostService) {}
    
  @Post('')
  @UseGuards(JwtAuthGuard, RolesGuard)
  @HasRoles(RoleType.USER, RoleType.ADMIN)
  createPost(@Body() post: CreatePostDto): Observable<BlogPost> {
    //...
  }
    
}    

You can add other rules on the resource access, such as a USER role is required to update a Post, and ADMIN is to delete a Post.

Adding auditing info

We have added roles to control access the resources, now we can save the current user who is creating the post or update the post.

There is a barrier when we wan to read the authenticated user from request and set it to fields createdBy and updatedBy in PostService, the PostService is singleton scoped, you can not inject a request in it. But you can declare the PostService is REQUEST scoped, thus injecting a request instance is possible.

@Injectable({ scope: Scope.REQUEST })
export class PostService {
  constructor(
    @Inject(POST_MODEL) private postModel: Model<Post>,
    @Inject(COMMENT_MODEL) private commentModel: Model<Comment>,
    @Inject(REQUEST) private req: AuthenticatedRequest,
  ) {}
    
  //...
  save(data: CreatePostDto): Observable<Post> {
    const createPost = this.postModel.create({
      ...data,
      createdBy: { _id: this.req.user._id },
    });
    return from(createPost);
  }
 
  update(id: string, data: UpdatePostDto): Observable<Post> {
    return from(
      this.postModel
        .findOneAndUpdate(
          { _id: id },
          { ...data, updatedBy: { _id: this.req.user._id } },
        )
        .exec(),
    );
  }
 
 //  actions for comments
  createCommentFor(id: string, data: CreateCommentDto): Observable<Comment> {
    const createdComment = this.commentModel.create({
      post: { _id: id },
      ...data,
      createdBy: { _id: this.req.user._id },
    });
    return from(createdComment);
  }    
}    

As a convention in Nestjs, you have to make PostController available in the REQUEST scoped.

@Controller({path:'posts', scope:Scope.REQUEST})
export class PostController {...}

In the test codes, you have to resolve to replace get to get the instance from Nestjs test harness.

describe('Post Controller', () => {
  describe('Replace PostService in provider(useClass: PostServiceStub)', () => {
    let controller: PostController;

    beforeEach(async () => {
      const module: TestingModule = await Test.createTestingModule({
        providers: [
          {
            provide: PostService,
            useClass: PostServiceStub,
          },
        ],
        controllers: [PostController],
      }).compile();

      controller = await module.resolve<PostController>(PostController);// use resovle here....
    });
  ...

PostService also should be changed to request scoped.

@Injectable({ scope: Scope.REQUEST })
export class PostService {...}

In the post.service.spec.ts , you have to update the mocking progress.

describe('PostService', () => {
  let service: PostService;
  let model: Model<Post>;
  let commentModel: Model<Comment>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        PostService,
        {
          provide: POST_MODEL,
          useValue: {
            new: jest.fn(),
            constructor: jest.fn(),
            find: jest.fn(),
            findOne: jest.fn(),
            update: jest.fn(),
            create: jest.fn(),
            remove: jest.fn(),
            exec: jest.fn(),
            deleteMany: jest.fn(),
            deleteOne: jest.fn(),
            updateOne: jest.fn(),
            findOneAndUpdate: jest.fn(),
            findOneAndDelete: jest.fn(),
          },
        },
        {
          provide: COMMENT_MODEL,
          useValue: {
            new: jest.fn(),
            constructor: jest.fn(),
            find: jest.fn(),
            findOne: jest.fn(),
            updateOne: jest.fn(),
            deleteOne: jest.fn(),
            update: jest.fn(),
            create: jest.fn(),
            remove: jest.fn(),
            exec: jest.fn(),
          },
        },
        {
          provide: REQUEST,
          useValue: {
            user: {
              id: 'dummyId',
            },
          },
        },
      ],
    }).compile();

    service = await module.resolve<PostService>(PostService);
    model = module.get<Model<Post>>(POST_MODEL);
    commentModel = module.get<Model<Comment>>(COMMENT_MODEL);
  });
    
//...

Run the application

Now we have done the clean work, run the application to make sure it works as expected.

> npm run start

Use curl to test the endpoints provided in the application.


$ curl http://localhost:3000/auth/login -d "{\"username\":\"hantsy\",\"password\":\"password\"}" -H "Content-Type:application/json" 

{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cG4iOiJoYW50c3kiLCJzdWIiOiI1ZWYwYjdkNTRkMDY3MzIxMTQxODQ1ZjYiLCJlbWFpbCI6ImhhbnRzeUBleGFtcGxlLmNvbSIsInJvbGVzIjpbIlVTRVIiXSwiaWF0IjoxNTkyODM0MDE3LCJleHAiOjE1OTI4Mzc2MTd9.Jx53KIWHgyPADhLr-LhjW-iu1e8hD650e9nduGgJ8Bw"}

$ curl -X POST http://localhost:3000/posts -d "{\"title\":\"my title\",\"content\":\"my content\"}" -H "Content-Type:application/json" -H "Authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cG4iOiJoYW50c3kiLCJzdWIiOiI1ZWYwYjdkNTRkMDY3MzIxMTQxODQ1ZjYiLCJlbWFpbCI6ImhhbnRzeUBleGFtcGxlLmNvbSIsInJvbGVzIjpbIlVTRVIiXSwiaWF0IjoxNTkyODM0MDE3LCJleHAiOjE1OTI4Mzc2MTd9.Jx53KIWHgyPADhLr-LhjW-iu1e8hD650e9nduGgJ8Bw"

{"_id":"5ef0b7fe4d067321141845fc","title":"my title","content":"my content","createdBy":"5ef0b7d54d067321141845f6","createdAt":"2020-06-22T13:54:06.873Z","updatedAt":"2020-06-22T13:54:06.873Z","__v":0}

$ curl -X POST http://localhost:3000/posts/5ef0b7fe4d067321141845fc/comments -d "{\"content\":\"my content\"}" -H "Content-Type:application/json" -H "Authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cG4iOiJoYW50c3kiLCJzdWIiOiI1ZWYwYjdkNTRkMDY3MzIxMTQxODQ1ZjYiLCJlbWFpbCI6ImhhbnRzeUBleGFtcGxlLmNvbSIsInJvbGVzIjpbIlVTRVIiXSwiaWF0IjoxNTkyODM0MDE3LCJleHAiOjE1OTI4Mzc2MTd9.Jx53KIWHgyPADhLr-LhjW-iu1e8hD650e9nduGgJ8Bw"

{"_id":"5ef0b8414d067321141845fd","post":"5ef0b7fe4d067321141845fc","content":"my content","createdBy":"5ef0b7d54d067321141845f6","createdAt":"2020-06-22T13:55:13.822Z","updatedAt":"2020-06-22T13:55:13.822Z","__v":0}

$ curl http://localhost:3000/posts/5ef0b7fe4d067321141845fc/comments
[{"_id":"5ef0b8414d067321141845fd","content":"my content","createdBy":"5ef0b7d54d067321141845f6","createdAt":"2020-06-22T13:55:13.822Z","updatedAt":"2020-06-22T13:55:13.822Z","__v":0}]

One last thing

After cleaning up the codes, we do not need the @nestjs/mongoose dependency, let's remove it.

npm uninstall --save @nestjs/mongoose

Grab the source codes from my github, switch to branch feat/model.

Comments