УРОК 3 / 30 0%

📡 REST API задач — CRUD + подготовка к JPA

🎯

Цель урока

Сделать полноценный CRUD для задач с правильными HTTP-статусами (201/204/404), обработкой ошибок, и подготовить Task к подключению JPA.

🧠

Теория · для собеса

1 HTTP-статусы — какие возвращать
Действие
Успех
Ошибка клиента
Сервера
GET /api/tasks
200
500
GET /api/tasks/{id}
200
404
500
POST /api/tasks
201
400
500
PUT /api/tasks/{id}
200
404/400
500
DELETE /api/tasks/{id}
204
404
500

В Spring:

  • 200 OKResponseEntity.ok(body) или просто вернуть body
  • 201 CreatedResponseEntity.created(URI.create(\"/api/tasks/\" + id)).body(body)
  • 204 No ContentResponseEntity.noContent().build()
  • 404 Not FoundResponseEntity.notFound().build() или исключение → @ControllerAdvice
  • 400 Bad RequestMethodArgumentNotValidException через @Valid (урок 8)
💡
На собесе: «когда 200 vs 201 vs 204?» — 200 = успех с телом, 201 = создано (+ Location), 204 = успех без тела.
2 5 уровней зрелости REST API (Richardson)
Уровень 0 · HTTP как транспорт

один endpoint, POST с action в теле (❌ антипаттерн)

Уровень 1 · Resources

каждый ресурс = URL (/api/tasks, /api/tasks/42)

Уровень 2 · HTTP verbs

GET / POST / PUT / DELETE правильно

Уровень 3 · HATEOAS

в ответе ссылки на связанные ресурсы

Уровень 4 · Hypermedia controls

управление состоянием через гиперссылки

💡
На собесе: достаточно уверенно говорить про уровни 1-2 + правильные статусы.
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}
💡
На собесе: «зачем Location header?» — клиент узнаёт URL нового ресурса без отдельного GET.
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. 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. 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. 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. 4
    Что изменилось
    • POST возвращает 201 Created + заголовок Location: /api/tasks/42
    • PUT проверяет существование задачи, иначе 404
    • DELETE проверяет существование, иначе 404
    • Task готов к переходу на JPA: есть no-arg constructor, equals/hashCode по бизнес-полям

    Bean Validation пока нет — добавим в уроке 8.

🎯

Зачем это на собесе

После урока ты должен уметь ответить на:

«Когда 200 vs 201 vs 204?» — 200 = успех с телом, 201 = создано (+ Location), 204 = успех без тела.
«Зачем Location header?» — клиент узнаёт URL нового ресурса без отдельного GET.
«PUT vs PATCH — в чём разница?» — PUT = полная замена, PATCH = частичное обновление.
«Что такое HATEOAS?» — гипермедиа-ссылки в ответах. На практике редко, но знать стоит.
«Почему equals по бизнес-полям, а не по id?» — до persist у объекта id=null, поэтому сравниваем смысл, а не идентификатор.

5 вопросов на углубление

0 / 5 выбрано

Раскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.

1
Почему PUT обычно идемпотентный, а POST — нет? Можно ли сделать POST идемпотентным? Как?

Idempotency-Key header. Как его используют Stripe и PayPal.

2
Чем отличается 404 Not Found от 410 Gone?

404 — «может, появится», 410 — «удалено окончательно».

3
Когда использовать PATCH вместо PUT?

PATCH = частичное обновление, PUT = полная замена. Когда что удобнее.

4
Что такое HATEOAS и зачем оно нужно? Почему его редко используют на практике?

Гипермедиа-ссылки в ответах. Плюсы (discovery) vs минусы (overhead).

5
Почему в equals сравниваются поля, а не this == o?

Разные инстансы могут быть «равны» по смыслу. Set/Map/contains это требуют.

Готов идти дальше?

Выбери вопросы, которые тебе интересны, и изучи их. Потом — к следующему уроку.

📖 Изучить выбранные вопросы

Сначала выбери хотя бы 1 вопрос на углубление

🚀 Перейти к Уроку 4

Сначала пройди все секции и выбери хотя бы 1 вопрос

⬅️ Назад к Уроку 2