О насБлогКонтакты
Backend5 августа 2013 г. 5 мин 14

REST API на PHP: уроки первого мобильного бэкенда

AunimedaAunimeda
📋 Содержание

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-методы, правильные статус-коды, версионирование - не зависят от языка.

Читайте также

Как перейти с PHP 5.6 на PHP 7.0 без поломки приложения (2016)aunimeda
Backend

Как перейти с PHP 5.6 на PHP 7.0 без поломки приложения (2016)

PHP 7.0 давал в 2 раза более высокую производительность по сравнению с PHP 5.6. Но миграция ломала старый код: mysql_* функции удалены, изменилась обработка ошибок, несовместимые изменения в типах. Мы мигрировали 4 проекта без простоя — вот точный чеклист.

Оптимизация MySQL: переход с MyISAM на InnoDB и кэширование через Memcachedaunimeda
Backend

Оптимизация MySQL: переход с MyISAM на InnoDB и кэширование через Memcached

Как мы спасли продакшен-базу от деградации под нагрузкой: миграция с MyISAM на InnoDB, настройка пула буферов и внедрение Memcached - реальный кейс 2011 года.

PostgreSQL на VPS: от 10 до 1000 запросов в секунду без смены железаaunimeda
Backend

PostgreSQL на VPS: от 10 до 1000 запросов в секунду без смены железа

Практическое руководство по настройке PostgreSQL на VPS с 4 CPU и 8 ГБ RAM: правильные параметры postgresql.conf с расчётами, частичные и covering индексы, настройка autovacuum, PgBouncer для connection pooling. Реальные цифры: 23 мс → 1.2 мс на запрос.

Нужна IT-разработка для вашего бизнеса?

Разрабатываем сайты, мобильные приложения и AI-решения для бизнеса в Кыргызстане. Бесплатная консультация.

Получить консультацию Все статьи