Spring Boot REST API

Kiến trúc ứng dụng Fullstack với Spring Boot và JavaScript

Giới thiệu

Trong bài viết này, chúng ta sẽ xây dựng một Todo List Application hoàn chỉnh với:

  • Backend: Java Spring Boot (REST API)
  • Frontend: HTML + CSS + JavaScript (Vanilla)
  • Database: H2 (in-memory database)

Bước 1: Tạo Spring Boot Project

Spring Boot

Spring Boot - Framework Java mạnh mẽ nhất

1.1. Sử dụng Spring Initializr

Truy cập: https://start.spring.io/

Cấu hình:

  • Project: Maven
  • Language: Java
  • Spring Boot: 3.2.x
  • Group: com.xuanduong
  • Artifact: todoapp
  • Packaging: Jar
  • Java: 17 hoặc 21

Dependencies:

  • Spring Web
  • Spring Data JPA
  • H2 Database
  • Lombok (optional)

1.2. Cấu trúc project

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
todoapp/
├── src/
│   ├── main/
│   │   ├── java/com/xuanduong/todoapp/
│   │   │   ├── TodoappApplication.java
│   │   │   ├── controller/
│   │   │   │   └── TodoController.java
│   │   │   ├── model/
│   │   │   │   └── Todo.java
│   │   │   ├── repository/
│   │   │   │   └── TodoRepository.java
│   │   │   └── service/
│   │   │       └── TodoService.java
│   │   └── resources/
│   │       ├── application.properties
│   │       └── static/
│   │           ├── index.html
│   │           ├── style.css
│   │           └── app.js
│   └── test/
├── pom.xml
└── README.md

Bước 2: Backend - Spring Boot API

2.1. Cấu hình application.properties

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# application.properties
spring.application.name=todoapp

# H2 Database
spring.datasource.url=jdbc:h2:mem:tododb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# JPA
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

# H2 Console (for development)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

# Server port
server.port=8080

2.2. Entity Class (Model)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// Todo.java
package com.xuanduong.todoapp.model;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "todos")
public class Todo {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private String title;
    
    private String description;
    
    @Column(nullable = false)
    private Boolean completed = false;
    
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt = LocalDateTime.now();
    
    // Constructors
    public Todo() {}
    
    public Todo(String title, String description) {
        this.title = title;
        this.description = description;
    }
    
    // Getters and Setters
    public Long getId() {
        return id;
    }
    
    public void setId(Long id) {
        this.id = id;
    }
    
    public String getTitle() {
        return title;
    }
    
    public void setTitle(String title) {
        this.title = title;
    }
    
    public String getDescription() {
        return description;
    }
    
    public void setDescription(String description) {
        this.description = description;
    }
    
    public Boolean getCompleted() {
        return completed;
    }
    
    public void setCompleted(Boolean completed) {
        this.completed = completed;
    }
    
    public LocalDateTime getCreatedAt() {
        return createdAt;
    }
}

2.3. Repository Interface

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// TodoRepository.java
package com.xuanduong.todoapp.repository;

import com.xuanduong.todoapp.model.Todo;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;

@Repository
public interface TodoRepository extends JpaRepository<Todo, Long> {
    // Custom queries
    List<Todo> findByCompleted(Boolean completed);
    List<Todo> findByTitleContainingIgnoreCase(String title);
}

2.4. Service Class

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// TodoService.java
package com.xuanduong.todoapp.service;

import com.xuanduong.todoapp.model.Todo;
import com.xuanduong.todoapp.repository.TodoRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;

@Service
public class TodoService {
    
    @Autowired
    private TodoRepository todoRepository;
    
    // Get all todos
    public List<Todo> getAllTodos() {
        return todoRepository.findAll();
    }
    
    // Get todo by ID
    public Optional<Todo> getTodoById(Long id) {
        return todoRepository.findById(id);
    }
    
    // Create todo
    public Todo createTodo(Todo todo) {
        return todoRepository.save(todo);
    }
    
    // Update todo
    public Todo updateTodo(Long id, Todo todoDetails) {
        Todo todo = todoRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("Todo not found with id: " + id));
        
        todo.setTitle(todoDetails.getTitle());
        todo.setDescription(todoDetails.getDescription());
        todo.setCompleted(todoDetails.getCompleted());
        
        return todoRepository.save(todo);
    }
    
    // Toggle completed status
    public Todo toggleCompleted(Long id) {
        Todo todo = todoRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("Todo not found with id: " + id));
        
        todo.setCompleted(!todo.getCompleted());
        return todoRepository.save(todo);
    }
    
    // Delete todo
    public void deleteTodo(Long id) {
        todoRepository.deleteById(id);
    }
    
    // Get completed todos
    public List<Todo> getCompletedTodos() {
        return todoRepository.findByCompleted(true);
    }
    
    // Get pending todos
    public List<Todo> getPendingTodos() {
        return todoRepository.findByCompleted(false);
    }
}

2.5. REST Controller

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// TodoController.java
package com.xuanduong.todoapp.controller;

import com.xuanduong.todoapp.model.Todo;
import com.xuanduong.todoapp.service.TodoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/todos")
@CrossOrigin(origins = "*")  // Allow CORS for frontend
public class TodoController {
    
    @Autowired
    private TodoService todoService;
    
    // GET /api/todos - Get all todos
    @GetMapping
    public ResponseEntity<List<Todo>> getAllTodos() {
        List<Todo> todos = todoService.getAllTodos();
        return ResponseEntity.ok(todos);
    }
    
    // GET /api/todos/{id} - Get todo by ID
    @GetMapping("/{id}")
    public ResponseEntity<Todo> getTodoById(@PathVariable Long id) {
        return todoService.getTodoById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
    
    // POST /api/todos - Create new todo
    @PostMapping
    public ResponseEntity<Todo> createTodo(@RequestBody Todo todo) {
        Todo created = todoService.createTodo(todo);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }
    
    // PUT /api/todos/{id} - Update todo
    @PutMapping("/{id}")
    public ResponseEntity<Todo> updateTodo(@PathVariable Long id, @RequestBody Todo todo) {
        try {
            Todo updated = todoService.updateTodo(id, todo);
            return ResponseEntity.ok(updated);
        } catch (RuntimeException e) {
            return ResponseEntity.notFound().build();
        }
    }
    
    // PATCH /api/todos/{id}/toggle - Toggle completed status
    @PatchMapping("/{id}/toggle")
    public ResponseEntity<Todo> toggleCompleted(@PathVariable Long id) {
        try {
            Todo updated = todoService.toggleCompleted(id);
            return ResponseEntity.ok(updated);
        } catch (RuntimeException e) {
            return ResponseEntity.notFound().build();
        }
    }
    
    // DELETE /api/todos/{id} - Delete todo
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteTodo(@PathVariable Long id) {
        todoService.deleteTodo(id);
        return ResponseEntity.noContent().build();
    }
    
    // GET /api/todos/completed - Get completed todos
    @GetMapping("/completed")
    public ResponseEntity<List<Todo>> getCompletedTodos() {
        List<Todo> todos = todoService.getCompletedTodos();
        return ResponseEntity.ok(todos);
    }
    
    // GET /api/todos/pending - Get pending todos
    @GetMapping("/pending")
    public ResponseEntity<List<Todo>> getPendingTodos() {
        List<Todo> todos = todoService.getPendingTodos();
        return ResponseEntity.ok(todos);
    }
}

Bước 3: Frontend - HTML + CSS + JavaScript

3.1. HTML (index.html)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<!-- src/main/resources/static/index.html -->
<!DOCTYPE html>
<html lang="vi">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Todo App - Nguyễn Võ Xuân Dương</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <header>
            <h1>📝 Todo List</h1>
            <p class="subtitle">by Nguyễn Võ Xuân Dương</p>
        </header>

        <div class="input-section">
            <input type="text" id="todoTitle" placeholder="Tiêu đề..." maxlength="100">
            <textarea id="todoDescription" placeholder="Mô tả (optional)..." rows="2"></textarea>
            <button id="addBtn" class="btn btn-primary">➕ Thêm Todo</button>
        </div>

        <div class="filter-section">
            <button class="filter-btn active" data-filter="all">Tất cả</button>
            <button class="filter-btn" data-filter="pending">Chưa xong</button>
            <button class="filter-btn" data-filter="completed">Đã xong</button>
        </div>

        <div id="todoList" class="todo-list">
            <p class="empty-message">Chưa có công việc nào. Hãy thêm mới!</p>
        </div>
    </div>

    <script src="app.js"></script>
</body>
</html>

3.2. CSS (style.css)

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
/* src/main/resources/static/style.css */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    min-height: 100vh;
    padding: 20px;
}

.container {
    max-width: 800px;
    margin: 0 auto;
    background: white;
    border-radius: 20px;
    padding: 30px;
    box-shadow: 0 10px 50px rgba(0, 0, 0, 0.3);
}

header {
    text-align: center;
    margin-bottom: 30px;
}

h1 {
    color: #667eea;
    font-size: 2.5rem;
    margin-bottom: 5px;
}

.subtitle {
    color: #666;
    font-size: 0.9rem;
}

.input-section {
    margin-bottom: 20px;
}

input, textarea {
    width: 100%;
    padding: 12px;
    border: 2px solid #e0e0e0;
    border-radius: 8px;
    font-size: 1rem;
    margin-bottom: 10px;
    transition: border-color 0.3s;
}

input:focus, textarea:focus {
    outline: none;
    border-color: #667eea;
}

.btn {
    padding: 12px 24px;
    border: none;
    border-radius: 8px;
    font-size: 1rem;
    cursor: pointer;
    transition: all 0.3s;
}

.btn-primary {
    background: #667eea;
    color: white;
    width: 100%;
}

.btn-primary:hover {
    background: #5568d3;
    transform: translateY(-2px);
    box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
}

.filter-section {
    display: flex;
    gap: 10px;
    margin-bottom: 20px;
}

.filter-btn {
    flex: 1;
    padding: 10px;
    background: #f5f5f5;
    border: 2px solid transparent;
    border-radius: 8px;
    cursor: pointer;
    transition: all 0.3s;
}

.filter-btn:hover {
    background: #e0e0e0;
}

.filter-btn.active {
    background: #667eea;
    color: white;
    border-color: #667eea;
}

.todo-list {
    display: flex;
    flex-direction: column;
    gap: 15px;
}

.todo-item {
    background: #f9f9f9;
    padding: 15px;
    border-radius: 10px;
    border-left: 4px solid #667eea;
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    transition: all 0.3s;
}

.todo-item:hover {
    transform: translateX(5px);
    box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1);
}

.todo-item.completed {
    opacity: 0.6;
    border-left-color: #4caf50;
}

.todo-content {
    flex: 1;
    cursor: pointer;
}

.todo-title {
    font-size: 1.1rem;
    font-weight: 600;
    color: #333;
    margin-bottom: 5px;
}

.todo-item.completed .todo-title {
    text-decoration: line-through;
    color: #999;
}

.todo-description {
    font-size: 0.9rem;
    color: #666;
    margin-top: 5px;
}

.todo-date {
    font-size: 0.8rem;
    color: #999;
    margin-top: 5px;
}

.todo-actions {
    display: flex;
    gap: 5px;
}

.todo-actions button {
    padding: 8px 12px;
    border: none;
    border-radius: 6px;
    cursor: pointer;
    font-size: 0.9rem;
    transition: all 0.3s;
}

.btn-delete {
    background: #f44336;
    color: white;
}

.btn-delete:hover {
    background: #d32f2f;
}

.empty-message {
    text-align: center;
    color: #999;
    padding: 40px;
    font-style: italic;
}

/* Responsive */
@media (max-width: 600px) {
    .container {
        padding: 20px;
    }
    
    h1 {
        font-size: 2rem;
    }
    
    .filter-section {
        flex-direction: column;
    }
}

3.3. JavaScript (app.js)

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
// src/main/resources/static/app.js
const API_URL = 'http://localhost:8080/api/todos';

// DOM Elements
const todoTitle = document.getElementById('todoTitle');
const todoDescription = document.getElementById('todoDescription');
const addBtn = document.getElementById('addBtn');
const todoList = document.getElementById('todoList');
const filterBtns = document.querySelectorAll('.filter-btn');

let currentFilter = 'all';
let todos = [];

// Initialize
document.addEventListener('DOMContentLoaded', () => {
    loadTodos();
    setupEventListeners();
});

// Setup Event Listeners
function setupEventListeners() {
    addBtn.addEventListener('click', addTodo);
    
    todoTitle.addEventListener('keypress', (e) => {
        if (e.key === 'Enter') addTodo();
    });
    
    filterBtns.forEach(btn => {
        btn.addEventListener('click', () => {
            filterBtns.forEach(b => b.classList.remove('active'));
            btn.classList.add('active');
            currentFilter = btn.dataset.filter;
            renderTodos();
        });
    });
}

// Load todos from API
async function loadTodos() {
    try {
        const response = await fetch(API_URL);
        if (!response.ok) throw new Error('Failed to fetch todos');
        todos = await response.json();
        renderTodos();
    } catch (error) {
        console.error('Error loading todos:', error);
        showError('Không thể tải dữ liệu. Vui lòng thử lại!');
    }
}

// Add new todo
async function addTodo() {
    const title = todoTitle.value.trim();
    
    if (!title) {
        alert('Vui lòng nhập tiêu đề!');
        return;
    }
    
    const newTodo = {
        title: title,
        description: todoDescription.value.trim(),
        completed: false
    };
    
    try {
        const response = await fetch(API_URL, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(newTodo)
        });
        
        if (!response.ok) throw new Error('Failed to create todo');
        
        const created = await response.json();
        todos.push(created);
        
        // Clear inputs
        todoTitle.value = '';
        todoDescription.value = '';
        todoTitle.focus();
        
        renderTodos();
    } catch (error) {
        console.error('Error creating todo:', error);
        showError('Không thể tạo todo. Vui lòng thử lại!');
    }
}

// Toggle todo completed status
async function toggleTodo(id) {
    try {
        const response = await fetch(`${API_URL}/${id}/toggle`, {
            method: 'PATCH'
        });
        
        if (!response.ok) throw new Error('Failed to toggle todo');
        
        const updated = await response.json();
        const index = todos.findIndex(t => t.id === id);
        if (index !== -1) {
            todos[index] = updated;
        }
        
        renderTodos();
    } catch (error) {
        console.error('Error toggling todo:', error);
        showError('Không thể cập nhật todo. Vui lòng thử lại!');
    }
}

// Delete todo
async function deleteTodo(id) {
    if (!confirm('Bạn có chắc muốn xóa todo này?')) return;
    
    try {
        const response = await fetch(`${API_URL}/${id}`, {
            method: 'DELETE'
        });
        
        if (!response.ok) throw new Error('Failed to delete todo');
        
        todos = todos.filter(t => t.id !== id);
        renderTodos();
    } catch (error) {
        console.error('Error deleting todo:', error);
        showError('Không thể xóa todo. Vui lòng thử lại!');
    }
}

// Render todos
function renderTodos() {
    let filteredTodos = todos;
    
    if (currentFilter === 'completed') {
        filteredTodos = todos.filter(t => t.completed);
    } else if (currentFilter === 'pending') {
        filteredTodos = todos.filter(t => !t.completed);
    }
    
    if (filteredTodos.length === 0) {
        todoList.innerHTML = '<p class="empty-message">Không có công việc nào!</p>';
        return;
    }
    
    todoList.innerHTML = filteredTodos.map(todo => `
        <div class="todo-item ${todo.completed ? 'completed' : ''}">
            <div class="todo-content" onclick="toggleTodo(${todo.id})">
                <div class="todo-title">${escapeHtml(todo.title)}</div>
                ${todo.description ? `<div class="todo-description">${escapeHtml(todo.description)}</div>` : ''}
                <div class="todo-date">${formatDate(todo.createdAt)}</div>
            </div>
            <div class="todo-actions">
                <button class="btn-delete" onclick="deleteTodo(${todo.id})">🗑️</button>
            </div>
        </div>
    `).join('');
}

// Utility functions
function escapeHtml(text) {
    const div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
}

function formatDate(dateString) {
    const date = new Date(dateString);
    return date.toLocaleDateString('vi-VN', {
        day: '2-digit',
        month: '2-digit',
        year: 'numeric',
        hour: '2-digit',
        minute: '2-digit'
    });
}

function showError(message) {
    alert(message);
}

Bước 4: Chạy ứng dụng

4.1. Chạy Backend

1
2
3
4
5
# Trong thư mục root của project
mvn spring-boot:run

# Hoặc nếu dùng Gradle
./gradlew bootRun

Backend sẽ chạy tại: http://localhost:8080

4.2. Test API với Browser hoặc Postman

Truy cập:

  • Frontend: http://localhost:8080/
  • H2 Console: http://localhost:8080/h2-console
  • API: http://localhost:8080/api/todos

4.3. Test endpoints

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# GET - Lấy tất cả todos
curl http://localhost:8080/api/todos

# POST - Tạo todo mới
curl -X POST http://localhost:8080/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Học Spring Boot","description":"Hoàn thành bài tutorial"}'

# PATCH - Toggle completed
curl -X PATCH http://localhost:8080/api/todos/1/toggle

# DELETE - Xóa todo
curl -X DELETE http://localhost:8080/api/todos/1

Kết luận

Chúc mừng! Bạn đã hoàn thành ứng dụng web fullstack đầu tiên với:

  • Backend: Spring Boot REST API
  • Frontend: HTML + CSS + JavaScript
  • Database: H2 (JPA)
  • CRUD Operations: Create, Read, Update, Delete
  • Responsive Design: Mobile-friendly

Bước tiếp theo:

  1. Deploy lên Heroku/Railway
  2. Thêm authentication (Spring Security + JWT)
  3. Tích hợp React/Vue thay vì Vanilla JS
  4. Chuyển sang PostgreSQL/MySQL
  5. Thêm unit tests và integration tests

Bạn đã tạo app thành công chưa? Chia sẻ screenshot của bạn! 🎉