HTTP-методы: или как не писать API на коленке

Введение: когда методы становятся спасательным кругом

Вы когда-нибудь видели код, где GET используется для удаления записи, а POST — для чтения данных? Или API, где один и тот же эндпоинт /users отвечает за создание, обновление и удаление пользователей через разные параметры запроса? Если да, то вы уже знаете, что такое хаос.

HTTP-методы — это не просто набор букв в заголовке запроса. Это контракт между клиентом и сервером, который:

  1. Сокращает время обучения новых разработчиков.
  2. Упрощает тестирование (автоматические проверки понимают, что DELETE /resource/123 должен возвращать 204, а не 200).
  3. Помогает избежать багов на этапе проектирования, когда логика ещё не замусорена хаками.

В реальных проектах (особенно в микросервисах или крупных монолитах) неправильное использование методов приводит к:

  • Непредсказуемому поведению (например, GET /order/123 возвращает 404, хотя запись существует, потому что её "обновили" через POST).
  • Проблемам с кэшированием (браузеры и CDN кэшируют GET, но не POST — это может привести к неожиданным ошибкам).
  • Сложностям в мониторинге (логи переполняются нестандартными запросами, а метрики теряют смысл).

Проблема: когда методы становятся камнем на шее

Давайте рассмотрим типичный сценарий:

  1. Стартап-проект начинается с одного эндпоинта /api/data, который обрабатывает всё через POST с параметром action=read|create|delete.
  2. Через год проект растёт, появляются новые команды, документация теряется.
  3. Внезапно POST /api/data?action=delete начинает возвращать 500 из-за гонки на запись, потому что кто-то забыл добавить транзакцию.
  4. Фронтенд-разработчики жалуются, что GET /api/data иногда возвращает старые данные из-за кэша.
  5. 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;
}

Типичные ошибки и как их избежать

  1. Использование GET для изменяемых операций

    • Ошибка: GET /api/orders/123/ship отправляет заказ в доставку.
    • Риск: Данные могут быть потеряны из-за кэширования или дублирования запросов.
    • Решение: Заменить на POST /api/orders/123/ship или PATCH /api/orders/123 с полем status: "shipped".
  2. Несоответствие статусов HTTP

    • Ошибка: DELETE /api/users/123 возвращает 200 OK с боди { "message": "User deleted" }.
    • Риск: Клиент ожидает 204 No Content, что приводит к ошибкам в логике.
    • Решение: Возвращать 204 для успешного удаления.
  3. Игнорирование Idempotency

    • Ошибка: POST /api/payments создаёт новый платёж каждый раз, даже если передан тот же payment_id.
    • Риск: Двойные списания, дубликаты в базе.
    • Решение: Делать POST /api/payments идемпотентным (например, через Idempotency-Key в заголовках).
  4. Смешивание логики в одном эндпоинте

    • Ошибка: /api/users обрабатывает создание, обновление и удаление через параметр ?action=....
    • Риск: Сложно тестировать, поддерживать и мониторить.
    • Решение: Разделить на /api/users, POST /api/users, PATCH /api/users/123, DELETE /api/users/123.
  5. Забывание о безопасности

    • Ошибка: 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-методы (и не плакать над логами)

  1. Автоматические тесты с 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
    
  2. Интеграционные тесты с 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);
      });
    });
    
  3. Мониторинг с 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 (и спать по ночам)

  1. Соблюдайте семантику методов:

    • GET = только чтение.
    • POST = создание (или сложные операции без идемпотентности).
    • PUT = полное обновление.
    • PATCH = частичное обновление.
    • DELETE = удаление (и только удаление).
  2. Документируйте контракты: Используйте OpenAPI/Swagger или хотя бы Markdown-таблицы с примерами. Это спасёт вас (и новых разработчиков) от вопросов типа "А что этот эндпоинт делает?".

  3. Автоматизируйте проверки:

    • Валидация статусов HTTP в тестах.
    • Проверка idempotency для POST/PUT.
    • Тесты на кэширование (GET запросы).
  4. Отключайте опасные методы: TRACE, CONNECT и OPTIONS (если не нужен CORS) должны быть заблокированы на уровне сервера.

  5. Мониторьте использование: Отслеживайте метрики по методам (например, процент POST vs PUT для одного эндпоинта). Если POST используется для обновлений — это повод пересмотреть дизайн.

  6. Не бойтесь переделывать: Если API уже написан с нарушениями — не стесняйтесь рефакторить. Лучше потратить неделю на исправление, чем месяц на отладку багов из-за неверных методов.


Послесловие: реальный случай из жизни

В одном проекте у нас был эндпоинт /api/orders/confirm, который работал через GET с параметром ?token=.... Проблемы начались, когда:

  1. Браузеры стали кэшировать подтверждение заказа (клиенты жаловались, что заказы не подтверждаются).
  2. В логах появились дубликаты подтверждений из-за повторных запросов.
  3. Фронтенд-разработчики случайно отправляли 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;
    }
    

Результат: нулевые баги на этапе подтверждения заказов, меньше вопросов от поддержки, и код стал понятнее даже джуниорам.