Архитектура REST API: принципы, методы HTTP и коды ответов

Архитектура REST API: принципы, методы HTTP и коды ответов Полезное

REST (Representational State Transfer, «передача состояния представления») — это архитектурный стиль распределенных систем: набор ограничений, который Рой Филдинг описал в диссертации 2000 года. Это не протокол и не стандарт. REST API — программный интерфейс сервиса, построенный по этим ограничениям, на практике почти всегда поверх HTTP.

Ниже — ограничения стиля, ресурсы и методы, коды ответов, идемпотентность, сравнение с RPC и GraphQL. В конце — учебный HTTP API на Python (проверен на Python 3.12 и 3.14): он иллюстрирует часть принципов REST на локальном эксперименте, но не является образцом production-сервера.

Термин Что это
HTTP API API, к которому обращаются HTTP-запросами; не обязательно REST
REST API (RESTful) HTTP API, следующий ограничениям REST (часто лишь частично)
Ресурс и представление ресурс — сущность («книга 2»), представление — ее форма в ответе (JSON, XML, HTML)
Эндпоинт адрес запроса: /books или /books/2 на конкретном хосте и порту

Шесть ограничений REST

Пять ограничений обязательны, шестое — опционально.

Ограничение Что значит Что дает
Клиент-сервер интерфейс отделен от хранения данных стороны развиваются независимо
Stateless каждый запрос несет все нужное для обработки любой экземпляр сервера обработает любой запрос
Кэшируемость ответ помечен как кэшируемый или нет меньше повторных запросов
Единообразный интерфейс одни правила обращения ко всем ресурсам клиент понимает API без знания реализации
Многоуровневость между клиентом и сервером могут стоять прокси, балансировщики, кэши узлы добавляются прозрачно для клиента
Код по требованию (опц.) сервер может передать клиенту код, например JavaScript клиент расширяется без обновления

Единообразный интерфейс включает четыре пункта: ресурсы идентифицируются URI; ресурс меняют через представления; сообщения самоописательны (метод, заголовки, Content-Type); ответы содержат ссылки на возможные действия (HATEOAS).

Граница «stateless»: хранить данные можно — книги в базе это состояние ресурсов. Нельзя держать на сервере состояние диалога с клиентом («текущую страницу списка»), без которого следующий запрос непонятен.

Большинство API, которые называют REST, выполняют ограничения частично: ресурсы, методы и коды есть, HATEOAS нет (уровень 2 из 3 в модели зрелости Ричардсона). Это обычная практика, но строго по Филдингу такой API не полностью RESTful.

Ресурсы, методы и идемпотентность

В REST адрес называет ресурс (существительное), а действие задает метод HTTP. Антипример — глаголы в адресе: /getBook?id=2, /deleteBook/2. Это RPC-стиль: рабочий, но смысл запроса спрятан в имени, а не в методе. Если такое удаление идет через GET или все вызовы через POST, прокси, кэши и клиентские библиотеки не смогут по методу понять, безопасен ли запрос и можно ли его повторить.

RFC 9110 задает два свойства методов. Безопасный метод не меняет состояние сервера (только чтение). Идемпотентный при N одинаковых запросах оставляет сервер в том же состоянии, что и при одном.

Запрос Смысл Безопасный Идемпотентный
GET /books, GET /books/2 список, одна книга да да
POST /books создать книгу в коллекции нет нет
PUT /books/2 заменить книгу целиком нет да
PATCH /books/2 изменить часть полей (RFC 5789) нет не гарантирован
DELETE /books/2 удалить книгу нет да

Идемпотентность — про состояние сервера, а не про код ответа: повторный DELETE вернет 404 вместо 204, но книги нет в обоих случаях. Поэтому PUT и DELETE клиент может повторить после обрыва связи, а слепой повтор POST может создать дубль (как во втором запросе ниже).

Минимальный REST API на Python

Это минимальный сервер для локального эксперимента с JSON-телом и заголовком Content-Length, рассчитанный на curl-запросы ниже. Не публикуйте его в сеть: HTTPServer обрабатывает запросы по одному, данные живут только в памяти и пропадают при перезапуске, а полной обработки HTTP нет (например, тела с Transfer-Encoding: chunked и выбора формата по Accept). Для реального API нужны фреймворк, хранилище и полноценная обработка HTTP — см. раздел «Что еще нужно в production».

Только стандартная библиотека: книги в памяти, GET/POST/PUT/DELETE, коды ответов, Location, ETag и проверка тела. Карта обработчиков: do_GET — список, одна книга и ответ 304; do_POST — создание в /books (на /books/1 — 405); do_PUT — замена книги; do_DELETE — удаление. PATCH из таблицы методов здесь сознательно не реализован — что при этом отвечает сервер, разобрано в разделе про коды. Сохраните как books_api.py и запустите python3 books_api.py — сервер слушает 127.0.0.1:8000. Прогон: Python 3.12.14 и 3.14.7 (docker python:*-slim), curl 8.14, 23.09.2026; правка ответа 304 перепроверена 24.09.2026.

import hashlib
import json
from http.server import BaseHTTPRequestHandler, HTTPServer

books = {1: {"id": 1, "title": "SICP", "year": 1985}}
next_id = 2
MAX_BODY = 10_000  # байт


def to_int(text):  # только 1-9 ASCII-цифр; "²", "1_0" и 5000 цифр -> None
    if text.isascii() and text.isdigit() and len(text) <= 9:
        return int(text)
    return None


class BooksAPI(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def send_json(self, status, data=None, headers=None):
        headers = dict(headers or {})
        if status >= 400:  # тело запроса могло остаться непрочитанным
            headers["Connection"] = "close"
        self.send_response(status)
        for name, value in headers.items():
            self.send_header(name, value)
        body = b"" if data is None else json.dumps(data).encode()
        if data is not None:  # у 204 и 304 тела нет
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def target(self):  # /books -> "all", /books/7 -> 7, иначе None
        parts = self.path.strip("/").split("/")
        if parts == ["books"]:
            return "all"
        if len(parts) == 2 and parts[0] == "books":
            return to_int(parts[1])
        return None

    def read_book(self):
        size = to_int(self.headers.get("Content-Length", "0"))
        if size is None:
            return None, 400
        if size > MAX_BODY:
            return None, 413
        try:
            data = json.loads(self.rfile.read(size))
        except ValueError:
            return None, 400
        if not isinstance(data, dict) or type(data.get("title")) is not str \
                or type(data.get("year")) is not int:
            return None, 400
        return {"title": data["title"], "year": data["year"]}, None

    def do_GET(self):
        t = self.target()
        if t == "all":
            return self.send_json(200, list(books.values()))
        if t not in books:
            return self.send_json(404, {"error": "not found"})
        etag = '"%s"' % hashlib.sha256(json.dumps(books[t]).encode()).hexdigest()[:16]
        cache = {"ETag": etag, "Cache-Control": "no-cache"}  # одинаковы в 200 и 304
        if self.headers.get("If-None-Match") == etag:  # упрощение: сравнение одной строки
            return self.send_json(304, None, cache)
        self.send_json(200, books[t], cache)

    def do_POST(self):
        global next_id
        t = self.target()
        if t is None:
            return self.send_json(404, {"error": "not found"})
        if t != "all":
            return self.send_json(405, {"error": "use PUT"}, {"Allow": "GET, PUT, DELETE"})
        book, err = self.read_book()
        if err:
            return self.send_json(err, {"error": "bad request body"})
        books[next_id] = {"id": next_id, **book}
        self.send_json(201, books[next_id], {"Location": "/books/%d" % next_id})
        next_id += 1

    def do_PUT(self):
        t = self.target()
        if t not in books:
            return self.send_json(404, {"error": "not found"})
        book, err = self.read_book()
        if err:
            return self.send_json(err, {"error": "bad request body"})
        books[t] = {"id": t, **book}
        self.send_json(200, books[t])

    def do_DELETE(self):
        t = self.target()
        if t not in books:
            return self.send_json(404, {"error": "not found"})
        del books[t]
        self.send_json(204)


HTTPServer(("127.0.0.1", 8000), BooksAPI).serve_forever()

Две детали защиты. to_int принимает только ASCII-цифры ограниченной длины: '²'.isdigit() в Python истинно, а int('²') падает, как и int() на 5000 цифр в Python 3.11+. Ответы с ошибкой закрывают соединение (Connection: close): тело запроса там могло остаться непрочитанным, и на keep-alive-соединении HTTP/1.1 сервер принял бы его байты за следующий запрос (без этой строки прогон дал 400 Bad request syntax на следующий GET).

Во втором терминале создадим книгу и повторим тот же POST:

curl -s -i -X POST http://127.0.0.1:8000/books -H 'Content-Type: application/json' -d '{"title": "Refactoring", "year": 1999}'
curl -s -X POST http://127.0.0.1:8000/books -H 'Content-Type: application/json' -d '{"title": "Refactoring", "year": 1999}'

Первый ответ (строки Server и Date здесь и дальше опущены):

HTTP/1.1 201 Created
Location: /books/2
Content-Type: application/json
Content-Length: 47

{"id": 2, "title": "Refactoring", "year": 1999}

Второй POST вернет {"id": 3, "title": "Refactoring", "year": 1999} — появилась вторая копия. Так выглядит неидемпотентность POST. Теперь дважды заменим книгу 2 и дважды удалим книгу 3:

curl -s -X PUT http://127.0.0.1:8000/books/2 -H 'Content-Type: application/json' -d '{"title": "Refactoring, 2nd ed.", "year": 2018}'
curl -s -X PUT http://127.0.0.1:8000/books/2 -H 'Content-Type: application/json' -d '{"title": "Refactoring, 2nd ed.", "year": 2018}'
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE http://127.0.0.1:8000/books/3
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE http://127.0.0.1:8000/books/3

Оба PUT вернут одинаковое {"id": 2, "title": "Refactoring, 2nd ed.", "year": 2018}, DELETE напечатает 204, затем 404. curl -s http://127.0.0.1:8000/books покажет две книги: 1 и 2.

Кэширование через ETag

Сначала обычный запрос — сервер возвращает книгу и ее отпечаток ETag:

curl -s -i http://127.0.0.1:8000/books/1
HTTP/1.1 200 OK
ETag: "9f2dbd2c58f0f1b0"
Cache-Control: no-cache
Content-Type: application/json
Content-Length: 40

{"id": 1, "title": "SICP", "year": 1985}

Клиент с сохраненной копией спрашивает, изменилось ли что-то, и передает этот ETag в If-None-Match:

curl -s -i http://127.0.0.1:8000/books/1 -H 'If-None-Match: "9f2dbd2c58f0f1b0"'
HTTP/1.1 304 Not Modified
ETag: "9f2dbd2c58f0f1b0"
Cache-Control: no-cache

Тела нет — клиент берет свою копию. ETag и Cache-Control в 304 те же, что в 200: RFC 9110 требует, чтобы 304 содержал Cache-Control и ETag, если они были бы отправлены в ответе 200 на тот же запрос, иначе кэш может неверно обновить сведения о сохраненной копии. Поэтому в коде оба заголовка собраны в один словарь cache. Cache-Control: no-cache значит «хранить можно, но перед использованием сверяйся с сервером», а не «не кэшировать». Упрощение учебного примера: If-None-Match сравнивается как одна строка, а по RFC 9110 заголовок может содержать список тегов или *. После PUT книги 1 ETag меняется, и тот же запрос вернет 200 с новым телом.

Коды ответов HTTP

По коду клиент решает, повторять ли запрос, чинить ли его или показать ошибку. В таблице разделено, что код означает по RFC 9110 и где его возвращает наш пример — все коды таблицы учебный сервер реально отдает. Для ошибок бизнес-логики часто добавляют 409 Conflict (конфликт с текущим состоянием) и 422 Unprocessable Content (JSON верный, данные не проходят правила).

Код Что означает по RFC 9110 В примере
200 OK успешный GET или PUT с телом GET /books/1
201 Created ресурс создан, адрес — в Location POST /books
204 No Content успех без тела DELETE /books/2
304 Not Modified представление не менялось GET с If-None-Match
400 Bad Request сломанный JSON, нет поля, неверный тип {"title": "No year"}
404 Not Found ресурса нет GET /books/99
405 Method Not Allowed метод известен серверу, но не поддерживается этим ресурсом; обязателен Allow POST /books/1
413 Content Too Large тело больше лимита тело больше 10 000 байт (Python 3.12 пишет старое название Request Entity Too Large)
501 Not Implemented сервер не распознает метод или не поддерживает его ни для одного ресурса PATCH /books/1

На POST /books/1 наш код отвечает 405 и Allow: GET, PUT, DELETE: POST сервер поддерживает, но только для коллекции /books. PATCH не реализован ни для одного ресурса — в классе нет метода do_PATCH, и http.server сам вернул 501 Unsupported method ('PATCH') с HTML-страницей вместо JSON. Для такого сервера 501 по смыслу уместен, в реальном API стоит только привести тело ошибки к общему формату. Выбор между 405 и 501 зависит от того, поддерживает ли приложение метод в целом: если PATCH работает, например, для /users/1, но не для /books/1, на /books/1 нужен 405 с Allow; если метод не реализован нигде, допустим 501. Еще одна частая путаница: 401 — «не представился или токен недействителен» (сервер обязан прислать WWW-Authenticate), 403 — «запрос понят, но в доступе отказано»: чаще всего личность известна, а прав нет, но по RFC 9110 причина может быть и не в учетных данных.

REST, RPC и GraphQL

Критерий REST RPC (JSON-RPC, gRPC) GraphQL
Единица API ресурс и метод HTTP процедура createBook(...) схема типов и запросы
HTTP-кэш штатно для GET обычно нет запросы чаще через POST, кэш на клиенте или сервере
Поля ответа задает сервер задает сервер выбирает клиент
Где уместен CRUD над сущностями, публичные API действия, вызовы между сервисами много клиентов с разными срезами связанных данных

Если предметная область — сущности, которые создают, читают, меняют и удаляют, REST ложится естественно. Если API — набор действий («рассчитать», «перевести»), честнее RPC.

Что еще нужно в production

Пример учебный: данные в памяти, однопоточный сервер, нет авторизации. Проверка типов, лимит тела и адрес 127.0.0.1 — минимальная защита, не готовое решение. Для реального сервиса нужны HTTPS и аутентификация с ответами 401/403; фреймворк и база данных; ограничение частоты (429); единый формат ошибок (Problem Details, RFC 9457); пагинация и версионирование; If-Match с ETag и ответ 412 против потерянных обновлений; ключ идемпотентности для опасных POST (оплаты); документация OpenAPI и журналирование без токенов и персональных данных.

Если не получилось

Симптом Что делать
OSError: [Errno 48] Address already in use (macOS; в Linux Errno 98) порт 8000 занят: остановите прежний экземпляр
curl: (7) Failed to connect to 127.0.0.1 port 8000 сервер не запущен или упал — смотрите первый терминал
400 на любой POST в Windows cmd не понимает одинарные кавычки, а Windows PowerShell 5.1 (где curl — псевдоним Invoke-WebRequest, нужен curl.exe) теряет внутренние кавычки при передаче аргументов. Надежнее всего положить JSON в файл book.json и отправить curl.exe ... -d @book.json
400 при верном JSON тип не тот: "year": "1999" — строка, true — не число

Выводы

  • REST — архитектурный стиль с шестью ограничениями Филдинга (одно опционально), а не протокол; REST API — HTTP API, следующий этим ограничениям.
  • Адрес называет ресурс, метод HTTP задает действие; глаголы в URL — признак RPC-стиля.
  • GET, PUT и DELETE идемпотентны, POST нет; идемпотентность — про состояние сервера, а не про одинаковый код ответа.
  • Коды ответов — часть контракта: 201 с Location, 204, 304, 400, 404, 405 с Allow клиент обрабатывает по-разному.
  • REST удобен для CRUD над сущностями, RPC — для действий, GraphQL — для гибких выборок связанных данных.

Где применяется / связь с практикой

REST API есть почти в любой backend- и интеграционной задаче. Сложнее всего не написать обработчик, а спроектировать контракт: ресурсы, ошибки, версии и совместимость при изменениях.

Освойте тему на практике

Этому посвящен курс «Проектирование API». Посмотреть формат занятий можно на бесплатных открытых уроках Otus.

FAQ

REST обязательно использует JSON?
Нет. Представлением может быть JSON, XML, HTML или бинарный формат, стороны договариваются через Content-Type и Accept. JSON просто самый распространенный.

Можно ли построить REST без HTTP?
Теоретически да: Филдинг описывал стиль независимо от протокола. На практике почти все REST API работают поверх HTTP, потому что его методы, коды и кэширование ложатся на ограничения напрямую.

Чем PUT отличается от PATCH?
PUT заменяет ресурс целиком и идемпотентен. PATCH присылает набор изменений (например, JSON Merge Patch), его идемпотентность зависит от формата изменений.

OTUS Журнал
Бесплатные открытые уроки (поп-ап)