RESTful API — Полное руководство
REST (Representational State Transfer) — архитектурный стиль для создания веб-сервисов. Простой, масштабируемый, понятный. Стандарт индустрии.
🌐 Что такое REST
REST (Representational State Transfer) — это архитектурный стиль для создания веб-сервисов, предложенный Роем Филдингом в 2000 году. Это не протокол и не стандарт, а набор принципов проектирования API.
REST API — это способ общения между клиентом и сервером через HTTP.
Клиент (браузер, мобильное приложение) отправляет HTTP-запросы.
Сервер обрабатывает их и возвращает данные (обычно JSON).
Это как меню в ресторане: вы делаете заказ (запрос),
официант (HTTP) передаёт его на кухню (сервер), и вам приносят блюдо (ответ).
Почему REST так популярен?
- Простота — использует стандартный HTTP, знакомый всем
- Универсальность — работает с любым клиентом (web, mobile, IoT)
- Масштабируемость — stateless архитектура легко масштабируется
- Кешируемость — HTTP-кеширование из коробки
- Читаемость — URL понятны человеку
REST — архитектурный стиль (теория).
RESTful — API, которое следует принципам REST (практика).
Если ваш API соблюдает все принципы REST — он RESTful.
📐 Принципы REST
REST определяет 6 ключевых принципов (constraints). API считается RESTful, если соблюдает их все:
📮 HTTP методы (CRUD)
REST использует стандартные HTTP-методы для операций над ресурсами. Это соответствует CRUD-операциям:
| Метод | CRUD | Описание | Идемпотентность |
|---|---|---|---|
| GET | Read | Получить ресурс(ы). Не изменяет данные. | ✅ Да |
| POST | Create | Создать новый ресурс. | ❌ Нет |
| PUT | Update | Полностью заменить ресурс. | ✅ Да |
| PATCH | Update | Частично обновить ресурс. | ❌ Нет* |
| DELETE | Delete | Удалить ресурс. | ✅ Да |
Идемпотентный метод — повторный вызов даёт тот же результат.
DELETE /users/1 — первый раз удалит, второй вернёт 404. Результат один — пользователя нет.
POST /users — каждый вызов создаёт нового пользователя. Не идемпотентен.
📊 HTTP статус-коды
Статус-код показывает результат запроса. Используйте правильные коды — это часть контракта вашего API:
- 200OK — успешный GET/PUT/PATCH
- 201Created — ресурс создан (POST)
- 204No Content — успех без тела (DELETE)
- 301Moved Permanently
- 302Found (временный)
- 304Not Modified (кеш)
- 400Bad Request — неверные данные
- 401Unauthorized — не авторизован
- 403Forbidden — нет прав
- 404Not Found — не найдено
- 409Conflict — конфликт данных
- 422Unprocessable — ошибка валидации
- 429Too Many Requests — rate limit
- 500Internal Server Error
- 502Bad Gateway
- 503Service Unavailable
- 504Gateway Timeout
🔗 Дизайн URL (эндпоинтов)
URL в REST — это адреса ресурсов (существительные), а не действий (глаголы). Действие определяется HTTP-методом.
Вложенные ресурсы
Фильтрация, сортировка, пагинация
❌ Глаголы в URL: /getBooks, /createUser
❌ Действия в URL: /books/delete/123
❌ Единственное число: /book/123 (используйте множественное)
❌ CamelCase: /userOrders (используйте kebab-case или snake_case)
📤 Запросы и ответы
Структура запроса
Структура ответа (успех)
{ "status": "success", "data": { "id": 42, "name": "John Doe", "email": "john@example.com", "created_at": "2024-01-15T10:30:00Z" } }
Структура ответа (ошибка)
{ "status": "error", "message": "Validation failed", "errors": [ { "field": "email", "message": "Email already exists" }, { "field": "password", "message": "Password must be at least 8 characters" } ] }
Ответ списка с пагинацией
{ "status": "success", "data": [ { "id": 11, "name": "User 11" }, { "id": 12, "name": "User 12" }, // ... ещё 8 записей ], "meta": { "current_page": 2, "per_page": 10, "total_pages": 5, "total_count": 47 }, "links": { "self": "/api/v1/users?page=2&limit=10", "first": "/api/v1/users?page=1&limit=10", "prev": "/api/v1/users?page=1&limit=10", "next": "/api/v1/users?page=3&limit=10", "last": "/api/v1/users?page=5&limit=10" } }
🌶️ Пример: Flask REST API
Flask — минималистичный Python-фреймворк. Отлично подходит для понимания основ REST API.
from flask import Flask, jsonify, request, abort from datetime import datetime app = Flask(__name__) # ═══════════════════════════════════════════════════════════════ # ПРОСТАЯ "БАЗА ДАННЫХ" # ═══════════════════════════════════════════════════════════════ # В реальном проекте это будет PostgreSQL, MongoDB и т.д. books_db = { 1: { "id": 1, "title": "Война и мир", "author": "Лев Толстой", "year": 1869, "created_at": "2024-01-01T10:00:00Z" }, 2: { "id": 2, "title": "Преступление и наказание", "author": "Фёдор Достоевский", "year": 1866, "created_at": "2024-01-02T12:00:00Z" } } next_id = 3 # ═══════════════════════════════════════════════════════════════ # ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ # ═══════════════════════════════════════════════════════════════ def success_response(data, status_code=200): """Формирует успешный ответ""" return jsonify({ "status": "success", "data": data }), status_code def error_response(message, status_code, errors=None): """Формирует ответ с ошибкой""" response = { "status": "error", "message": message } if errors: response["errors"] = errors return jsonify(response), status_code def validate_book(data, partial=False): """Валидация данных книги""" errors = [] if not partial: if not data.get("title"): errors.append({"field": "title", "message": "Title is required"}) if not data.get("author"): errors.append({"field": "author", "message": "Author is required"}) if "year" in data: if not isinstance(data["year"], int) or data["year"] < 0: errors.append({"field": "year", "message": "Year must be a positive integer"}) return errors # ═══════════════════════════════════════════════════════════════ # REST ENDPOINTS # ═══════════════════════════════════════════════════════════════ # ─────────────── GET /books — Получить все книги ─────────────── @app.route('/api/v1/books', methods=['GET']) def get_books(): """ Получить список всех книг Query параметры: - author: фильтр по автору - year: фильтр по году - sort: сортировка (title, -title, year, -year) """ books = list(books_db.values()) # Фильтрация author = request.args.get('author') if author: books = [b for b in books if author.lower() in b['author'].lower()] year = request.args.get('year', type=int) if year: books = [b for b in books if b['year'] == year] # Сортировка sort = request.args.get('sort', 'id') reverse = sort.startswith('-') sort_field = sort.lstrip('-') if sort_field in ['id', 'title', 'year']: books = sorted(books, key=lambda x: x.get(sort_field, ''), reverse=reverse) return success_response(books) # ─────────────── GET /books/:id — Получить одну книгу ─────────────── @app.route('/api/v1/books/<int:book_id>', methods=['GET']) def get_book(book_id): """Получить книгу по ID""" book = books_db.get(book_id) if not book: return error_response(f"Book with id {book_id} not found", 404) return success_response(book) # ─────────────── POST /books — Создать книгу ─────────────── @app.route('/api/v1/books', methods=['POST']) def create_book(): """Создать новую книгу""" global next_id # Проверяем Content-Type if not request.is_json: return error_response("Content-Type must be application/json", 415) data = request.get_json() # Валидация errors = validate_book(data) if errors: return error_response("Validation failed", 422, errors) # Создаём книгу book = { "id": next_id, "title": data["title"], "author": data["author"], "year": data.get("year"), "created_at": datetime.utcnow().isoformat() + "Z" } books_db[next_id] = book next_id += 1 return success_response(book, 201) # ─────────────── PUT /books/:id — Полное обновление ─────────────── @app.route('/api/v1/books/<int:book_id>', methods=['PUT']) def update_book(book_id): """Полностью заменить книгу""" if book_id not in books_db: return error_response(f"Book with id {book_id} not found", 404) if not request.is_json: return error_response("Content-Type must be application/json", 415) data = request.get_json() # Валидация (все поля обязательны для PUT) errors = validate_book(data, partial=False) if errors: return error_response("Validation failed", 422, errors) # Обновляем (полная замена, но сохраняем id и created_at) old_book = books_db[book_id] books_db[book_id] = { "id": book_id, "title": data["title"], "author": data["author"], "year": data.get("year"), "created_at": old_book["created_at"], "updated_at": datetime.utcnow().isoformat() + "Z" } return success_response(books_db[book_id]) # ─────────────── PATCH /books/:id — Частичное обновление ─────────────── @app.route('/api/v1/books/<int:book_id>', methods=['PATCH']) def patch_book(book_id): """Частично обновить книгу""" if book_id not in books_db: return error_response(f"Book with id {book_id} not found", 404) if not request.is_json: return error_response("Content-Type must be application/json", 415) data = request.get_json() # Валидация (частичная) errors = validate_book(data, partial=True) if errors: return error_response("Validation failed", 422, errors) # Обновляем только переданные поля book = books_db[book_id] for field in ['title', 'author', 'year']: if field in data: book[field] = data[field] book["updated_at"] = datetime.utcnow().isoformat() + "Z" return success_response(book) # ─────────────── DELETE /books/:id — Удалить книгу ─────────────── @app.route('/api/v1/books/<int:book_id>', methods=['DELETE']) def delete_book(book_id): """Удалить книгу""" if book_id not in books_db: return error_response(f"Book with id {book_id} not found", 404) del books_db[book_id] # 204 No Content — стандарт для успешного DELETE return '', 204 # ═══════════════════════════════════════════════════════════════ # ОБРАБОТКА ОШИБОК # ═══════════════════════════════════════════════════════════════ @app.errorhandler(404) def not_found(error): return error_response("Resource not found", 404) @app.errorhandler(500) def internal_error(error): return error_response("Internal server error", 500) # ═══════════════════════════════════════════════════════════════ # ЗАПУСК # ═══════════════════════════════════════════════════════════════ if __name__ == '__main__': app.run(debug=True, port=5000)
Тестирование Flask API
⚡ Пример: FastAPI (современный подход)
FastAPI — современный Python-фреймворк с автоматической валидацией, документацией и поддержкой async. Рекомендуется для новых проектов.
- ✅ Простой, минимум магии
- ✅ Много документации
- ✅ Большая экосистема
- ⚠️ Валидация вручную
- ⚠️ Нет async из коробки
- ⚠️ Swagger вручную
- ✅ Автоматическая валидация
- ✅ Swagger/OpenAPI из коробки
- ✅ Async поддержка
- ✅ Type hints
- ✅ Очень быстрый
- ⚠️ Моложе, меньше плагинов
from fastapi import FastAPI, HTTPException, Query from pydantic import BaseModel, Field from typing import Optional, List from datetime import datetime from enum import Enum app = FastAPI( title="Books API", description="REST API для управления книгами", version="1.0.0" ) # ═══════════════════════════════════════════════════════════════ # PYDANTIC МОДЕЛИ (СХЕМЫ) # ═══════════════════════════════════════════════════════════════ class BookBase(BaseModel): """Базовые поля книги""" title: str = Field(..., min_length=1, max_length=200, example="Война и мир") author: str = Field(..., min_length=1, max_length=100, example="Лев Толстой") year: Optional[int] = Field(None, ge=0, le=2100, example=1869) class BookCreate(BookBase): """Схема для создания книги""" pass class BookUpdate(BaseModel): """Схема для частичного обновления""" title: Optional[str] = Field(None, min_length=1, max_length=200) author: Optional[str] = Field(None, min_length=1, max_length=100) year: Optional[int] = Field(None, ge=0, le=2100) class BookResponse(BookBase): """Схема ответа с книгой""" id: int created_at: datetime updated_at: Optional[datetime] = None class Config: from_attributes = True class SortOrder(str, Enum): """Порядок сортировки""" asc = "asc" desc = "desc" # ═══════════════════════════════════════════════════════════════ # ПРОСТАЯ "БАЗА ДАННЫХ" # ═══════════════════════════════════════════════════════════════ books_db: dict[int, dict] = { 1: { "id": 1, "title": "Война и мир", "author": "Лев Толстой", "year": 1869, "created_at": datetime(2024, 1, 1, 10, 0), "updated_at": None }, 2: { "id": 2, "title": "Преступление и наказание", "author": "Фёдор Достоевский", "year": 1866, "created_at": datetime(2024, 1, 2, 12, 0), "updated_at": None } } next_id = 3 # ═══════════════════════════════════════════════════════════════ # REST ENDPOINTS # ═══════════════════════════════════════════════════════════════ @app.get("/api/v1/books", response_model=List[BookResponse], tags=["Books"]) async def get_books( author: Optional[str] = Query(None, description="Фильтр по автору"), year: Optional[int] = Query(None, description="Фильтр по году"), sort_by: str = Query("id", enum=["id", "title", "year"]), order: SortOrder = Query(SortOrder.asc) ): """Получить список всех книг с фильтрацией и сортировкой""" books = list(books_db.values()) # Фильтрация if author: books = [b for b in books if author.lower() in b["author"].lower()] if year: books = [b for b in books if b["year"] == year] # Сортировка reverse = order == SortOrder.desc books = sorted(books, key=lambda x: x.get(sort_by) or "", reverse=reverse) return books @app.get("/api/v1/books/{book_id}", response_model=BookResponse, tags=["Books"]) async def get_book(book_id: int): """Получить книгу по ID""" if book_id not in books_db: raise HTTPException(status_code=404, detail=f"Book {book_id} not found") return books_db[book_id] @app.post("/api/v1/books", response_model=BookResponse, status_code=201, tags=["Books"]) async def create_book(book: BookCreate): """Создать новую книгу""" global next_id new_book = { "id": next_id, **book.model_dump(), "created_at": datetime.utcnow(), "updated_at": None } books_db[next_id] = new_book next_id += 1 return new_book @app.put("/api/v1/books/{book_id}", response_model=BookResponse, tags=["Books"]) async def update_book(book_id: int, book: BookCreate): """Полностью обновить книгу""" if book_id not in books_db: raise HTTPException(status_code=404, detail=f"Book {book_id} not found") old_book = books_db[book_id] books_db[book_id] = { "id": book_id, **book.model_dump(), "created_at": old_book["created_at"], "updated_at": datetime.utcnow() } return books_db[book_id] @app.patch("/api/v1/books/{book_id}", response_model=BookResponse, tags=["Books"]) async def patch_book(book_id: int, book: BookUpdate): """Частично обновить книгу""" if book_id not in books_db: raise HTTPException(status_code=404, detail=f"Book {book_id} not found") # Обновляем только переданные поля update_data = book.model_dump(exclude_unset=True) for field, value in update_data.items(): books_db[book_id][field] = value books_db[book_id]["updated_at"] = datetime.utcnow() return books_db[book_id] @app.delete("/api/v1/books/{book_id}", status_code=204, tags=["Books"]) async def delete_book(book_id: int): """Удалить книгу""" if book_id not in books_db: raise HTTPException(status_code=404, detail=f"Book {book_id} not found") del books_db[book_id] return None # ═══════════════════════════════════════════════════════════════ # HEALTH CHECK # ═══════════════════════════════════════════════════════════════ @app.get("/health", tags=["System"]) async def health_check(): """Проверка работоспособности API""" return {"status": "healthy", "timestamp": datetime.utcnow()}
FastAPI автоматически генерирует:
• Swagger UI — интерактивная документация на /docs
• OpenAPI схему — на /openapi.json
• Валидацию — из Pydantic моделей и type hints
• Сериализацию — автоматическое преобразование в JSON
✅ Best Practices
❌ Глаголы в URL: /getUser, /deleteBook
❌ Игнорирование статус-кодов: всегда возвращать 200
❌ Передача секретов в URL: /api?password=123
❌ Нет валидации: принимать любые данные
❌ Разные форматы: camelCase и snake_case вперемешку
❌ Огромные ответы: возвращать все поля всегда
❓ FAQ
• Простой, понятный, стандартный
• Хорошее кеширование через HTTP
• Подходит для большинства CRUD-приложений
GraphQL:
• Гибкие запросы (клиент выбирает поля)
• Один эндпоинт для всего
• Сложнее кеширование
• Подходит для сложных связанных данных
Совет: Начните с REST. Переходите на GraphQL, если появятся проблемы с over-fetching/under-fetching.
Нужно отправить ВСЕ поля. Если поле не отправлено — оно станет null.
PATCH — частичное обновление:
Отправляете только те поля, которые хотите изменить.
Пример:
PUT /users/1 с {"name": "John"} — email станет nullPATCH /users/1 с {"name": "John"} — изменится только nameНа практике: PATCH используется чаще, он удобнее.
1. JWT (JSON Web Tokens):
• Токен в заголовке:
Authorization: Bearer <token>• Stateless — сервер не хранит сессии
• Подходит для микросервисов
2. OAuth 2.0:
• Для интеграции с внешними сервисами
• "Войти через Google/GitHub"
3. API Keys:
• Для server-to-server
• Заголовок:
X-API-Key: <key>
1. В URL (рекомендуется):
/api/v1/users, /api/v2/usersПлюсы: явно, просто, кешируется
2. В заголовке:
Accept: application/vnd.api+json;version=2Плюсы: чистые URL
3. Query параметр:
/api/users?version=2Редко используется
Совет: Используйте версию в URL — это самый понятный способ.
1. Правильный статус-код: 400, 401, 403, 404, 422, 500...
2. Понятное сообщение:
{"error": "User not found"} — не {"error": "Error"}3. Детали для валидации:
{"errors": [{"field": "email", "message": "Invalid format"}]}4. Не показывайте внутренности:
Скрывайте stack traces и SQL-ошибки в продакшене.
• Меньше магии, всё понятно
• Больше туториалов и ресурсов
Для новых проектов: FastAPI
• Современный, быстрый
• Автоматическая документация
• Автоматическая валидация
• Type hints
Совет: Изучите Flask для понимания основ, затем переходите на FastAPI для реальных проектов.
1. Postman / Insomnia:
GUI для ручного тестирования запросов
2. cURL:
Командная строка:
curl -X GET http://api.example.com/users3. HTTPie:
Удобнее cURL:
http GET api.example.com/users4. pytest + requests:
Автоматические тесты на Python
5. Встроенные клиенты:
FastAPI TestClient, Flask test_client()
📋 Шпаргалка по REST API
╔═══════════════════════════════════════════════════════════════════════════╗ ║ REST API QUICK REFERENCE ║ ╚═══════════════════════════════════════════════════════════════════════════╝ ┌─────────────────────────────────────────────────────────────────────────────┐ │ HTTP МЕТОДЫ │ ├─────────────────────────────────────────────────────────────────────────────┤ │ GET /resources → Получить список │ │ GET /resources/:id → Получить один │ │ POST /resources → Создать новый │ │ PUT /resources/:id → Полностью заменить │ │ PATCH /resources/:id → Частично обновить │ │ DELETE /resources/:id → Удалить │ └─────────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────────┐ │ СТАТУС-КОДЫ │ ├─────────────────────────────────────────────────────────────────────────────┤ │ 200 OK → GET/PUT/PATCH успешно │ │ 201 Created → POST успешно (ресурс создан) │ │ 204 No Content → DELETE успешно (нет тела) │ │ 400 Bad Request → Неверный запрос │ │ 401 Unauthorized → Не авторизован │ │ 403 Forbidden → Нет прав доступа │ │ 404 Not Found → Ресурс не найден │ │ 422 Unprocessable → Ошибка валидации │ │ 500 Server Error → Ошибка сервера │ └─────────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────────┐ │ ПРИМЕРЫ URL │ ├─────────────────────────────────────────────────────────────────────────────┤ │ /api/v1/users → Коллекция пользователей │ │ /api/v1/users/123 → Пользователь #123 │ │ /api/v1/users/123/orders → Заказы пользователя #123 │ │ /api/v1/users?status=active → Фильтрация │ │ /api/v1/users?sort=-created_at → Сортировка (- = DESC) │ │ /api/v1/users?page=2&limit=20 → Пагинация │ └─────────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────────┐ │ ЗАГОЛОВКИ │ ├─────────────────────────────────────────────────────────────────────────────┤ │ Content-Type: application/json → Тип тела запроса │ │ Accept: application/json → Ожидаемый тип ответа │ │ Authorization: Bearer <token> → JWT аутентификация │ │ X-Request-ID: uuid → ID запроса для трейсинга │ └─────────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────────┐ │ ФОРМАТ ОТВЕТА │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ // Успешный ответ │ │ { │ │ "status": "success", │ │ "data": { ... } │ │ } │ │ │ │ // Ответ с ошибкой │ │ { │ │ "status": "error", │ │ "message": "Validation failed", │ │ "errors": [ │ │ { "field": "email", "message": "Invalid email" } │ │ ] │ │ } │ │ │ └─────────────────────────────────────────────────────────────────────────────┘
🧪 Пример тестирования API
import pytest from fastapi.testclient import TestClient from main import app # Создаём тестовый клиент client = TestClient(app) # ═══════════════════════════════════════════════════════════════ # ТЕСТЫ ДЛЯ GET # ═══════════════════════════════════════════════════════════════ def test_get_all_books(): """Тест получения всех книг""" response = client.get("/api/v1/books") assert response.status_code == 200 assert isinstance(response.json(), list) def test_get_book_by_id(): """Тест получения книги по ID""" response = client.get("/api/v1/books/1") assert response.status_code == 200 data = response.json() assert data["id"] == 1 assert "title" in data def test_get_book_not_found(): """Тест 404 для несуществующей книги""" response = client.get("/api/v1/books/99999") assert response.status_code == 404 # ═══════════════════════════════════════════════════════════════ # ТЕСТЫ ДЛЯ POST # ═══════════════════════════════════════════════════════════════ def test_create_book(): """Тест создания книги""" new_book = { "title": "Тестовая книга", "author": "Тестовый автор", "year": 2024 } response = client.post("/api/v1/books", json=new_book) assert response.status_code == 201 data = response.json() assert data["title"] == "Тестовая книга" assert "id" in data assert "created_at" in data def test_create_book_validation_error(): """Тест валидации при создании""" invalid_book = { "title": "", # Пустой title "author": "Test" } response = client.post("/api/v1/books", json=invalid_book) assert response.status_code == 422 # Unprocessable Entity # ═══════════════════════════════════════════════════════════════ # ТЕСТЫ ДЛЯ PATCH # ═══════════════════════════════════════════════════════════════ def test_patch_book(): """Тест частичного обновления""" update_data = {"year": 2025} response = client.patch("/api/v1/books/1", json=update_data) assert response.status_code == 200 data = response.json() assert data["year"] == 2025 assert data["updated_at"] is not None # ═══════════════════════════════════════════════════════════════ # ТЕСТЫ ДЛЯ DELETE # ═══════════════════════════════════════════════════════════════ def test_delete_book(): """Тест удаления книги""" # Сначала создаём книгу для удаления new_book = {"title": "To Delete", "author": "Test"} create_response = client.post("/api/v1/books", json=new_book) book_id = create_response.json()["id"] # Удаляем response = client.delete(f"/api/v1/books/{book_id}") assert response.status_code == 204 # Проверяем, что книга удалена get_response = client.get(f"/api/v1/books/{book_id}") assert get_response.status_code == 404 # ═══════════════════════════════════════════════════════════════ # ЗАПУСК ТЕСТОВ # ═══════════════════════════════════════════════════════════════ # pytest test_api.py -v
1. Создайте свой API — начните с Flask или FastAPI
2. Добавьте базу данных — SQLAlchemy или SQLModel
3. Реализуйте аутентификацию — JWT токены
4. Напишите тесты — pytest + TestClient
5. Задокументируйте — OpenAPI / Swagger
6. Задеплойте — Docker + любой хостинг
REST API — фундаментальный навык для любого разработчика.
Понимание REST открывает двери к бэкенд-разработке и интеграциям.