1. CRUD и HTTP-методы: читаемость API
Когда начинающий разработчик впервые проектирует API, он часто мыслит так: “мне нужно создать задачу — значит, будет /createTask, нужно удалить — будет /deleteTask”. Это выглядит логично… если вы мысленно рисуете API как набор кнопок в админке. Но клиент API видит не кнопки и не ваш контроллер, а контракт, в котором важны предсказуемость и повторяемость паттернов.
Связка CRUD ↔ HTTP-методы — не догма и не религия. Это способ договориться о базовой грамматике: где у нас “существительные” (ресурсы в URI), а где “глаголы” (методы HTTP). Когда эта грамматика соблюдена, клиент быстро угадывает поведение endpoint’ов даже без документации, а вы сами не плодите бесконечные маршруты-исключения.
Ресурсы, коллекции и представления мы уже развели по ролям. Теперь важно понять, как на эту модель ложится знакомая HTTP-грамматика: какой метод работает с коллекцией, какой — с конкретным ресурсом, и почему это вообще делает API читаемым.
Если очень упрощать, мы хотим, чтобы API читалось как фраза:
GET /tasks — «дай задачи»
POST /tasks — «добавь задачу»
DELETE /tasks/{taskId} — «удали эту задачу»
И именно эта “читаемость глазами клиента” — главный практический смысл REST в нашем курсе.
CRUD как шпаргалка: Create, Read, Update, Delete
CRUD — это простая модель: создать, прочитать, изменить, удалить. Она хороша тем, что помогает не забыть базовые сценарии, которые почти всегда нужны бизнесу. Но важно понимать тонкость: CRUD — это про операции в голове, а REST — про то, как эти операции выражаются через ресурсы и HTTP.
Здесь легко попасть в ловушку: “если CRUD — четыре действия, значит, должно быть четыре endpoint’а”. На практике всё сложнее. Как минимум есть две оси: во‑первых, вы работаете либо с коллекцией, либо с одним ресурсом; во‑вторых, вы можете читать и менять разные представления (например, краткое и подробное), но это уже тема будущих модулей про DTO, туда сейчас не лезем.
Пока нам достаточно вот такого здравого уровня:
- Create обычно означает “появился новый элемент в коллекции”.
- Read означает “дай представление ресурса (одного или списка)”.
- Update означает “поменяй состояние существующего ресурса”.
- Delete означает “убери ресурс из адресуемого мира”.
CRUD полезен как стартовая карта. Но он не отвечает на вопрос “какой именно метод HTTP поставить и на какой URI”. За этим мы и идём дальше.
2. Коллекция и ресурс: смысл URI
Одна из самых недооценённых вещей в REST-дизайне — то, что один и тот же HTTP-метод на разных “целях” означает разные сценарии. GET по коллекции — это список, GET по одному ресурсу — детальный просмотр. И это не нюанс, а основа читаемости контракта: клиент должен понимать, просит он сейчас “всё” или “одно”.
Давайте зафиксируем на нашем домене:
- Коллекция задач: /tasks
- Одна задача: /tasks/{taskId}
И то же самое для подресурсов:
- Коллекция комментариев задачи: /tasks/{taskId}/comments
- Один комментарий конкретной задачи: /tasks/{taskId}/comments/{commentId}
Обратите внимание: URI не описывает действие, он описывает “о чём речь”. Действие приходит сверху — через метод HTTP. Это как адрес дома и то, что вы делаете: “прийти посмотреть” или “прийти заменить замок”. Адрес один, действия разные.
Мини-проверка для себя очень простая: если вы видите .../tasks — мысленно добавьте “все задачи”, если .../tasks/{taskId} — “конкретная задача”. Если это не складывается, значит, URI уже начал сползать в командный стиль.
3. CRUD-операции и методы HTTP
Read: GET для коллекции и для одного ресурса
GET — самый дружелюбный метод. Он буквально создан для того, чтобы получать представление ресурса. На Дне 2 мы уже обсуждали safe methods: GET не должен менять состояние сервера. Это очень практично, потому что клиент и промежуточные системы могут повторять GET, кешировать его, переиспользовать — и не бояться, что от “просмотра страницы” внезапно что-то удалится.
В нашем проекте это выражается максимально естественно:
- GET /tasks — вернуть список задач (представление коллекции).
- GET /tasks/{taskId} — вернуть одну задачу (представление ресурса).
И здесь начинается полезная мелкая дисциплина. Например, если вы хотите отфильтровать список, это всё ещё GET /tasks, просто с query-параметрами, а не с новым “командным” URI. Но детали фильтрации, пагинации и сортировки мы сознательно оставим на будущее — сегодня нам важна базовая грамматика.
Небольшой Java-черновик, который помогает увидеть два разных read-сценария:
import java.util.Map;
// Два варианта "прочитать": список и один конкретный ресурс.
// Важно: метод один (GET), а "смысл" задаётся тем, куда мы идём (коллекция vs ресурс).
Map<String, String> readEndpoints = Map.of(
"read list", "GET /tasks",
"read one", "GET /tasks/{taskId}"
);
Да, это “игрушечная” запись. Но она полезна тем, что вы сразу видите: список и один элемент — это разные endpoints, хотя метод один и тот же.
Create: POST как добавление элемента в коллекцию
Создание — место, где новички чаще всего выдают /createSomething и считают задачу выполненной. А REST-логика говорит: “создание чего-то нового” обычно означает добавление нового элемента в коллекцию. Значит, цель операции — коллекция, а метод — тот, который подходит для добавления.
Поэтому базовый паттерн выглядит так:
- POST /tasks — создать новую задачу в коллекции задач.
Важно держать в голове следующее: POST — это не “метод для всего, что не GET”. POST хорошо читается как “добавить в контейнер или коллекцию новый элемент” или “создать дочерний ресурс”. Именно поэтому комментарии как подресурс задачи создаются так:
- POST /tasks/{taskId}/comments — создать новый комментарий внутри коллекции комментариев конкретной задачи.
Обратите внимание на очень полезный психологический эффект: у вас исчезает необходимость придумывать глаголы в path. Вы не пишете /addCommentToTask, потому что контекст уже есть в URI: комментарии живут внутри задачи.
Мини-пример для фиксации этой мысли:
import java.util.Map;
// Создание почти всегда адресуется в "контейнер": в коллекцию ресурсов.
// Отдельно обратите внимание на подресурс comments: он "живёт" внутри task.
Map<String, String> createEndpoints = Map.of(
"create task", "POST /tasks",
"create comment", "POST /tasks/{taskId}/comments"
);
Про коды ответа (201 Created) и Location мы будем говорить в модуле про request/response и корректные ответы. Сейчас держим только главную идею: POST идёт в коллекцию.
Update: PUT и PATCH на высоком уровне
Обновления — это место, где в реальных проектах начинается философия, священные войны и комментарии на 200 строк в ревью. Чтобы этого не случилось у нас слишком рано, берём “достаточную” модель: PUT и PATCH нужны, чтобы изменить существующий ресурс, и обычно они применяются к одному конкретному ресурсу, а не к коллекции.
Базовая связка такая:
- PUT /tasks/{taskId} — заменить изменяемое состояние задачи целиком (высокоуровнево: “сделать так, как в присланном представлении”).
- PATCH /tasks/{taskId} — поменять часть состояния задачи (высокоуровнево: “внести частичные изменения”).
Пока не лезем в детали “что значит целиком” и “как трактовать null”. Это будет отдельная тема в курсе (и она реально важна), но не в этой лекции. Сейчас нам нужно запомнить только грамматику: изменения адресуются на конкретный ресурс, потому что мы меняем не “все задачи в мире”, а “вот эту задачу”.
И ещё одна практическая связка с Днём 2: PUT и PATCH — это про запись, поэтому они не safe. А вопрос идемпотентности мы здесь держим как фоновую дисциплину: повторный PUT с тем же содержимым обычно должен приводить к тому же результату. Но повторюсь: сегодня это на уровне “помнить, что такое бывает”, без реализации.
Небольшая карта обновлений:
import java.util.Map;
// Обновление обычно делаем по адресу конкретного ресурса (/tasks/{taskId}),
// потому что изменяем "вот эту" задачу, а не всю коллекцию целиком.
Map<String, String> updateEndpoints = Map.of(
"full replace", "PUT /tasks/{taskId}",
"partial change", "PATCH /tasks/{taskId}"
);
Delete: DELETE и аккуратный выход из мира ресурсов
Удаление часто воспринимается как “ну это же просто, делаем /removeTask”. Но в REST-модели мы опять возвращаемся к тому же принципу: URI показывает ресурс, метод показывает действие. Если вы удаляете конкретную задачу, вы обращаетесь к адресу этой задачи и используете DELETE.
Базовый паттерн:
- DELETE /tasks/{taskId} — удалить конкретную задачу.
И тот же паттерн прекрасно работает для подресурсов:
- DELETE /tasks/{taskId}/comments/{commentId} — удалить конкретный комментарий конкретной задачи.
- DELETE /tasks/{taskId}/attachments/{attachmentId} — удалить конкретное вложение конкретной задачи (когда мы дойдём до files).
Это выглядит скучно (и это комплимент). Скучная грамматика контракта и делает API удобным: клиенту не нужно запоминать десять “особенных” команд, он просто комбинирует два понятных измерения: метод + ресурс.
Вспомним ещё одну идею из HTTP-мира: DELETE считается идемпотентным на транспортном уровне. Это не значит, что бизнес “ничего не почувствует”, но означает: повторный DELETE не должен превращаться в “удаление дважды”, что бы это ни значило. Реальные ответы (204 vs 404) мы разберём позже, а сейчас достаточно помнить: DELETE — это нормальный способ “попрощаться” с ресурсом.
Мини-фиксация:
// Плохой пример: действие вшито в path и ещё и сделано через GET (небезопасно).
String wrong = "GET /deleteTask/{taskId}";
// Хороший пример: ресурс в URI, действие (удаление) — через метод DELETE.
String better = "DELETE /tasks/{taskId}";
4. Черновик API surface в коде
Есть одна практика, которая очень помогает новичкам: зафиксировать поверхность API не только в голове и в заметках, а в простом виде прямо в проекте. Не как “финальную документацию”, а как маленький ориентир, чтобы в любой момент проверить: “у нас вообще API сейчас выглядит как ресурсное, или я уже придумал /doMagic?”
Сделаем крошечный “контракт-черновик” на чистой Java. Он не использует Spring, не лезет в контроллеры и не конфликтует с будущими темами. Его задача — быть читаемым: списком endpoint’ов, которые мы подразумеваем на уровне дизайна.
На таком черновике особенно хорошо видна базовая сетка API. И тут же заметно, где проект чаще всего снова срывается в команды: бизнес редко просит “добавьте ещё один PATCH”, он просит complete, assign, upload. Именно такие сценарии и показывают, удерживаете вы ресурсную модель или снова строите каталог кнопок.
Описываем HTTP-методы как enum
Для начала заведём enum. Звучит почти смешно, но даёт полезный эффект: вы перестаёте писать методы строками и даже в черновиках допускаете меньше ошибок.
package com.example.tasktracker.api.contract;
// Явный enum полезен даже в "черновике контракта":
// так мы не опечатаемся в методе ("POTS" вместо "POST") и не размазываем строки по коду.
public enum HttpMethod {
GET, POST, PUT, PATCH, DELETE
}
Описываем endpoint как маленькую запись
Теперь сделаем простую модель endpoint’а. Нам хватит метода и path.
package com.example.tasktracker.api.contract;
// Мини-модель endpoint'а: только метод и путь.
// Этого достаточно, чтобы обсуждать дизайн API, не привязываясь к Spring и контроллерам.
public record Endpoint(HttpMethod method, String path) {
}
Собираем «поверхность» задач и комментариев
А теперь — то, ради чего всё затевалось: соберём список. Обратите внимание: мы сознательно используем пути без /api/v1 (версионирование и правила URI будут отдельным днём). Здесь нам нужна только ресурсная структура.
package com.example.tasktracker.api.contract;
import java.util.List;
public final class TaskTrackerApiSurface {
// Возвращаем "поверхность" API в виде списка endpoint'ов.
// Это не реализация контроллеров, а просто фиксатор договорённостей по контракту.
public static List<Endpoint> endpoints() {
return List.of(
// Read
new Endpoint(HttpMethod.GET, "/tasks"),
new Endpoint(HttpMethod.GET, "/tasks/{taskId}"),
// Create
new Endpoint(HttpMethod.POST, "/tasks"),
// Update
new Endpoint(HttpMethod.PUT, "/tasks/{taskId}"),
new Endpoint(HttpMethod.PATCH, "/tasks/{taskId}"),
// Delete
new Endpoint(HttpMethod.DELETE, "/tasks/{taskId}")
);
}
// Утилитный класс: экземпляры не нужны.
private TaskTrackerApiSurface() {
}
}
Если хочется добавить comments как подресурс — чисто на уровне дизайна, — можно расширить список ещё несколькими строками в том же стиле, но не превращать это в “финальную карту”. Наша цель тут учебная: почувствовать, как CRUD складывается в ресурсную модель.
Маленькая печать в консоль
Иногда полезно просто распечатать это и увидеть глазами. Сделаем мини-демо-класс. Да, в Spring Boot проекте обычно запускается основной класс приложения, но отдельный main() для учебных экспериментов — это нормально.
package com.example.tasktracker.api.contract;
public class ApiSurfaceDemo {
public static void main(String[] args) {
TaskTrackerApiSurface.endpoints()
.forEach(System.out::println);
// Endpoint[method=GET, path=/tasks]
// Endpoint[method=GET, path=/tasks/{taskId}]
}
}
Дальше, когда мы будем реализовывать контроллеры, DTO, validation и errors, этот “список” может либо удалиться, либо превратиться в документацию, либо остаться как подсказка. Но на текущем этапе он хорош тем, что дисциплинирует мышление: вы начинаете видеть API как поверхность, а не как “методы в контроллере”.
5. Типичные ошибки при выборе HTTP-методов
Ошибки в выборе HTTP-методов редко выглядят как “ой, компилятор ругается”. Обычно они выглядят так: “через месяц никто не понимает, как этим пользоваться”, а иногда — и так: “клиент случайно удалил данные, просто открыв ссылку”. Поэтому полезно заранее знать типовые грабли, чтобы не собирать коллекцию этих грабель у себя в проде (и не делать вид, что так и задумывалось).
Ошибка №1: использовать GET для изменения состояния.
Самый популярный антипример: GET /deleteTask/{id} или GET /completeTask/{id}. На уровне “мне же нужно вызвать действие” это кажется нормальным, но ломает сам смысл GET как безопасного чтения. Клиент или даже браузер/кеш могут повторить GET автоматически, и вы получите неожиданные побочные эффекты. Если операция меняет состояние — это не GET, даже если вам очень хочется.
Ошибка №2: делать POST универсальной кнопкой “сделай что-нибудь”.
Вторая крайность после злоупотребления GET — злоупотребление POST. Появляется API вида POST /createTask, POST /updateTask, POST /deleteTask. Формально “работает”, но теряется смысл методов, и контракт перестаёт читаться. POST /tasks как создание элемента коллекции читается проще, чем любой /create..., потому что вы не смешиваете действие и ресурс в одном месте.
Ошибка №3: путать операции над коллекцией и операции над одним ресурсом.
Например, DELETE /tasks — “удалить все задачи” — звучит как “о, удобно”, но это уже очень опасный endpoint и почти никогда не нужен в публичном API “по умолчанию”. В учебном проекте мы вообще стараемся держать операции естественными: удаление — по конкретному taskId. Как только вы начинаете массово воздействовать на коллекции, резко растёт сложность ошибок, идемпотентности и ожиданий клиента.
Ошибка №4: пытаться “выразить update” через отдельные глагольные маршруты.
Вместо PUT /tasks/{taskId} появляются /tasks/{taskId}/rename, /tasks/{taskId}/changeDescription, /tasks/{taskId}/assignUser. Иногда так действительно делают, но это быстрый путь к разрастанию глагольных маршрутов, когда на каждую мелочь появляется отдельный endpoint. В нашем курсе мы сначала учимся базовой дисциплине: update — это изменение представления ресурса через PUT или PATCH.
Ошибка №5: слишком рано пытаться идеально различить PUT и PATCH до уровня философии.
На этом этапе достаточно понимать намерение: PUT — “заменить целиком”, PATCH — “поменять часть”. Не нужно уже сегодня пытаться придумать идеальную merge-логику, JSON Patch, конфликтующие правила для null и “отсутствия поля”. Это реальная сложность, и мы до неё дойдём, но сейчас она только мешает сформировать базовую грамматику.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ