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), его идемпотентность зависит от формата изменений.



