Trong bài viết này, mình sẽ hướng dẫn các bạn xây dựng REST API với Typescript, Express và MySQL. Nếu bạn chưa biết về các syntax cơ bản, các bạn có thể đọc tại đây.
1. Phân tích và thiết lập User Story cơ bản
Bài viết này mình sẽ tập trung vào phần backend, nên mình sẽ để hình phần giao diện đơn giản về TODO List phía dưới các bạn có thể tham khảo qua nhé.
Mình khuyên các bạn nên xây dựng User Story chi tiết, vì nó sẽ giúp bạn tránh được việc sẽ bị sót các tính năng, kiểm soát các tính năng cần cho dự án.
- Service không đăng nhập phân biệt người dùng với nhau.
- Người dùng có thể tạo mới một TODO Item.
- Người dùng có thể xem được toàn bộ các TODO Item (thường sẽ có phân trang), hiển thị tổng TODO Item đã hoàn thành / tổng TODO Item.
- Người dùng có thể edit mark done bất kỳ một TODO Item.
- Người dùng có thể xoá một TODO Item.
Các bạn theo mảng backend sẽ cảm thấy hơi lạ với việc xây dựng User Story như thế này. Vì các backend developer trong các công ty không cần phải làm việc này. Mình nêu đến để các bạn hiểu rõ quy trình từ phân tích đến triển khai.
2. Thiết kế cơ sở dữ liệu từ User Story
Dựa trên User Story phía trên mình vừa xây dựng, mình có 3 danh từ được in đậm: "Service", "Người dùng", "TODO Item". Và vì trong ví dụ này, không yêu cầu đăng nhập nên "Service" và "Người dùng" có thể không cần quan tâm đến.
Với danh từ "TODO Item" mình sẽ cần chứa title và status. Dựa vào đó ta sẽ xây dựng cơ sở dữ liệu như sau:
- Id (Primary Key, Auto Increment): định danh (Identifier) cho từng TODO Item, vì là PK nên sẽ không trùng lặp, không thể NULL.
- Title: tiêu đề cho TODO Item. Cột này chắc chắn sẽ chứa nội dung. Cụ thể trong MySQL thì nó là varchar.
- Status: trạng thái của TODO Item. Vì chỉ có 2 giá trị các bạn có thể dùng 0 và 1. Tuy nhiên mình vẫn thích dùng kiểu Enum để rõ ràng và dễ mở rộng về sau hơn.
- Created At: Thời gian Item được tạo trên hệ thống. Cột này chỉ là một tuỳ chọn thêm. Theo mình mỗi table nên có cột này để tiện quản lý về sau.
- Updated At: Thời gian Item được update lần cuối trên hệ thống. Cột này cũng dùng để quản lý thêm thôi.
Phần code tạo bảng trong MySQL như sau:
CREATE TABLE `todo_items` (
`id` int NOT NULL AUTO_INCREMENT,
`title` varchar(150) CHARACTER SET utf8 NOT NULL,
`status` enum('Doing','Finished') DEFAULT 'Doing',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` timestamp NOT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
Sau khi chạy thì database của mình sẽ như sau:
3. Thiết kế các REST API cho TODO List service
Mình thấy đây là bước cực kỳ quan trọng, nhưng mình thấy nhiều bạn thường bỏ qua. Nếu bạn chưa hiểu rõ về REST API nên được thiết kế thế nào thì xem lại bài này:
Mình sẽ thiết kế CRUD (Create-Read-Update-Delete) API như vầy:
- POST /v1/items tạo mới TODO Item với dữ liệu chỉ cần có title là đủ. Thuộc tính status nên để mặc định là "Doing". API này sẽ trả về ID của TODO Item sau khi tạo thành công. Ràng buộc đơn giản là "title không rỗng hoặc chỉ chứa toàn khoảng trắng" là ok.
- GET /v1/items lấy danh sách các TODO Items. Nếu có phân trang thì có thể dùng thêm query string ?page=1&limit=10. Một trang sẽ hiển thị tối đa 10 items. Mặc định page là 1 và limit là 10.
- PUT /v1/items/:id update tiêu đề hoặc trạng thái của một Item thông qua ID của nó. Vì API này chúng ta có thể truyền lên cả 2 thông tin hoặc chỉ một trong 2 nên các bạn có thể dùng method PATCH sẽ chuẩn chỉ hơn. Vì PUT thông dụng hơn cho các API update nên mình chọn trong ví dụ này.
- DELETE /v1/items/:id xoá một TODO Item thông qua ID của nó. Trong ví dụ này mình sẽ xoá luôn trong table. Trong thực tế, hầu hết tất cả trường hợp là không nên xoá mà chỉ chuyển đổi trạng thái deleted thôi.
- GET /v1/items/:id lấy toàn bộ thông tin chi tiết của một TODO Item thông qua ID của nó. Theo giao diện demo thì chúng ta không cần API này, tuy nhiên 200Lab để vào cho đủ bộ CRUD nha.
4. Xây dựng REST API với Typescript Express
Mình đã hoàn tất phần chuẩn bị ở các mục phía trên, tiến hành code thôi nào.
npm init -y
npm install express typescript ts-node mysql2 @types/node @types/express --save-dev
npx tsc --init
Đây là tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Nếu bạn chưa có MySQL thì bạn có thể dùng Docker để chạy container MySQL với câu lệnh sau:
docker run -d --name demo-mysql -p 3306:3306 -e MYSQL_ROOT_PASSWORD=my-root-pass -e MYSQL_DATABASE=todo_db mysql:8.3.1
Để dễ dàng hơn mình sẽ để code trong cùng một file để các bạn dễ theo dõi
src/index.ts
import express, { Request, Response } from 'express';
import mysql, { RowDataPacket, ResultSetHeader } from 'mysql2';
interface TodoItem extends RowDataPacket {
id: number;
title: string;
status: 'Doing' | 'Finished';
created_at: string;
updated_at: string;
}
interface CreateTodoItem {
title: string;
}
interface UpdateTodoItem {
title?: string;
status?: 'Doing' | 'Finished';
}
const db = mysql.createConnection({
host: 'localhost',
user: process.env.USER,
password: process.env.PASS,
database: process.env.DATABASE,
});
db.connect((err) => {
if (err) {
console.error('Error connect:', err.message);
} else {
console.log('Connected to MySQL');
}
});
const app = express();
app.use(express.json());
app.post('/v1/items', (req: Request<{}, {}, CreateTodoItem>, res: Response) => {
const { title } = req.body;
if (!title || !title.trim()) {
return res.status(400).json({ message: 'Title cannot be empty' });
}
const sql = 'INSERT INTO todo_items (title) VALUES (?)';
db.query<ResultSetHeader>(sql, [title], (err, result) => {
if (err) {
return res.status(500).json({ message: 'Database error', error: err });
}
return res.status(201).json({ id: result.insertId });
});
});
app.get('/v1/items', (req: Request, res: Response) => {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 10;
const offset = (page - 1) * limit;
const sql = 'SELECT * FROM todo_items LIMIT ? OFFSET ?';
db.query<TodoItem[]>(sql, [limit, offset], (err, results) => {
if (err) {
return res.status(500).json({ message: 'Database error', error: err });
}
return res.status(200).json(results);
});
});
app.put(
'/v1/items/:id',
(req: Request<{ id: string }, {}, UpdateTodoItem>, res: Response) => {
const { id } = req.params;
const { title, status } = req.body;
if (!title && !status) {
return res.status(400).json({ message: 'Nothing to update' });
}
const updates: any[] = [];
let sql = 'UPDATE todo_items SET ';
if (title) {
sql += 'title = ? ';
updates.push(title);
}
if (status) {
if (updates.length > 0) {
sql += ', ';
}
sql += 'status = ? ';
updates.push(status);
}
sql += 'WHERE id = ?';
updates.push(id);
db.query<ResultSetHeader>(sql, updates, (err, result) => {
if (err) {
return res.status(500).json({ message: 'Database error', error: err });
}
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Item not found' });
}
return res.status(200).json({ message: 'Item updated successfully' });
});
}
);
app.delete('/v1/items/:id', (req: Request<{ id: string }>, res: Response) => {
const { id } = req.params;
const sql = 'DELETE FROM todo_items WHERE id = ?';
db.query<ResultSetHeader>(sql, [id], (err, result) => {
if (err) {
return res.status(500).json({ message: 'Database error', error: err });
}
if (result.affectedRows === 0) {
// Không tìm thấy item với ID đã cung cấp
return res.status(404).json({ message: 'Item not found' });
}
return res.status(200).json({ message: 'Item deleted successfully' });
});
});
app.get('/v1/items/:id', (req: Request<{ id: string }>, res: Response) => {
const { id } = req.params;
const sql = 'SELECT * FROM todo_items WHERE id = ?';
db.query<TodoItem[]>(sql, [id], (err, result) => {
if (err) {
return res.status(500).json({ message: 'Database error', error: err });
}
if (result.length === 0) {
return res.status(404).json({ message: 'Item not found' });
}
return res.status(200).json(result[0]);
});
});
const PORT = 8080;
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
Cuối cùng, mình sẽ dùng POSTMAN để test API nhé!
- POST /v1/items
- GET /v1/items
- PUT /v1/items/:id
- DELETE /v1/items/:id
- GET /v1/items/:id
5. Kết luận
Qua bài viết này, bạn sẽ hiểu rõ và có thể tự mình hoàn tất được một REST API TODO List đơn giản với Typescript, vì đây là một ví dụ nên việc để code chỉ trong file index.ts không phải là best practice trong thực tế.
Nếu các bạn chưa tự tin, cảm thấy khó khăn khi học các kiến thức nâng cao với Typescript thì có thể tham khảo khóa học Typescript tại 200Lab nhé.
Một số bài có thể bạn sẽ quan tâm:
Bài viết liên quan
Hướng dẫn tích hợp Redux và React Query trong dự án React Vite
Nov 22, 2024 • 8 min read
Giới thiệu Kiến trúc Backend for Frontend (BFF)
Nov 16, 2024 • 10 min read
Flask là gì? Hướng dẫn tạo Ứng dụng Web với Flask
Nov 15, 2024 • 7 min read
Webhook là gì? So sánh Webhook và API
Nov 15, 2024 • 8 min read
Spring Boot là gì? Hướng dẫn Khởi tạo Project Spring Boot với Docker
Nov 14, 2024 • 6 min read
Two-Factor Authentication (2FA) là gì? Vì sao chỉ Mật khẩu thôi là chưa đủ?
Nov 13, 2024 • 7 min read