REST API на PHP: уроки первого мобильного бэкенда
В 2013 году заказчик попросил сделать iOS-приложение к уже работающему PHP-сайту. Нам нужен был API, через который приложение получало бы данные. До этого мы делали только веб-страницы - PHP генерировал HTML. Теперь нужно было отдавать JSON.
Казалось просто. Оказалось - нет.
Первая версия: не REST, а CRUD через GET
Первые эндпоинты выглядели так:
GET /api.php?action=get_products
GET /api.php?action=get_product&id=123
GET /api.php?action=create_product&name=Товар&price=500
GET /api.php?action=delete_product&id=123
Технически работало. Семантически - нарушало всё что можно.
Проблемы:
GETсaction=delete_product- получение данных удаляет что-то. Браузер может закэшировать GET.- Нет единообразия - каждый разработчик добавлял
actionпроизвольно. - Нет стандарта для ошибок - возвращали
{"success": false, "error": "что-то пошло не так"}без HTTP кодов.
Что такое настоящий REST
REST (Representational State Transfer) - это архитектурный стиль, не протокол. Ключевые принципы применительно к HTTP API:
Ресурсы, не действия. URL - это существительное (ресурс), метод HTTP - глагол.
Неправильно:
GET /api/getProduct?id=123
GET /api/deleteProduct?id=123
Правильно:
GET /api/products/123 - получить продукт
DELETE /api/products/123 - удалить продукт
POST /api/products - создать продукт
PUT /api/products/123 - обновить продукт целиком
PATCH /api/products/123 - обновить частично
HTTP-коды - это семантика. Не {"success": false}, а правильный код ответа:
200 OK - успешный запрос
201 Created - ресурс создан (ответ на POST)
204 No Content - успешно, нечего возвращать (ответ на DELETE)
400 Bad Request - ошибка клиента (невалидные данные)
401 Unauthorized - не аутентифицирован
403 Forbidden - аутентифицирован, но нет доступа
404 Not Found - ресурс не найден
422 Unprocessable Entity - данные невалидны (ошибки валидации)
500 Internal Server Error - ошибка сервера
Реализация на PHP 5.5
<?php
// index.php - единая точка входа
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Разбираем путь: /api/products/123 → ['products', '123']
$segments = array_filter(explode('/', trim($path, '/')));
$segments = array_values($segments);
// Пропускаем 'api' если он первый
if ($segments[0] === 'api') {
array_shift($segments);
}
$resource = $segments[0] ?? null; // 'products'
$id = $segments[1] ?? null; // '123' или null
// Роутинг
switch ($resource) {
case 'products':
require_once 'controllers/ProductController.php';
$controller = new ProductController();
$controller->handle($method, $id);
break;
default:
http_response_code(404);
echo json_encode(['error' => 'Resource not found']);
}
<?php
// controllers/ProductController.php
class ProductController {
private $db;
public function __construct() {
$this->db = Database::getInstance();
}
public function handle($method, $id) {
switch ($method) {
case 'GET':
$id ? $this->show($id) : $this->index();
break;
case 'POST':
$this->store();
break;
case 'PUT':
$this->update($id);
break;
case 'DELETE':
$this->destroy($id);
break;
default:
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
}
}
private function index() {
$stmt = $this->db->query('SELECT * FROM products WHERE active = 1');
$products = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['data' => $products]);
}
private function show($id) {
$stmt = $this->db->prepare('SELECT * FROM products WHERE id = ?');
$stmt->execute([$id]);
$product = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$product) {
http_response_code(404);
echo json_encode(['error' => 'Product not found']);
return;
}
echo json_encode(['data' => $product]);
}
private function store() {
$body = json_decode(file_get_contents('php://input'), true);
// Валидация
$errors = [];
if (empty($body['name'])) $errors['name'] = 'Name is required';
if (!isset($body['price']) || $body['price'] <= 0) $errors['price'] = 'Price must be positive';
if (!empty($errors)) {
http_response_code(422);
echo json_encode(['errors' => $errors]);
return;
}
$stmt = $this->db->prepare(
'INSERT INTO products (name, price, created_at) VALUES (?, ?, NOW())'
);
$stmt->execute([$body['name'], $body['price']]);
$id = $this->db->lastInsertId();
http_response_code(201);
echo json_encode(['data' => ['id' => $id, 'name' => $body['name'], 'price' => $body['price']]]);
}
private function destroy($id) {
$stmt = $this->db->prepare('DELETE FROM products WHERE id = ?');
$stmt->execute([$id]);
if ($stmt->rowCount() === 0) {
http_response_code(404);
echo json_encode(['error' => 'Product not found']);
return;
}
http_response_code(204);
}
}
Аутентификация без сессий
HTTP-сессии работают через куки - это нормально для браузера. Мобильное приложение хранить куки может, но это неудобно и нестандартно для API.
Решение в 2013 году - API-токены:
// При логине - генерируем токен и сохраняем в базу
POST /api/auth/login
Body: {"email": "user@example.com", "password": "secret"}
// Ответ:
{
"token": "a8f3c9b2e7d14f6a8b3c9...", // случайная строка, 64 символа
"expires_at": "2013-09-05T12:00:00Z"
}
// Все последующие запросы - токен в заголовке
GET /api/products
Authorization: Bearer a8f3c9b2e7d14f6a8b3c9...
// Middleware для проверки токена
function requireAuth() {
$headers = getallheaders();
$authHeader = $headers['Authorization'] ?? '';
if (!preg_match('/^Bearer (.+)$/', $authHeader, $matches)) {
http_response_code(401);
echo json_encode(['error' => 'Authentication required']);
exit;
}
$token = $matches[1];
$db = Database::getInstance();
$stmt = $db->prepare(
'SELECT user_id FROM api_tokens
WHERE token = ? AND expires_at > NOW() AND revoked = 0'
);
$stmt->execute([$token]);
$row = $stmt->fetch();
if (!$row) {
http_response_code(401);
echo json_encode(['error' => 'Invalid or expired token']);
exit;
}
return $row['user_id'];
}
// В контроллере
$userId = requireAuth(); // Прерывает выполнение если не авторизован
Версионирование API
Первая ошибка: не предусмотрели версионирование. Когда потребовалось изменить формат ответа, мобильное приложение сломалось - оно ожидало старую структуру.
Плохо: /api/products - нет версии
Хорошо: /api/v1/products - версия в URL
При смене формата создаёте /api/v2/products с новой структурой. Старое приложение продолжает работать через v1, новое использует v2. Когда все пользователи обновятся - v1 можно убрать.
Что бы мы сделали иначе
Использовали бы готовый микрофреймворк. В 2013 уже был Slim Framework для PHP:
// Slim 2.x - гораздо чище самописного роутера
$app = new \Slim\Slim();
$app->get('/api/v1/products', function() use ($app) {
// ...
});
$app->post('/api/v1/products', function() use ($app) {
// ...
});
$app->run();
Не изобретали бы велосипед с роутингом. Slim, Lumen, Silex - в 2013 году они уже были и решали именно эту задачу.
Сегодня для PHP-API это Laravel или Lumen. Для нового проекта мы бы выбрали Node.js + Express или Go. Но принципы REST - те же: ресурсы, HTTP-методы, правильные статус-коды, версионирование - не зависят от языка.