HTTP-методы: или как не писать API на коленке
Введение: когда методы становятся спасательным кругом
Вы когда-нибудь видели код, где GET используется для удаления записи, а POST — для чтения данных? Или API, где один и тот же эндпоинт /users отвечает за создание, обновление и удаление пользователей через разные параметры запроса? Если да, то вы уже знаете, что такое хаос.
HTTP-методы — это не просто набор букв в заголовке запроса. Это контракт между клиентом и сервером, который:
- Сокращает время обучения новых разработчиков.
- Упрощает тестирование (автоматические проверки понимают, что
DELETE /resource/123должен возвращать 204, а не 200). - Помогает избежать багов на этапе проектирования, когда логика ещё не замусорена хаками.
В реальных проектах (особенно в микросервисах или крупных монолитах) неправильное использование методов приводит к:
- Непредсказуемому поведению (например,
GET /order/123возвращает 404, хотя запись существует, потому что её "обновили" черезPOST). - Проблемам с кэшированием (браузеры и CDN кэшируют
GET, но неPOST— это может привести к неожиданным ошибкам). - Сложностям в мониторинге (логи переполняются нестандартными запросами, а метрики теряют смысл).
Проблема: когда методы становятся камнем на шее
Давайте рассмотрим типичный сценарий:
- Стартап-проект начинается с одного эндпоинта
/api/data, который обрабатывает всё черезPOSTс параметромaction=read|create|delete. - Через год проект растёт, появляются новые команды, документация теряется.
- Внезапно
POST /api/data?action=deleteначинает возвращать 500 из-за гонки на запись, потому что кто-то забыл добавить транзакцию. - Фронтенд-разработчики жалуются, что
GET /api/dataиногда возвращает старые данные из-за кэша. - QA тратит недели на ручное тестирование, потому что логика размазана по параметрам запроса.
Корень проблемы: HTTP-методы используются как "что удобно сейчас", а не как "что правильно с точки зрения архитектуры".
Практика: как правильно использовать методы (и почему это важно)
1. GET: только для чтения (и ничего больше)
Правило: GET должен быть аффирмативным — он не должен изменять состояние сервера. Если вы видите GET с боди (body) или side-effects (например, отправка письма), это красный флаг.
Пример правильного использования:
GET /api/users/123
- Возвращает данные пользователя.
- Может быть кэширован браузером или CDN.
- Безопасен для повторных вызовов.
Пример НЕправильного использования:
GET /api/users?action=reset_password
- Изменяет состояние (сбрасывает пароль).
- Не кэшируется (но это не очевидно для клиента).
- Приводит к утечкам данных в логах (пароль может попасть в историю браузера).
Конфиг Nginx для кэширования GET-запросов:
location /api/ {
proxy_pass http://backend;
proxy_cache my_cache;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;
}
2. POST: создание ресурсов (и иногда обновление)
Правило: POST создаёт новый ресурс и возвращает его URI (или идентификатор). Если вы обновляете существующий ресурс — используйте PATCH или PUT.
Пример правильного создания пользователя:
POST /api/users
Content-Type: application/json
{
"name": "Ivan",
"email": "ivan@example.com"
}
HTTP/1.1 201 Created
Location: /api/users/456
Пример НЕправильного использования POST для обновления:
POST /api/users/123
Content-Type: application/json
{
"name": "UpdatedIvan"
}
- Проблема: Клиент не знает, создаёт ли он новый ресурс или обновляет старый.
- Решение: Использовать
PATCH /api/users/123илиPUT /api/users/123.
3. PUT: полное обновление ресурса
Правило: PUT заменяет ресурс целиком. Если вы отправляете только часть данных — это PATCH.
Пример использования PUT:
PUT /api/users/123
Content-Type: application/json
{
"name": "Ivan Updated",
"email": "ivan.updated@example.com",
"role": "admin" // Все поля должны быть переданы
}
- Если ресурс
/api/users/123не существует — его создадут. - Если часть полей не передана — они сбросятся в
null(или будут удалены, в зависимости от реализации).
Когда использовать:
- Обновление конфигурации (например, настройки приложения).
- Загрузка файла (например,
PUT /api/files/123с боди как бинарные данные).
4. PATCH: частичное обновление
Правило: PATCH обновляет только указанные поля. Формат боди зависит от спецификации (например, JSON Patch или просто поля объекта).
Пример с JSON Patch:
PATCH /api/users/123
Content-Type: application/json-patch+json
[
{ "op": "replace", "path": "/name", "value": "Ivan Patched" },
{ "op": "add", "path": "/tags", "value": ["premium"] }
]
Пример простого PATCH (только поля):
PATCH /api/users/123
Content-Type: application/json
{
"name": "Ivan Patched"
}
- Обновляет только
name, остальные поля остаются без изменений.
5. DELETE: удаление ресурса
Правило: DELETE должен удалять ресурс и только ресурс. Никаких побочных эффектов (например, отправки уведомлений).
Пример:
DELETE /api/users/123
HTTP/1.1 204 No Content
- Возвращает
204(ресурс удалён, но тело отсутствует). - Неправильно: Возвращать
200с боди{ "status": "deleted" }— это путает клиента.
Важно:
- Удаление должно быть атомарным (или с явной транзакцией).
- Логировать удаления (например, в отдельную таблицу
audit_logs).
6. HEAD: GET без тела
Правило: Аналог GET, но возвращает только заголовки. Полезно для проверки существования ресурса или его метаданных (например, Last-Modified, ETag).
Пример:
HEAD /api/products/456
HTTP/1.1 200 OK
Content-Type: application/json
Last-Modified: Wed, 21 Oct 2023 07:28:00 GMT
Content-Length: 128
Когда использовать:
- Проверка доступности ресурса перед загрузкой.
- Получение
ETagдляconditional requests (например,If-None-Match).
7. OPTIONS: описание возможностей эндпоинта
Правило: Возвращает поддерживаемые методы и заголовки для ресурса. Используется для CORS или документирования API.
Пример ответа:
OPTIONS /api/users
HTTP/1.1 200 OK
Allow: GET, POST, OPTIONS
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Конфиг CORS в Express.js:
const express = require('express');
const app = express();
app.options('/api/*', (req, res) => {
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.sendStatus(204);
});
8. Методы, которые редко используются (но иногда полезны)
| Метод | Использование | Пример |
|---|---|---|
| TRACE | Диагностика (отражает полученный запрос) | Используется для отладки прокси. Опасен из-за XST (Cross-Site Tracing). |
| CONNECT | Проксирование TCP (например, для SSH) | CONNECT example.com:443 — редко встречается в REST API. |
Правило: Если вы не знаете, зачем нужен TRACE или CONNECT — отключите их в веб-сервере:
location / {
if ($request_method = TRACE) {
return 405;
}
if ($request_method = CONNECT) {
return 405;
}
proxy_pass http://backend;
}
Типичные ошибки и как их избежать
Использование GET для изменяемых операций
- Ошибка:
GET /api/orders/123/shipотправляет заказ в доставку. - Риск: Данные могут быть потеряны из-за кэширования или дублирования запросов.
- Решение: Заменить на
POST /api/orders/123/shipилиPATCH /api/orders/123с полемstatus: "shipped".
- Ошибка:
Несоответствие статусов HTTP
- Ошибка:
DELETE /api/users/123возвращает200 OKс боди{ "message": "User deleted" }. - Риск: Клиент ожидает
204 No Content, что приводит к ошибкам в логике. - Решение: Возвращать
204для успешного удаления.
- Ошибка:
Игнорирование Idempotency
- Ошибка:
POST /api/paymentsсоздаёт новый платёж каждый раз, даже если передан тот жеpayment_id. - Риск: Двойные списания, дубликаты в базе.
- Решение: Делать
POST /api/paymentsидемпотентным (например, черезIdempotency-Keyв заголовках).
- Ошибка:
Смешивание логики в одном эндпоинте
- Ошибка:
/api/usersобрабатывает создание, обновление и удаление через параметр?action=.... - Риск: Сложно тестировать, поддерживать и мониторить.
- Решение: Разделить на
/api/users,POST /api/users,PATCH /api/users/123,DELETE /api/users/123.
- Ошибка:
Забывание о безопасности
- Ошибка:
PUT /api/users/123позволяет изменитьroleнаadminлюбому пользователю. - Риск: Уязвимость для privilege escalation.
- Решение: Валидировать права доступа на уровне сервера (например, middleware в Express).
- Ошибка:
Пример полноценного API с правильными методами
Сценарий: Управление задачами в трекере.
| Операция | Метод | Эндпоинт | Заголовки/Боди | Статус успеха | Примечания |
|---|---|---|---|---|---|
| Создать задачу | POST | /api/tasks |
{"title": "Fix bug", "priority": "high"} |
201 Created | Возвращает Location: /api/tasks/123 |
| Получить задачу | GET | /api/tasks/123 |
— | 200 OK | Кэшируемый запрос |
| Обновить задачу | PATCH | /api/tasks/123 |
{"status": "in_progress"} |
200 OK | Частичное обновление |
| Завершить задачу | PUT | /api/tasks/123 |
{"status": "done", "completed_at": "..."} |
200 OK | Полное обновление |
| Удалить задачу | DELETE | /api/tasks/123 |
— | 204 No Content | Без тела ответа |
| Список задач | GET | /api/tasks |
?status=open&priority=high |
200 OK | Фильтрация через query params |
| Проверка статуса задачи | HEAD | /api/tasks/123 |
— | 200 OK | Возвращает только заголовки |
Конфиг Swagger (OpenAPI) для документации:
paths:
/api/tasks:
get:
summary: Get list of tasks
parameters:
- name: status
in: query
schema:
type: string
example: open
responses:
200:
description: OK
post:
summary: Create a new task
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Task'
responses:
201:
description: Created
headers:
Location:
schema:
type: string
description: URL of the created task
/api/tasks/{id}:
patch:
summary: Update task partially
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
status:
type: string
priority:
type: string
responses:
200:
description: OK
Как тестировать HTTP-методы (и не плакать над логами)
Автоматические тесты с Postman/Newman Пример коллекции для тестирования задач:
{ "info": { "name": "Tasks API", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ { "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\"title\": \"Test task\", \"priority\": \"low\"}" }, "url": { "raw": "{{base_url}}/api/tasks", "host": ["{{base_url}}"], "path": ["api", "tasks"] } }, "response": [ { "status": "201", "code": 201 } ] }, { "request": { "method": "GET", "url": { "raw": "{{base_url}}/api/tasks/1", "host": ["{{base_url}}"], "path": ["api", "tasks", "1"] } }, "response": [ { "status": "200", "code": 200 } ] } ] }Команда для запуска тестов:
newman run tasks.postman_collection.json -e dev.environment.json --reporters cli,jsonИнтеграционные тесты с Jest и Supertest Пример на Node.js:
const request = require('supertest'); const app = require('../app'); describe('Tasks API', () => { it('should create a task', async () => { const res = await request(app) .post('/api/tasks') .send({ title: 'Test task', priority: 'low' }) .expect(201); expect(res.headers['location']).toMatch(/^\/api\/tasks\/\d+$/); }); it('should return 404 for non-existent task', async () => { await request(app) .get('/api/tasks/99999') .expect(404); }); });Мониторинг с Prometheus и Grafana Метрики для отслеживания использования методов:
# prometheus.yml scrape_configs: - job_name: 'api' static_configs: - targets: ['localhost:3000']Пример экспорта метрик в Express:
const client = require('prom-client'); const collectDefaultMetrics = client.collectDefaultMetrics; collectDefaultMetrics({ timeout: 5000 }); const httpRequestDurationMicroseconds = new client.Histogram({ name: 'http_request_duration_seconds', help: 'Duration of HTTP requests in seconds', labelNames: ['method', 'route', 'code'], }); app.use((req, res, next) => { const end = httpRequestDurationMicroseconds.startTimer(); res.on('finish', () => { end({ method: req.method, route: req.route?.path, code: res.statusCode }); }); next(); }); app.get('/metrics', async (req, res) => { res.set('Content-Type', client.register.contentType); res.end(await client.register.metrics()); });
Вывод: как не загубить API (и спать по ночам)
Соблюдайте семантику методов:
GET= только чтение.POST= создание (или сложные операции без идемпотентности).PUT= полное обновление.PATCH= частичное обновление.DELETE= удаление (и только удаление).
Документируйте контракты: Используйте OpenAPI/Swagger или хотя бы Markdown-таблицы с примерами. Это спасёт вас (и новых разработчиков) от вопросов типа "А что этот эндпоинт делает?".
Автоматизируйте проверки:
- Валидация статусов HTTP в тестах.
- Проверка idempotency для
POST/PUT. - Тесты на кэширование (
GETзапросы).
Отключайте опасные методы:
TRACE,CONNECTиOPTIONS(если не нужен CORS) должны быть заблокированы на уровне сервера.Мониторьте использование: Отслеживайте метрики по методам (например, процент
POSTvsPUTдля одного эндпоинта). ЕслиPOSTиспользуется для обновлений — это повод пересмотреть дизайн.Не бойтесь переделывать: Если API уже написан с нарушениями — не стесняйтесь рефакторить. Лучше потратить неделю на исправление, чем месяц на отладку багов из-за неверных методов.
Послесловие: реальный случай из жизни
В одном проекте у нас был эндпоинт /api/orders/confirm, который работал через GET с параметром ?token=.... Проблемы начались, когда:
- Браузеры стали кэшировать подтверждение заказа (клиенты жаловались, что заказы не подтверждаются).
- В логах появились дубликаты подтверждений из-за повторных запросов.
- Фронтенд-разработчики случайно отправляли
GETвместоPOSTпри отладке.
Решение:
- Перевели на
POST /api/orders/123/confirmс боди{ "token": "..." }. - Добавили валидацию
Content-Type: application/json. - Заблокировали
GET /api/orders/123/confirmна уровне Nginx:location ~* /api/orders/(\d+)/confirm { if ($request_method = GET) { return 405; } proxy_pass http://backend; }
Результат: нулевые баги на этапе подтверждения заказов, меньше вопросов от поддержки, и код стал понятнее даже джуниорам.