📡 REST API задач — CRUD + подготовка к JPA
Цель урока
Сделать полноценный CRUD для задач с правильными HTTP-статусами (201/204/404), обработкой ошибок, и подготовить Task к подключению JPA.
Теория · для собеса
1 HTTP-статусы — какие возвращать ▸
В Spring:
200 OK—ResponseEntity.ok(body)или просто вернутьbody201 Created—ResponseEntity.created(URI.create(\"/api/tasks/\" + id)).body(body)204 No Content—ResponseEntity.noContent().build()404 Not Found—ResponseEntity.notFound().build()или исключение →@ControllerAdvice400 Bad Request—MethodArgumentNotValidExceptionчерез@Valid(урок 8)
2 5 уровней зрелости REST API (Richardson) ▸
один endpoint, POST с action в теле (❌ антипаттерн)
каждый ресурс = URL (/api/tasks, /api/tasks/42)
GET / POST / PUT / DELETE правильно
в ответе ссылки на связанные ресурсы
управление состоянием через гиперссылки
3 Идемпотентность (глубже) ▸
GET,PUT,DELETE— идемпотентные (повторный вызов = тот же результат)POST— НЕ идемпотентный (каждый вызов создаёт новый ресурс)
Это важно для retry-логики на клиенте: если запрос ушёл, а ответ потерялся — можно повторить безопасно только идемпотентный.
🔁 Аналогия: идемпотентный запрос — как повторный набор номера: если абонент уже снял трубку, второй звонок скажет «занято». Неидемпотентный — как отправка SMS: каждое сообщение создаёт новое.
4 Location header при создании ▸
Когда создаёшь ресурс через POST, по стандарту HTTP нужно вернуть заголовок Location с URL нового ресурса.
@PostMapping
public ResponseEntity<Task> create(@RequestBody TaskRequest req) {
Task created = service.create(new Task(null, req.title(), req.description(), req.done()));
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(created);
}
Ответ:
HTTP/1.1 201
Location: http://localhost:8080/api/tasks/1
Content-Type: application/json
{"id":1,"title":"Read book","description":"...","done":false}
5 equals/hashCode — почему по полям ▸
Сейчас Task — record (иммутабельный), но для JPA нужен class с @Id. Сделаем гибрид: class с конструктором + геттеры, но с equals/hashCode по бизнес-полям.
Почему equals по бизнес-полям, а не по id?
- При создании (
id == null) два объекта с одинаковымиtitle/description/done— это «один и тот же» концепт задачи - В Set/Map это убирает дубликаты ДО сохранения в БД
- ⚠️ В уроке 6 заменим на
equalsпоidдля JPA-сущностей
👥 Аналогия: == — «у нас один и тот же паспорт в руках». equals — «у нас одинаковые ФИО и дата рождения, значит это один и тот же человек».
Практика: улучшаем CRUD
-
1Обновлённый TaskController.java
package com.taskflow.controller; import com.taskflow.dto.TaskRequest; import com.taskflow.model.Task; import com.taskflow.service.TaskService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import java.net.URI; import java.util.List; @RestController @RequestMapping("/api/tasks") public class TaskController { private final TaskService service; public TaskController(TaskService service) { this.service = service; } @GetMapping public List<Task> findAll() { return service.findAll(); } @GetMapping("/{id}") public ResponseEntity<Task> findById(@PathVariable Long id) { return service.findById(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } @PostMapping public ResponseEntity<Task> create(@RequestBody TaskRequest req) { Task created = service.create( new Task(null, req.title(), req.description(), req.done()) ); URI location = ServletUriComponentsBuilder .fromCurrentRequest() .path("/{id}") .buildAndExpand(created.id()) .toUri(); return ResponseEntity.created(location).body(created); } @PutMapping("/{id}") public ResponseEntity<Task> update(@PathVariable Long id, @RequestBody TaskRequest req) { return service.findById(id) .map(existing -> { Task updated = service.update(id, new Task(id, req.title(), req.description(), req.done())); return ResponseEntity.ok(updated); }) .orElse(ResponseEntity.notFound().build()); } @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable Long id) { if (service.findById(id).isEmpty()) { return ResponseEntity.notFound().build(); } service.delete(id); return ResponseEntity.noContent().build(); } } -
2Тест через curl
# Создать → 201 + Location header $ curl -i -X POST http://localhost:8080/api/tasks \ -H "Content-Type: application/json" \ -d '{"title":"Read book","description":"Spring in Action","done":false}' HTTP/1.1 201 Location: http://localhost:8080/api/tasks/1 Content-Type: application/json {"id":1,"title":"Read book","description":"Spring in Action","done":false} # Получить несуществующую → 404 $ curl -i http://localhost:8080/api/tasks/999 HTTP/1.1 404 # Удалить → 204 $ curl -i -X DELETE http://localhost:8080/api/tasks/1 HTTP/1.1 204Все статусы правильные. 🎉
-
3Готовим Task к JPA · class с equals/hashCode
Record пока подходит, но для JPA нужен class с no-arg constructor + setters. Делаем гибрид:
package com.taskflow.model; import java.util.Objects; public class Task { private Long id; private String title; private String description; private boolean done; public Task() {} // JPA требует no-arg constructor public Task(Long id, String title, String description, boolean done) { this.id = id; this.title = title; this.description = description; this.done = done; } 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 isDone() { return done; } public void setDone(boolean done) { this.done = done; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Task task)) return false; return Objects.equals(title, task.title) && Objects.equals(description, task.description) && done == task.done; } @Override public int hashCode() { return Objects.hash(title, description, done); } } -
4Что изменилось
POSTвозвращает201 Created+ заголовокLocation: /api/tasks/42PUTпроверяет существование задачи, иначе404DELETEпроверяет существование, иначе404Taskготов к переходу на JPA: есть no-arg constructor, equals/hashCode по бизнес-полям
Bean Validation пока нет — добавим в уроке 8.
Зачем это на собесе
После урока ты должен уметь ответить на:
5 вопросов на углубление
0 / 5 выбраноРаскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.
PUT обычно идемпотентный, а POST — нет? Можно ли сделать POST идемпотентным? Как?Idempotency-Key header. Как его используют Stripe и PayPal.
404 Not Found от 410 Gone?404 — «может, появится», 410 — «удалено окончательно».
PATCH вместо PUT?PATCH = частичное обновление, PUT = полная замена. Когда что удобнее.
Гипермедиа-ссылки в ответах. Плюсы (discovery) vs минусы (overhead).
equals сравниваются поля, а не this == o?Разные инстансы могут быть «равны» по смыслу. Set/Map/contains это требуют.
Готов идти дальше?
Выбери вопросы, которые тебе интересны, и изучи их. Потом — к следующему уроку.
📖 Изучить выбранные вопросыСначала выбери хотя бы 1 вопрос на углубление
🚀 Перейти к Уроку 4Сначала пройди все секции и выбери хотя бы 1 вопрос
⬅️ Назад к Уроку 2