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 content
TypeORM
Ensures 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.