Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

create DTOs, BOs and DAOs for NestJs REST API

I would like to get into creating REST APIs with NestJs and I'm not sure how to setup scalable layer communication objects.

So from the docs on how to get started I come up with a UsersController dealing with the HTTP requests and responses, a UsersService dealing with the logic between the controller and the database accessor and the UsersRepository which is responsible for the database management.

I use the TypeORM package provided by NestJs so my database model would be

@Entity('User')
export class UserEntity extends BaseEntity {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  username: string;

  @Column()
  passwordHash: string;

  @Column()
  passwordSalt: string;
}

but as you might know this model has to be mapped to other models and vice versa because you don't want to send the password information back to the client. I will try to describe my API flow with a simple example:


Controllers

First I have a controller endpoint for GET /users/:id and POST /users.

  @Get(':id')
  findById(@Param() findByIdParamsDTO: FindByIdParamsDTO): Promise<UserDTO> {
    // find user by id and return it
  }

  @Post()
  create(@Body() createUserBodyDTO: CreateUserBodyDTO): Promise<UserDTO> {
    // create a new user and return it
  }

I setup the DTOs and want to validate the request first. I use the class-validator package provided by NestJs and created a folder called RequestDTOs. Finding something by id or deleting something by id via url parameters is reusable so I can put this into a shared folder for other resources like groups, documents, etc.

export class IdParamsDTO {
  @IsUUID()
  id: string;
}

The POST request is user specific

export class CreateUserBodyDTO {
  @IsString()
  @IsNotEmpty()
  username: string;

  @IsString()
  @IsNotEmpty()
  password: string;
}

Now the controller input gets validated before executing business logic. For the responses I created a folder called ResponseDTOs but currently it only contains the database user without its password information

export interface UserDTO {
  id: string;
  username: string;
}

Services

The service needs the bundled information from the params and the body.

  public async findById(findByIdBO: FindByIdBO): Promise<UserBO> {
    // ...
  }

  public async create(createBO: CreateBO): Promise<UserBO> {
    // ...
  }

The GET request only needs the ID, but maybe it's still better to create a BO because you might want to switch from string IDs to integers later. The "find by id" BO is reusable, I moved it to the shared directory

export interface IdBO {
  id: string;
}

For the user creation I created the folder RequestBOs

export interface CreateBO {
  username: string;
  password: string;
}

Now for the ResponseBOs the result would be

export interface UserBO {
  id: string;
  username: string;
}

and as you will notice this is the same like the UserDTO. So one of them seems to be redundant?


Repositories

Lastly I setup the DAOs for the repositories. I could use the auto-generated user repository and would deal with my database model I mentioned above. But then I would have to deal with it within my service business logic. When creating a user I would have to do it within the service and only call the usermodel.save function from the repository.

Otherwise I could create RequestDAOs

The shared one..

export interface IdDAO {
  id: string;
}

And the POST DAO

export interface CreateDAO {
  username: string;
  password: string;
}

With that I could create a database user within my repository and map database responses with ResponseDAOs but this would always be the whole database user without the password information. Seems to generate a big overhead again.


I would like to know if my approach using 3 request and 3 response interfaces is way too much and can be simplified. But I would like to keep a flexible layer because I think those layers should be highly independent... On the other hand there would be a huge amount of models out there.

Thanks in advance!

like image 772
Question3r Avatar asked Dec 31 '19 18:12

Question3r


1 Answers

I handle this by having a single class to represent a User (internally and externally) with the class-transformer library (recommended by NestJs) to handle the differences between the exposed user and the internal user without defining two classes.

Here's an example using your user model:

Defining the User Class

Since this user class is saved to the database, I usually create a base class for all the fields that every database object expects to have. Let's say:

export class BaseDBObject {
  // this will expose the _id field as a string
  // and will change the attribute name to `id`
  @Expose({ name: 'id' })
  @Transform(value => value && value.toString())
  @IsOptional()
  // tslint:disable-next-line: variable-name
  _id: any;

  @Exclude()
  @IsOptional()
  // tslint:disable-next-line: variable-name
  _v: any;

  toJSON() {
    return classToPlain(this);
  }

  toString() {
    return JSON.stringify(this.toJSON());
  }
}

Next, our user will expend this basic class:

@Exclude()
export class User extends BaseDBObject {
  @Expose()
  username: string;

  password: string;

  constructor(partial: Partial<User> = {}) {
    super();
    Object.assign(this, partial);
  }
}

I'm using a few decorators here from the class-transformer library to change this internal user (with all the database fields intact) when we expose the class outside of our server.

  • @Expose - will expose the attribute if the class-default is to exclude
  • @Exclude - will exclude the property if the class-default is to expose
  • @Transform - changes the attribute name when 'exporting'

This means that after running the classToPlain function from class-transformer, all the rules we defined on the given class will be applied.

Controllers

NestJs have a decorator you add to make sure classes you return from controller endpoints will use the classToPlain function to transform the object, returning the result object with all the private fields omitted and transformations (like changing _id to id)

@Get(':id')
@UseInterceptors(ClassSerializerInterceptor)
async findById(@Param('id') id: string): Promise<User> {
  return await this.usersService.find(id);
}

@Post()
@UseInterceptors(ClassSerializerInterceptor)
async create(@Body() createUserBody: CreateUserBodyDTO): Promise<User> {
  // create a new user from the createUserDto
  const userToCreate = new User(createUserBody);

  return await this.usersService.create(userToCreate);
}

Services

@Injectable()
export class UsersService {
  constructor(@InjectModel('User') private readonly userModel: Model<IUser>) { }

  async create(createCatDto: User): Promise<User> {
    const userToCreate = new User(createCatDto);
    const createdUser = await this.userModel.create(userToCreate);

    if (createdUser) {
      return new User(createdUser.toJSON());
    }
  }

  async findAll(): Promise<User[]> {
    const allUsers = await this.userModel.find().exec();
    return allUsers.map((user) => new User(user.toJSON()));
  }

  async find(_id: string): Promise<User> {
    const foundUser = await this.userModel.findOne({ _id }).exec();
    if (foundUser) {
      return new User(foundUser.toJSON());
    }
  }
}

Because internally we always use the User class, I convert the data returned from the database to a User class instance.

I'm using @nestjs/mongoose, but basically after retrieving the user from the db, everything is the same for both mongoose and TypeORM.

Caveats

With @nestjs/mongoose, I can't avoid creating IUser interface to pass to the mongo Model class since it expects something that extends the mongodb Document

export interface IUser extends mongoose.Document {
  username: string;

  password: string;
}

When GETting a user, the API will return this transformed JSON:

{
    "id": "5e1452f93794e82db588898e",
    "username": "username"
}

Here's the code for this example in a GitHub repository.

Update

If you want to see an example using typegoose to eliminate the interface as well (based on this blog post), take a look here for a model, and here for the base model

like image 50
Thatkookooguy Avatar answered Nov 07 '22 09:11

Thatkookooguy