preface

Recently, I fell in love with the framework of Nest.js, and made a nest-Todo project while learning.

Yes, it’s a Todo List App with an ugly UI. I don’t know why, but I’m starting to like this primitive UI style. It looks good without CSS.

Although the skin is ugly, the project contains a lot of knowledge points in the Nest.js document (except for GraphQL and microservices, which are usually not used much). I basically want to realize the requirements:

why

Why did you do this project? I read a lot of articles and blogs on the market, and many of them are superficial. It is too easy to write a CRUD, and a line of Nest G Resource will fix it. So, I wanted to implement a large and complete Nest. Js Demo.

In addition, this Demo will serve as a demonstration for many front ends that need to get started right away. While nest.js is well-documented, it can be a bit overwhelming if you’re doing a bit of heavy work, and you need to try a lot of things. That’s when Nest-Todo can step up and say, “You can’t just copy me. I can Work.”

The front end

The React front end is mainly implemented with only 0.0000001% style, almost all JS logic, and 100% TypeScript type prompts, which can be learned and watched.

Since the project is mainly back-end, there are only these front-end things:

The back-end

There’s a lot of stuff on the back end, mainly nest.js, and a lot of modules:

Here are a few examples of modules that I think are important. Of course, there are some code snippets below. For more details, check out Github’s Nest-Todo.

Todo module

The most basic add, delete, change, check. I’m sure many of you have seen this in some blogs or articles.

TodoController is responsible for implementing routing:

@ApiTags('To-do list')
@ApiBearerAuth(a)@Controller('todo')
export class TodoController {
  constructor(private readonly todoService: TodoService) {}

  @Post(a)async create(
    @Request() request,
    @Body() createTodoDto: CreateTodoDto,
  ): Promise<Todo> {
    return this.todoService.create(request.user.id, createTodoDto);
  }

  @Get(a)async findAll(@Request() request): Promise<Todo[]> {
    const { id, is_admin } = request.user;

    if (is_admin === 1) {
      return this.todoService.findAll();
    } else {
      return this.todoService.findAllByUserId(id); }}@Get(':id')
  async findOne(@Param('id', ParseIntPipe) id: number) :Promise<Todo> {
    return this.todoService.findOne(id);
  }

  @Patch(':id')
  async update(
    @Param('id', ParseIntPipe) id: number.@Body() updateTodoDto: UpdateTodoDto,
  ) {
    await this.todoService.update(id, updateTodoDto);
    return updateTodoDto;
  }

  @Delete(':id')
  async remove(@Param('id', ParseIntPipe) id: number) {
    await this.todoService.remove(id);
    return{ id }; }}Copy the code

TodoService implements the lower-level business logic, which is to add, delete, change, and query from the database:

@Injectable(a)export class TodoService {
  constructor(
    private todoRepository: TodoRepository,
    private userRepository: UserRepository,
  ) {}

  async create(userId: number.createTodoDto: CreateTodoDto): Promise<Todo> {
    const user = await this.userRepository.findOne(userId);
    const { title, description, media } = createTodoDto;

    const todo = new Todo();
    todo.title = title;
    todo.description = description;
    todo.status = createTodoDto.status || TodoStatus.TODO;
    todo.media = media;
    todo.author = user;

    return this.todoRepository.save(todo);
  }

  async findAll(): Promise<Todo[]> {
    return this.todoRepository.find();
  }

  async findAllByUserId(userId: number) :Promise<Todo[]> {
    const user = await this.userRepository.findOne({
      relations: ['todos'].where: { id: userId },
    });

    return user ? user.todos : [];
  }

  async findOne(id: number) :Promise<Todo> {
    return this.todoRepository.findOne(id);
  }

  async update(id: number, updateTodoDto: UpdateTodoDto) {
    const { title, description, status, media } = updateTodoDto;

    return this.todoRepository.update(id, {
      title,
      description,
      status: status || TodoStatus.TODO,
      media: media || ' '}); }async remove(id: number) {
    return this.todoRepository.delete({ id, }); }}Copy the code

Unfortunately, this is the end of the article and blog, and the author may not want to continue. But I’m not going to stop here. This is just the beginning.

Database module

TodoService uses databases, so let’s talk about database modules. I’m using TypeORM + Mariadb, why not use mysql? Because I use M1 Mac, can not install mysql image, very painful.

To use TypeORM, you need to add this configuration to the AppModule; however, writing the configuration in plaintext is a sand sculpture; a better implementation would use the ConfigModule provided with Nest.js to read the configuration.

Env. In a company, there should be a configuration center where sensitive fields such as username and password are stored. ConfigModule is responsible for reading these configurations when the application is started.

Read configuration here using read. Env “implementation:

const loadConfig = () = > {
  const { env } = process;

  return {
    db: {
      database: env.TYPEORM_DATABASE,
      host: env.TYPEORM_HOST,
      port: parseInt(env.TYPEORM_PORT, 10) | |3306.username: env.TYPEORM_USERNAME,
      password: env.TYPEORM_PASSWORD,
    },
    redis: {
      host: env.REDIS_HOST,
      port: parseInt(env.REDIS_PORT) || 6379,}}; };Copy the code

Then use ConfigModule and TypeORMModule in AppModule:

const libModules = [
  ConfigModule.forRoot({
    load: [loadConfig],
    envFilePath: [DOCKER_ENV ? '.docker.env' : '.env'],
  }),
  ScheduleModule.forRoot(),
  TypeOrmModule.forRootAsync({
    imports: [ConfigModule],
    inject: [ConfigService],
    useFactory: (configService: ConfigService) = > {
      const { host, port, username, password, database } =
        configService.get('db');

      return {
        type: 'mariadb'./ / the env
        host,
        port,
        username,
        password,
        database,
        // entities
        entities: ['dist/**/*.entity{.ts,.js}']}; }}),];@Module({
  imports: [...libModules, ...businessModules],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
Copy the code

As a final step, the Todo business module injects the Repository for the data table. From there, TodoService can use the Repository to manipulate the database table:

@Module({
  imports: [
    TypeOrmModule.forFeature([TodoRepository, UserRepository]),
    UserModule,
  ],
  controllers: [TodoController],
  providers: [TodoService],
})
export class TodoModule {}
Copy the code

The database module is not finished…

In addition to connecting to the database, database migration and initialization is a point that many people often overlook.

Initialization, very simple, is a script:

const checkExist = async (userRepository: Repository<User>) => {
  console.log('Check if it is initialized... ');

  const userNum = await userRepository.count();
  const exist = userNum > 0;

  if (exist) {
    console.log(` existing${userNum}A piece of user data, no longer initialized. `);
    return true;
  }

  return false;
};

const seed = async() = > {console.log('Start inserting data... ');
  const connection = await createConnection(ormConfig);

  const userRepository = connection.getRepository<User>(User);

  const dataExist = await checkExist(userRepository);

  if (dataExist) {
    return;
  }

  const initUsers = getInitUsers();

  console.log('Generate initialization data... ');
  initUsers.forEach((user) = > {
    user.todos = lodash.range(3).map(getRandomTodo);
  });

  const users = lodash.range(10).map(() = > {
    const todos = lodash.range(3).map(getRandomTodo);
    return getRandomUser(todos);
  });

  const allUsers = [...initUsers, ...users];

  console.log('Insert initialization data... ');
  await userRepository.save(allUsers);

  console.log('Data initialization successful! ');
};

seed()
  .then(() = > process.exit(0))
  .catch((e) = > {
    console.error(e);
    process.exit(1);
  });
Copy the code

Of course, it is best to also provide the ability to reset the database:

const reset = async() = > {const connection = await createConnection(ormConfig);

  await connection.createQueryBuilder().delete().from(Todo).execute();
  await connection.createQueryBuilder().delete().from(User).execute();
};

reset()
  .then(() = > process.exit(0))
  .catch((e) = > {
    console.error(e);
    process.exit(1);
  });
Copy the code

In this way, the little white hand is not panic. Once the database is corrupted, a reset + seed operation will restore the database. Of course, this step is just for the data.

Database migration is required for database table structures. The exciting thing is that TypeORM already provides a very NB migration command:

// package.json
"db:seed": "ts-node scripts/db/seed.ts",
"db:reset": "ts-node scripts/db/reset.ts",
"migration:generate": "npm run build && npm run typeorm migration:generate -- -n",
"migration:run": "npm run build && npm run typeorm migration:run"
Copy the code

But where does TypeORM know the structure of a data table? That’s what an Entity does. Here’s a Todo Entity:

@Entity(a)export class Todo {
  @ApiProperty(a)@PrimaryGeneratedColumn(a)id: number; / / on the id

  @ApiProperty(a)@Column({ length: 500 })
  title: string; / / title

  @ApiProperty(a)@Column('text') description? :string; // Details

  @ApiProperty(a)@Column('int', { default: TodoStatus.TODO })
  status: TodoStatus; / / state

  @ApiProperty({ required: false })
  @Column('text') media? :string;

  @ManyToOne(() = > User, (user) = > user.todos)
  author: User;
}
Copy the code

Then add the configuration to.env:

# Type ORM proprietary variable https://typeorm.io/#/using-ormconfig # Production environment configure TYPEORM_CONNECTION= Mariadb TYPEORM_DATABASE=nest_todo in the container on the server TYPEORM_HOST = 127.0.0.1 TYPEORM_PORT = 3306 TYPEORM_USERNAME = root TYPEORM_PASSWORD = 123456 TYPEORM_ENTITIES=dist/**/*.entity{.ts,.js} TYPEORM_MIGRATIONS=dist/src/db/migrations/*.js TYPEORM_MIGRATIONS_DIR=src/db/migrationsCopy the code

With the above command, what other databases do I dare not delete? NPM run migration:run + NPM run db:seed

Upload the module

As you can see from the Demo above, Todo supports image uploads, so uploading is also required. Nest.js is a great example of a library built directly into multer:

@ApiTags('File upload')
@ApiBearerAuth(a)@Controller('upload')
export class UploadController {
  @Post('file')
  @UseInterceptors(FileInterceptor('file'))
  uploadFile(@UploadedFile() file: Express.Multer.File) {
    return {
      file: staticBaseUrl + file.originalname,
    };
  }

  @Post('files')
  @UseInterceptors(FileInterceptor('files'))
  uploadFiles(@UploadedFiles() files: Array<Express.Multer.File>) {
    return {
      files: files.map((f) = >staticBaseUrl + f.originalname), }; }}Copy the code

UploadModule UploadModule UploadModule UploadModule

@Module({
  imports: [
    MulterModule.register({
      storage: diskStorage({
        destination: path.join(__dirname, '.. /.. /upload_dist'),
        filename(req, file, cb) {
          cb(null, file.originalname); }})}),],controllers: [UploadController],
  providers: [UploadService],
})
export class UploadModule {}
Copy the code

Static resource module

First of all, it should be noted that the above upload should be uploaded to COS bucket or CDN, not to your own server, use your own server to manage files. The static resource module is only used here.

To get back to the theme, upload to /upload_dist, so our static resource will be a file under host:

const uploadDistDir = join(__dirname, '.. /.. / '.'upload_dist');

@Controller('static')
export class StaticController {
  @SkipJwtAuth(a)@Get(':subPath')
  render(@Param('subPath') subPath, @Res() res) {
    const filePath = join(uploadDistDir, subPath);
    returnres.sendFile(filePath); }}Copy the code
@Module({
  controllers: [StaticController],
})
export class StaticModule {}
Copy the code

Very easy ~

The login module

If you are careful, you must have noticed @SkipJWTAuth above. This is because I have global JWT authentication. Only request headers with Bearer tokens can access this interface, and @SkipJwtauth indicates that this interface does not require JWT authentication. Let’s take a look at how ordinary authentication is implemented.

First of all, you need to be familiar with the Strategy and verifyCallback concepts in Passport. Nex.js encapsulates the verifyCallback as the Strategy’s validate method. When writing valiate, you write verifyCallback:

@Injectable(a)export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(
    private moduleRef: ModuleRef,
    private reportLogger: ReportLogger,
  ) {
    super({ passReqToCallback: true });
    this.reportLogger.setContext('LocalStrategy');
  }

  async validate(
    request: Request,
    username: string.password: string,
  ): Promise<Omit<User, 'password'> > {const contextId = ContextIdFactory.getByRequest(request);

    // Now authService is a request-Scoped Provider
    const authService = await this.moduleRef.resolve(AuthService, contextId);

    const user = await authService.validateUser(username, password);

    if(! user) {this.reportLogger.error('Unable to log in, SB');
      throw new UnauthorizedException();
    }

    returnuser; }}Copy the code

The above is a policy that uses username + password to implement authentication. Of course, there are various authentication policies for normal services. To use this policy, Guard is required:

@Injectable(a)export class LocalAuthGuard extends AuthGuard('local') {}
Copy the code

Then place the Guard on top of the corresponding interface:

@ApiTags('Login Verification')
@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @ApiBody({ type: LoginDto })
  @SkipJwtAuth(a)@UseGuards(LocalAuthGuard)
  @Post('login')
  async login(@Request() req) {
    return this.authService.login(req.user); }}Copy the code

Similar to the Strategy of local, JWT also has corresponding Strategy:

@Injectable(a)export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private userService: UserService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false.secretOrKey: jwtConstants.secret,
    });
  }

  async validate(payload: any) {
    const existUser = this.userService.findOne(payload.sub);

    if(! existUser) {throw new UnauthorizedException();
    }

    return { ...payload, id: payload.sub }; }}Copy the code

In JwtGuard, permissions are controlled using canActive:

@Injectable(a)export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super(a); } canActivate( context: ExecutionContext, ):boolean | Promise<boolean> | Observable<boolean> {
    // Customize user authentication logic
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    // skip
    if (isPublic) return true;

    return super.canActivate(context);
  }

  handleRequest(err, user) {
    / / handle the info
    if(err || ! user) {throw err || new UnauthorizedException();
    }
    returnuser; }}Copy the code

Formatted output

Once you’ve written the interface, you have to format the output. My preferred format is:

{
  retcode: 0.message: "".data:... }Copy the code

We prefer not to repeat the above “formatted” data structure in the Controller. Nex.js provides Interceptor, which allows us to “spice things up” before pulling data to the front end:

export class TransformInterceptor<T>
  implements NestInterceptor<T.Response<T>>
{
  intercept(context: ExecutionContext, next: CallHandler<T>) {
    return next.handle().pipe(
      map((data) = > ({
        retcode: 0.message: 'OK', data, })), ); }}Copy the code

Then use it globally in the main.ts entry:

app.useGlobalInterceptors(
  new LogInterceptor(reportLogger),
  new TransformInterceptor(),
);
Copy the code

test

Once you write an interface, you’re bound to write tests. I’m sure most people don’t write tests, and they certainly don’t write tests themselves.

It is not “Jest” or “Cypress”, but an area that can be studied very deeply. The hard part is not “writing”, but “building”, and testing strategies.

Let’s start with the testing strategy. What should we test? What can go wrong? What should not be tested? I think these three questions are metaphysical questions, there is no correct answer, can only be judged according to their own projects. 100% coverage is not always good, depending on the cost of changing the code to be tested during the update iteration.

Let me start with the test principles for this project:

  • Database operation contingency as this test contentTypeORMEnsures that API calls are OK
  • Simple implementation contingency, such as a function of only one line, that also test P
  • I only test one module, because I am lazy, the rest of you can learn by watching the test of my module
  • My testing strategy may not be the right one, just that I have a better testing strategy in mind

The hardest part of TodoService testing was mocking the Repository of TypeOrm, which took me a whole day to figure out by myself. I’m sure no one has the patience to do this:

const { mockTodos, mockUsers } = createMockDB();

describe('TodoService'.() = > {
  let mockTodoRepository;
  let mockUserRepository;
  let service: TodoService;

  beforeEach(async () => {
    mockUserRepository = new MockUserRepository(mockUsers);
    mockTodoRepository = new MockTodoRepository(mockTodos);

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        TodoService,
        {
          provide: TodoRepository,
          useValue: mockTodoRepository,
        },
        {
          provide: UserRepository,
          useValue: mockUserRepository,
        },
      ],
    }).compile();

    service = module.get<TodoService>(TodoService);
  });

  it('create'.async () => {
    expect(service).toBeDefined();
    // Create todo
    const returnTodos = await service.create(99, {
      title: 'title99'.description: 'desc99'.status: TodoStatus.TODO,
    });
    // expect
    expect(returnTodos.title).toEqual('title99');
    expect(returnTodos.description).toEqual('desc99');
    expect(returnTodos.status).toEqual(TodoStatus.TODO);
  });

  it('findAll'.async () => {
    expect(service).toBeDefined();
    const returnTodos = await service.findAll();
    // expect
    expect(returnTodos).toEqual(mockTodos);
  });

  it('findAllByUserId'.async () => {
    expect(service).toBeDefined();
    // Return the first user directly
    jest.spyOn(mockUserRepository, 'findOne').mockImplementation(async() = > {return mockUsers[0];
    });
    // Find all toDos with userId 0
    const returnTodos = await service.findAllByUserId(0);
    const [firstTodo] = returnTodos;
    // expect
    expect(mockUserRepository.findOne).toBeCalled();
    expect(firstTodo.id).toEqual(0);
    expect(firstTodo.title).toEqual('todo1');
    expect(firstTodo.description).toEqual('desc1');
  });

  it('findOne'.async () => {
    expect(service).toBeDefined();
    // Find a todo
    const returnTodo = await service.findOne(0);
    // expect
    expect(returnTodo.id).toEqual(0);
    expect(returnTodo.title).toEqual('todo1');
    expect(returnTodo.description).toEqual('desc1');
  });

  it('update'.async () => {
    expect(service).toBeDefined();
    / / all todo
    const allTodos = await service.findAll();
    // Update a todo
    await service.update(0, {
      title: 'todo99'.description: 'desc99'});// expect
    const targetTodo = allTodos.find((todo) = > todo.id === 0);
    expect(targetTodo.id).toEqual(0);
    expect(targetTodo.title).toEqual('todo99');
    expect(targetTodo.description).toEqual('desc99');
  });

  it('remote'.async () => {
    expect(service).toBeDefined();
    / / delete todo
    await service.remove(0);
    // Get all toDos
    const allTodos = await service.findAll();
    // expect
    expect(allTodos.length).toEqual(1);
    expect(allTodos.find((todo) = > todo.id === 0)).toBeUndefined();
  });
});
Copy the code

For unit tests of TodoController, I found this class untestable because the functions inside it were too simple:

const { mockTodos, mockUsers } = createMockDB();

describe('TodoController'.() = > {
  let todoController: TodoController;
  let todoService: TodoService;
  let mockTodoRepository;
  let mockUserRepository;

  beforeEach(async () => {
    mockTodoRepository = new MockTodoRepository(mockTodos);
    mockUserRepository = new MockUserRepository(mockUsers);

    const app: TestingModule = await Test.createTestingModule({
      controllers: [TodoController],
      providers: [
        TodoService,
        {
          provide: TodoRepository,
          useValue: mockTodoRepository,
        },
        {
          provide: UserRepository,
          useValue: mockUserRepository,
        },
      ],
    }).compile();

    todoService = app.get<TodoService>(TodoService);
    todoController = app.get<TodoController>(TodoController);
  });

  describe('findAll'.() = > {
    const [firstTodo] = mockTodos;

    it('Ordinary users can only access their own Todo'.async () => {
      jest
        .spyOn(todoService, 'findAllByUserId')
        .mockImplementation(async() = > {return [firstTodo];
        });
      const todos = await todoController.findAll({
        user: { id: 0.is_admin: 0}}); expect(todos).toEqual([firstTodo]); }); it('Administrator can access all todo'.async () => {
      jest.spyOn(todoService, 'findAll').mockImplementation(async() = > {return mockTodos;
      });
      const todos = await todoController.findAll({
        user: { id: 0.is_admin: 1}}); expect(todos).toEqual(mockTodos); }); }); });Copy the code

Finally, there was the E2E test, with the difficulty of obtaining Bearer Token authentication, which also took me a day:

describe('TodoController (e2e)'.() = > {
  const typeOrmModule = TypeOrmModule.forRoot({
    type: 'mariadb'.database: 'nest_todo'.username: 'root'.password: '123456'.entities: [User, Todo],
  });
  let app: INestApplication;
  let bearerToken: string;
  let createdTodo: Todo;

  beforeAll(async (done) => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [TodoModule, AuthModule, typeOrmModule],
      providers: [TodoRepository, UserRepository],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
    // Generate the token of the test user
    request(app.getHttpServer())
      .post('/auth/login')
      .send({ username: 'user'.password: 'user' })
      .expect(201)
      .expect((res) = > {
        bearerToken = `Bearer ${res.body.token}`;
      })
      .end(done);
  });

  it('GET /todo'.(done) = > {
    return request(app.getHttpServer())
      .get('/todo')
      .set('Authorization', bearerToken)
      .expect(200)
      .expect((res) = > {
        expect(typeof res.body).toEqual('object');
        expect(res.body instanceof Array).toBeTruthy();
        expect(res.body.length >= 3).toBeTruthy();
      })
      .end(done);
  });

  it('POST /todo'.(done) = > {
    const newTodo: CreateTodoDto = {
      title: 'todo99'.description: 'desc99'.status: TodoStatus.TODO,
      media: ' '};return request(app.getHttpServer())
      .post('/todo')
      .set('Authorization', bearerToken)
      .send(newTodo)
      .expect(201)
      .expect((res) = > {
        createdTodo = res.body;
        expect(createdTodo.title).toEqual('todo99');
        expect(createdTodo.description).toEqual('desc99');
        expect(createdTodo.status).toEqual(TodoStatus.TODO);
      })
      .end(done);
  });

  it('PATCH /todo/:id'.(done) = > {
    const updatingTodo: UpdateTodoDto = {
      title: 'todo9999'.description: 'desc9999'};return request(app.getHttpServer())
      .patch(`/todo/${createdTodo.id}`)
      .set('Authorization', bearerToken)
      .send(updatingTodo)
      .expect(200)
      .expect((res) = > {
        expect(res.body.title).toEqual(updatingTodo.title);
        expect(res.body.description).toEqual(updatingTodo.description);
      })
      .end(done);
  });

  it('DELETE /todo/:id'.(done) = > {
    return request(app.getHttpServer())
      .delete(`/todo/${createdTodo.id}`)
      .set('Authorization', bearerToken)
      .expect(200)
      .expect((res) = > {
        expect(res.body.id).toEqual(createdTodo.id);
      })
      .end(done);
  });

  afterAll(async() = > {await app.close();
  });
});
Copy the code

Swagger

Swagger is a powerful document tool that can identify interface urls, incoming and outgoing parameters.

Add Swagger to main.ts:

const setupSwagger = (app) = > {
  const config = new DocumentBuilder()
    .addBearerAuth()
    .setTitle('To-do list')
    .setDescription('Nest-Todo API Documentation')
    .setVersion('1.0')
    .build();
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('docs', app, document, {
    swaggerOptions: {
      persistAuthorization: true,}}); };Copy the code

Json > nest-cli.json > Swagger > nest-cli.json

{
  "collection": "@nestjs/schematics"."sourceRoot": "src"."compilerOptions": {
    "plugins": ["@nestjs/swagger"]}}Copy the code

The last

There are so many more modules that I don’t think are that important, as long as you look at the documentation. It took me about a month to realize the above module after stepping on a lot of pits.

Could have been online for everyone to see an online Demo, but my domain name is still in the record, we first local Clone play.

If you’re interested in nest.js and want to learn about it, Clone my Nest-Todo project and try it out.