Первый REST API для мобильного приложения: как мы учились проектировать API в 2012 году
Весной 2012 года мы делали первый самостоятельный REST API для Android-приложения. До этого у нас были только веб-сайты — форм-сабмит, серверный рендеринг, сессии в куках. API для мобильного клиента был другим миром.
Мы прочли Roy Fielding's диссертацию (ну, её реферат). Прочли несколько статей на Хабре. И начали делать. Вот что вышло.
Ошибка №1: URL как имена процедур
Первая версия нашего API выглядела так:
GET /api/getUser?id=123
POST /api/createOrder
GET /api/getUserOrders?userId=123&status=active
POST /api/cancelOrder?orderId=456
GET /api/getProductList?categoryId=2&page=1
Технически работало. Но это RPC через HTTP, не REST. Проблема проявилась через месяц: у нас было 40 endpoint'ов, все начинались с глагола, никакой структуры. Новый разработчик не мог предсказать URL — только смотреть в документацию.
Переписали по принципу «ресурсы, а не действия»:
GET /api/v1/users/123 → получить пользователя
GET /api/v1/users/123/orders → заказы пользователя
GET /api/v1/orders?status=active → список заказов с фильтром
POST /api/v1/orders → создать заказ
DELETE /api/v1/orders/456 → отменить заказ (не GET!)
GET /api/v1/products?category=2 → список продуктов
Глаголы — HTTP-методы. Существительные — в URL. Новый разработчик мог угадать структуру.
Ошибка №2: API без версионирования
Первый месяц мы жили без версий в URL. Когда Android-клиент уже был у тестеров, нам потребовалось изменить структуру ответа /api/orders — добавить поле и переименовать существующее.
Мы изменили. Старый клиент сломался у тестеров. Нам повезло — приложение ещё не было в Play Store.
Версионирование через URL (/api/v1/) — самый простой вариант:
// routes.php — маршрутизация по версии
$version = $router->getVersion(); // Извлекает 'v1' из URL
switch ($version) {
case 'v1':
require 'controllers/v1/' . $controller . '.php';
break;
case 'v2':
require 'controllers/v2/' . $controller . '.php';
break;
default:
jsonResponse(400, ['error' => 'Unknown API version']);
}
Правило, которое мы приняли: v1 никогда не ломается. Новые фичи — в v2. Deprecated endpoint'ы в v1 живут ещё 6 месяцев, потом удаляются с предупреждением в заголовке.
// Deprecated endpoint: предупреждаем, но не ломаем
header('X-Deprecated: This endpoint will be removed on 2013-01-01. Use /v2/orders instead.');
// Возвращаем старую структуру ответа как обычно
Ошибка №3: токены в GET-параметрах
Первая аутентификация выглядела так:
GET /api/v1/orders?user_id=123&token=abc123def456
Проблема: токен в URL попадает в:
- Логи сервера (access.log)
- Историю браузера
- Referrer-заголовок при переходе на другой сайт
- Кэши прокси-серверов
Мы читали о HTTPS и думали, что это защищает. HTTPS шифрует передачу, но не мешает попаданию URL в логи на сервере.
Правильно: токен в заголовке Authorization:
GET /api/v1/orders
Authorization: Bearer abc123def456
// Извлечение токена из заголовка
function getAuthToken(): ?string {
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (preg_match('/^Bearer\s+(.+)$/i', $header, $m)) {
return $m[1];
}
return null;
}
function authenticate(): ?array {
$token = getAuthToken();
if (!$token) {
jsonResponse(401, ['error' => 'Authorization required']);
exit;
}
$user = User::findByToken($token);
if (!$user || $user->token_expires_at < time()) {
jsonResponse(401, ['error' => 'Invalid or expired token']);
exit;
}
return $user;
}
Ошибка №4: HTTP 200 на всё подряд
Первое время мы возвращали HTTP 200 с полем success: false при ошибках. Это заставляло клиент парсить тело ответа, чтобы понять — успех это или ошибка:
// Плохо: 200 OK с признаком ошибки внутри
HTTP/1.1 200 OK
{"success": false, "error": "User not found"}
Правильно использовать семантику HTTP-статусов:
function jsonResponse(int $status, array $data): void {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
// Использование
if (!$user) {
jsonResponse(404, ['error' => 'User not found', 'code' => 'USER_NOT_FOUND']);
}
if (!$user->canAccessOrder($orderId)) {
jsonResponse(403, ['error' => 'Access denied', 'code' => 'FORBIDDEN']);
}
if (!$validator->passes()) {
jsonResponse(422, ['error' => 'Validation failed', 'fields' => $validator->errors()]);
}
// Успех
jsonResponse(200, ['data' => $order]);
// Создание ресурса
jsonResponse(201, ['data' => $newOrder, 'id' => $newOrder->id]);
Клиент на Android мог теперь проверить response.code() и обрабатывать ошибки без парсинга JSON:
if (!response.isSuccessful()) {
// 4xx — ошибка клиента, показать пользователю
// 5xx — ошибка сервера, показать "попробуйте позже"
handleError(response.code());
return;
}
// 2xx — обработать успешный ответ
Что получилось в итоге
Через три месяца и две итерации у нас был API, которым мы не стыдились:
- URL-структура на основе ресурсов
- Версионирование с
/v1/в пути - JWT-подобные токены в заголовке
Authorization - Корректные HTTP-статусы
- Единый формат ошибки
{"error": "...", "code": "..."} - Документация в Markdown — по одной странице на endpoint
Первая версия API — это всегда обучение в бою. Важно не то, что вы сделали ошибки с версионированием и токенами. Важно что вы их заметили, пока приложение было у тестеров, а не у 50,000 пользователей с разными версиями клиента, которые все сломались одновременно.