Работа с API: REST, GraphQL и практические примеры

API (Application Programming Interface) — это интерфейс, который позволяет разным приложениям общаться друг с другом. Когда вы заходите на сайт через мобильное приложение, когда платёжная система подтверждает транзакцию, когда бот отправляет сообщение в Telegram — везде работает API. Сегодня умение проектировать, создавать и использовать API — базовый навык для любого веб-разработчика.

В этой статье мы подробно разберём:

  • Что такое API и зачем они нужны.
  • REST — принципы, методы, статус-коды, примеры.
  • Создание простого REST API на PHP.
  • Аутентификацию и авторизацию (Basic, API-ключи, JWT, OAuth2).
  • GraphQL — что это, чем отличается от REST, примеры запросов и мутаций.
  • Сравнение REST и GraphQL: когда что выбирать.
  • Документирование API (Swagger/OpenAPI).
  • Версионирование API.
  • Практические советы и частые ошибки.

1. Что такое API и зачем они нужны

API — это набор правил и инструментов, с помощью которых одна программа может взаимодействовать с другой. API определяет, какие запросы можно отправлять, какие данные передавать и какие ответы ожидать.

Зачем нужны API:

  • Интеграция — связать разные сервисы (сайт, мобильное приложение, CRM).
  • Разделение — отделить фронтенд от бэкенда.
  • Масштабирование — разные команды могут работать над разными частями.
  • Публичный доступ — предоставить данные или функции внешним разработчикам.

Типы API:

  • REST — самый популярный, использует HTTP-методы и URL.
  • GraphQL — язык запросов, позволяет клиенту точно указывать, какие данные нужны.
  • SOAP — устаревший, тяжёлый протокол на основе XML.
  • gRPC — высокопроизводительный, использует бинарный протокол.

2. REST API

REST (Representational State Transfer) — архитектурный стиль, который определяет правила построения API. REST не является стандартом, но большинство современных API следуют его принципам.

2.1 Принципы REST

  1. Клиент-сервер — клиент и сервер разделены, каждый может развиваться независимо.
  2. Отсутствие состояния (Stateless) — каждый запрос содержит всю информацию, необходимую для его обработки. Сервер не хранит состояние клиента между запросами.
  3. Кеширование — ответы могут кешироваться.
  4. Единообразие интерфейса — использование стандартных HTTP-методов и URL.
  5. Слои — клиент может не знать, общается ли он напрямую с сервером или через прокси.
  6. Код по требованию (опционально) — сервер может передавать клиенту исполняемый код.

2.2 HTTP-методы

МетодНазначениеПример
GETПолучить данныеGET /users — список пользователей
POSTСоздать ресурсPOST /users — создать пользователя
PUTПолностью обновить ресурсPUT /users/1 — обновить пользователя с ID 1
PATCHЧастично обновить ресурсPATCH /users/1 — обновить только имя
DELETEУдалить ресурсDELETE /users/1 — удалить пользователя

2.3 Статус-коды HTTP

КодЗначениеКогда использовать
200OKУспешный GET, PUT, PATCH
201CreatedУспешный POST (ресурс создан)
204No ContentУспешный DELETE (нет тела ответа)
400Bad RequestНеверные параметры запроса
401UnauthorizedТребуется аутентификация
403ForbiddenДоступ запрещён
404Not FoundРесурс не найден
405Method Not AllowedМетод не поддерживается для этого URL
422Unprocessable EntityОшибка валидации данных
500Internal Server ErrorОшибка на сервере

2.4 Структура URL

REST использует ресурсы и операции над ними. URL должны быть понятными и отражать структуру данных.

Примеры:

  • GET /users — список пользователей.
  • GET /users/1 — пользователь с ID 1.
  • GET /users/1/orders — заказы пользователя с ID 1.
  • POST /users — создать пользователя.
  • PUT /users/1 — обновить пользователя.
  • DELETE /users/1 — удалить пользователя.

Правила:

  • Используйте существительные, а не глаголы (/users, а не /getUsers).
  • Используйте множественное число (/users, а не /user).
  • Используйте вложенность для связанных ресурсов (/users/1/orders).
  • Избегайте глубокой вложенности (максимум 2–3 уровня).

2.5 Форматы данных

Стандарт де-факто — JSON. Он лёгкий, читаемый и поддерживается всеми языками.

Пример ответа:

JSON
{
  "id": 1,
  "name": "John Doe",
  "email": "john@example.com",
  "created_at": "2024-03-10T12:00:00Z"
}

Пример списка с пагинацией:

JSON
{
  "data": [
    {"id": 1, "name": "John"},
    {"id": 2, "name": "Jane"}
  ],
  "meta": {
    "page": 1,
    "per_page": 10,
    "total": 100
  },
  "links": {
    "self": "/users?page=1",
    "next": "/users?page=2",
    "last": "/users?page=10"
  }
}

Короткий пример реализации пагинации в контроллере:

PHP
$page = max(1, (int)($_GET['page'] ?? 1));
$perPage = 10;
$offset = ($page - 1) * $perPage;

$stmt = $this->pdo->prepare("SELECT * FROM tasks ORDER BY created_at DESC LIMIT :limit OFFSET :offset");
$stmt->execute([
    ':limit' => $perPage,
    ':offset' => $offset,
]);
$tasks = $stmt->fetchAll(PDO::FETCH_ASSOC);

3. Создание простого REST API на PHP

Рассмотрим пример API для управления задачами (todo list).

3.1 Структура проекта

3.2 Роутер (index.php)

PHP
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *'); // в продакшене — конкретный домен
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Key');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204);
    exit;
}

// Получаем метод и путь
$method = $_SERVER['REQUEST_METHOD'];
// Безопасное отсечение префикса /api только в начале строки URL
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if (strpos($path, '/api') === 0) {
    $path = substr($path, 4);
}
$path = trim($path, '/');
$parts = explode('/', $path);

// Подключение к БД
require_once 'config/database.php';
require_once 'controllers/TaskController.php';

$controller = new TaskController($pdo);

// Роутинг
if ($parts[0] === 'tasks') {
    $id = $parts[1] ?? null;
    
    switch ($method) {
        case 'GET':
            if ($id) {
                $controller->getTask($id);
            } else {
                $controller->getTasks();
            }
            break;
        case 'POST':
            $controller->createTask();
            break;
        case 'PUT':
            if ($id) {
                $controller->updateTask($id);
            } else {
                http_response_code(400);
                echo json_encode(['error' => 'ID required']);
            }
            break;
        case 'DELETE':
            if ($id) {
                $controller->deleteTask($id);
            } else {
                http_response_code(400);
                echo json_encode(['error' => 'ID required']);
            }
            break;
        default:
            http_response_code(405);
            echo json_encode(['error' => 'Method not allowed']);
    }
} else {
    http_response_code(404);
    echo json_encode(['error' => 'Not found']);
}

3.3 Подключение к БД (config/database.php)

PHP
<?php
$host = 'localhost';
$dbname = 'api_db';
$user = 'api_user';
$password = 'secure_password';

try {
    $pdo = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8mb4", $user, $password);
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
    http_response_code(500);
    echo json_encode(['error' => 'Database connection failed']);
    exit;
}

3.4 Контроллер (controllers/TaskController.php)

PHP
<?php
class TaskController {
    private $pdo;

    public function __construct($pdo) {
        $this->pdo = $pdo;
    }

    // GET /tasks
    //ВАЖНО! Здесь нет пользовательских данных, поэтому query допустим; во всех остальных случаях — только prepare
    public function getTasks() {
        $stmt = $this->pdo->query("SELECT * FROM tasks ORDER BY created_at DESC");
        $tasks = $stmt->fetchAll(PDO::FETCH_ASSOC);
        echo json_encode(['data' => $tasks]);
    }

    // GET /tasks/{id}
    public function getTask($id) {
        $stmt = $this->pdo->prepare("SELECT * FROM tasks WHERE id = ?");
        $stmt->execute([$id]);
        $task = $stmt->fetch(PDO::FETCH_ASSOC);

        if ($task) {
            echo json_encode(['data' => $task]);
        } else {
            http_response_code(404);
            echo json_encode(['error' => 'Task not found']);
        }
    }

        // POST /tasks 
    public function createTask() {
        try {
            $data = json_decode(file_get_contents('php://input'), true, 512, JSON_THROW_ON_ERROR);
        } catch (\JsonException $e) {
            http_response_code(400);
            echo json_encode(['error' => 'Invalid JSON format']);
            return;
        }

        if (empty($data['title'])) {
            http_response_code(422);
            echo json_encode(['error' => 'Title is required']);
            return;
        }

        $allowedStatuses = ['pending', 'in_progress', 'done'];
        $status = $data['status'] ?? 'pending';
        if (!in_array($status, $allowedStatuses, true)) {
            http_response_code(422);
            echo json_encode(['error' => 'Invalid status value']);
            return;
        }

        $stmt = $this->pdo->prepare("INSERT INTO tasks (title, description, status) VALUES (?, ?, ?)");
        $stmt->execute([$data['title'], $data['description'] ?? '', $status]); // Передаем валидированный статус
        $id = $this->pdo->lastInsertId();
        
        http_response_code(201);
        echo json_encode(['data' => ['id' => (int)$id, 'title' => $data['title'], 'status' => $status]]);
    }

    // PUT /tasks/{id} 
    public function updateTask($id) {
        $data = json_decode(file_get_contents('php://input'), true);
        $stmt = $this->pdo->prepare("UPDATE tasks SET title = ?, description = ?, status = ? WHERE id = ?");
        $stmt->execute([
            $data['title'] ?? '',
            $data['description'] ?? '',
            $data['status'] ?? 'pending',
            (int)$id // Жестко приводим к целому числу для безопасности
        ]);
        
        echo json_encode(['message' => 'Task processed']);
    }

    // DELETE /tasks/{id}
    public function deleteTask($id) {
        $stmt = $this->pdo->prepare("DELETE FROM tasks WHERE id = ?");
        $stmt->execute([$id]);

        if ($stmt->rowCount() > 0) {
            http_response_code(204);
        } else {
            http_response_code(404);
            echo json_encode(['error' => 'Task not found']);
        }
    }
}

3.5 .htaccess для перенаправления

Apache
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]

Теперь API доступно по адресам:

  • GET /api/tasks
  • GET /api/tasks/1
  • POST /api/tasks
  • PUT /api/tasks/1
  • DELETE /api/tasks/1

4. Аутентификация и авторизация

API должны быть защищены. Рассмотрим основные методы.

4.1 Basic Auth

Самый простой метод: логин и пароль передаются в заголовке Authorization в base64.

Плюсы: простота.
Минусы: пароль передаётся в открытом виде (только с HTTPS), нет гибкости.

Пример на PHP:

PHP
$user = $_SERVER['PHP_AUTH_USER'] ?? '';
$pass = $_SERVER['PHP_AUTH_PW'] ?? '';

if ($user !== 'admin' || $pass !== 'secret') {
    http_response_code(401);
    header('WWW-Authenticate: Basic realm="API"');
    echo json_encode(['error' => 'Unauthorized']);
    exit;
}

ВАЖНО! Basic Auth требует корректной настройки веб‑сервера; для реальных API лучше использовать API‑ключи или JWT

4.2 API-ключи

Клиент передаёт ключ в заголовке или параметре.

Плюсы: простота, можно отозвать ключ.
Минусы: ключ может утечь, нет срока действия (если не реализовано).

Пример:

PHP
$apiKey = $_SERVER['HTTP_X_API_KEY'] ?? '';

$stmt = $pdo->prepare("SELECT * FROM api_keys WHERE `key` = ? AND active = 1");
$stmt->execute([$apiKey]);
$key = $stmt->fetch();

if (!$key) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid API key']);
    exit;
}

4.3 JWT (JSON Web Token)

JWT — это токен, который содержит закодированную информацию о пользователе и подписан секретным ключом.

Структура JWT:

  • Header (алгоритм, тип).
  • Payload (данные: user_id, exp, iat).
  • Signature (подпись).

Пример создания JWT на PHP:

PHP
function generateJWT($userId, $secret) {
    $header = base64_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
    $payload = base64_encode(json_encode([
        'user_id' => $userId,
        'iat' => time(),
        'exp' => time() + 3600
    ]));
    $signature = base64_encode(hash_hmac('sha256', "$header.$payload", $secret, true));
    return "$header.$payload.$signature";
}

function validateJWT($token, $secret) {
    $parts = explode('.', $token);
    if (count($parts) !== 3) return false;

    [$header, $payload, $signature] = $parts;
    $validSignature = base64_encode(hash_hmac('sha256', "$header.$payload", $secret, true));

    if ($signature !== $validSignature) return false;

    $data = json_decode(base64_decode($payload), true);
    if ($data['exp'] < time()) return false;

    return $data;
}

ВАЖНО! В примере это демонстрация принципа; в реальном проекте используйте проверенные библиотеки.

Использование:

PHP
$token = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$token = str_replace('Bearer ', '', $token);

$user = validateJWT($token, 'your_secret_key');
if (!$user) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid token']);
    exit;
}

// Теперь $user['user_id'] доступен

Плюсы JWT: stateless, можно хранить данные, подпись защищает от подделки.
Минусы: нельзя отозвать до истечения срока, нужно хранить секрет.

Для PHP рекомендуем firebase/php-jwt (стандарт индустрии):

Bash
composer require firebase/php-jwt

Пример безопасного создания токена:

PHP
use Firebase\JWT\JWT;
use Firebase\JWT\Key; // Обязательный импорт класса Key для версии 6+

// Безопасное создание токена
$payload = [
    'user_id' => 123,
    'iat' => time(),
    'exp' => time() + 3600,
];
$secret = getenv('JWT_SECRET') ?: 'default_fallback_secret';
$token = JWT::encode($payload, $secret, 'HS256');

// Валидация токена (актуальный синтаксис Firebase JWT v6+)
try {
    $decoded = JWT::decode($token, new Key($secret, 'HS256'));
    $userId = $decoded->user_id; // Доступ к данным через объект
} catch (\Exception $e) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid or expired token']);
    exit;
}

Валидация:

PHP
try {
    $decoded = JWT::decode($token, $secret, ['HS256']);
} catch (\Exception $e) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid token']);
    exit;
}

4.4 OAuth2

OAuth2 — протокол авторизации, который позволяет пользователям предоставлять доступ к своим данным без передачи пароля. Используется для входа через Google, GitHub, VK и т.д.

Основные понятия:

  • Client ID и Client Secret — идентификаторы приложения.
  • Authorization Code — временный код для получения токена.
  • Access Token — токен для доступа к API.
  • Refresh Token — токен для обновления access token.

Схема:

  1. Пользователь перенаправляется на сервер авторизации.
  2. Пользователь подтверждает доступ.
  3. Сервер возвращает authorization code.
  4. Приложение обменивает код на access token.
  5. Приложение использует access token для запросов к API.

Реализация OAuth2 — тема отдельной статьи, но большинство фреймворков имеют готовые библиотеки.

5. GraphQL

GraphQL — это язык запросов для API, разработанный в 2015 году. В отличие от REST, где клиент получает фиксированную структуру ответа, GraphQL позволяет клиенту точно указать, какие поля ему нужны.

5.1 Основные понятия

  • Schema — описание типов данных и доступных запросов.
  • Query — запрос на чтение данных.
  • Mutation — запрос на изменение данных.
  • Subscription — подписка на обновления в реальном времени.
  • Resolver — функция, которая возвращает данные для поля.

5.2 Пример схемы

GraphQL
type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post]
}

type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
}

type Query {
    users: [User]
    user(id: ID!): User
    posts: [Post]
}

type Mutation {
    createUser(name: String!, email: String!): User
    updateUser(id: ID!, name: String): User
    deleteUser(id: ID!): Boolean
}

5.3 Пример запроса

Клиент запрашивает только нужные поля:

GraphQL
query {
    user(id: 1) {
        name
        email
        posts {
            title
        }
    }
}

Ответ:

JSON
{
    "data": {
        "user": {
            "name": "John Doe",
            "email": "john@example.com",
            "posts": [
                {"title": "First post"},
                {"title": "Second post"}
            ]
        }
    }
}

5.4 Пример мутации

GraphQL
mutation {
    createUser(name: "Jane", email: "jane@example.com") {
        id
        name
    }
}

5.5 Реализация GraphQL на PHP

Используйте библиотеку webonyx/graphql-php (устанавливается через Composer).

Пример:

PHP
<?php
require_once 'vendor/autoload.php';
require_once 'database.php';

use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\GraphQL;
use GraphQL\Type\Schema;

$userType = new ObjectType([
    'name' => 'User',
    'fields' => [
        'id' => Type::int(),
        'name' => Type::string(),
        'email' => Type::string(),
    ]
]);

$queryType = new ObjectType([
    'name' => 'Query',
    'fields' => [
        'user' => [
            'type' => $userType,
            'args' => ['id' => Type::int()],
            'resolve' => function ($root, $args) use ($pdo) {
                $stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
                $stmt->execute([$args['id']]);
                return $stmt->fetch(PDO::FETCH_ASSOC);
            }
        ],
        'users' => [
            'type' => Type::listOf($userType),
            'resolve' => function () use ($pdo) {
                $stmt = $pdo->query("SELECT * FROM users");
                return $stmt->fetchAll(PDO::FETCH_ASSOC);
            }
        ]
    ]
]);

$schema = new Schema(['query' => $queryType]);

$input = json_decode(file_get_contents('php://input'), true);
$result = GraphQL::executeQuery($schema, $input['query']);
echo json_encode($result->toArray());

6. Сравнение REST и GraphQL

КритерийRESTGraphQL
Конечные точкиМного (по одной на ресурс)Одна (/graphql)
Получение данныхФиксированная структураКлиент указывает, что нужно
Избыточность данныхМожет быть (over-fetching)Нет
Недостаток данныхМожет быть (under-fetching)Нет (можно запросить всё за раз)
КешированиеЛегко (HTTP-кеш)Сложнее (нужны свои механизмы)
СложностьПроще для простых APIСложнее в настройке
ВерсионированиеЧерез URL (/v1/users)Через эволюцию схемы
ФайлыЛегко (multipart)Треб