Symfony-бандл для быстрого и удобного создания JSON-RPC 2.0 API приложений.
GitHub: https://github.com/OtezVikentiy/symfony-jsonrpc-api-bundle
- Полная совместимость со спецификацией JSON-RPC 2.0
- Конфигурация методов через PHP 8 атрибуты (
#[JsonRPCAPI(...)]) - Поддержка HTTP-методов: POST, GET, PUT, PATCH, DELETE
- Версионирование API (
/api/v1,/api/v2, ...) - Автоматическая генерация OpenAPI/Swagger документации
- Pre- и Post-процессоры (middleware)
- Пакетные запросы (batch requests)
- Встроенная валидация запросов
- Ролевой контроль доступа через Symfony Security
- Поддержка бинарных ответов (изображения, документы)
- PHP >= 8.2
- Symfony >= 6.4
composer require otezvikentiy/json-rpc-apiВключите бандл (если не используется Symfony Flex):
// config/bundles.php
return [
// ...
OV\JsonRPCAPIBundle\OVJsonRPCAPIBundle::class => ['all' => true],
];Создайте конфигурационные файлы:
# config/routes/ov_json_rpc_api.yaml
ov_json_rpc_api:
resource: '@OVJsonRPCAPIBundle/config/routes/routes.yaml'# config/packages/ov_json_rpc_api.yaml
ov_json_rpc_api:
access_control_allow_origin_list:
- '*'
swagger:
api_v1:
api_version: '1'
base_path: '%env(string:OV_JSON_RPC_API_BASE_URL)%'
base_path_description: 'Production server'
test_path: '%env(string:OV_JSON_RPC_API_TEST_URL)%'
test_path_description: 'Sandbox server'
auth_token_name: 'X-AUTH-TOKEN'
auth_token_test_value: '%env(string:OV_JSON_RPC_API_AUTH_TOKEN)%'
info:
title: 'My API'
description: 'JSON-RPC 2.0 API'
terms_of_service_url: 'https://example.com/tos'
contact:
name: 'Support'
url: 'https://example.com'
email: 'support@example.com'
license: 'MIT'
licenseUrl: 'https://opensource.org/licenses/MIT'# .env
OV_JSON_RPC_API_SWAGGER_PATH=public/openapi/
OV_JSON_RPC_API_BASE_URL=http://localhost
OV_JSON_RPC_API_TEST_URL=http://localhost
OV_JSON_RPC_API_AUTH_TOKEN=your_test_token_hereПодробная инструкция: docs/installation.md
// src/RPC/V1/GetProduct/Request.php
namespace App\RPC\V1\GetProduct;
class Request
{
private int $id;
private string $title;
public function __construct(int $id)
{
$this->id = $id;
}
public function getId(): int { return $this->id; }
public function setId(int $id): void { $this->id = $id; }
public function getTitle(): string { return $this->title; }
public function setTitle(string $title): void { $this->title = $title; }
}// src/RPC/V1/GetProduct/Response.php
namespace App\RPC\V1\GetProduct;
class Response
{
private bool $success;
private string $title;
private int $price;
public function __construct(bool $success = true)
{
$this->success = $success;
}
public function isSuccess(): bool { return $this->success; }
public function setSuccess(bool $success): void { $this->success = $success; }
public function getTitle(): string { return $this->title; }
public function setTitle(string $title): void { $this->title = $title; }
public function getPrice(): int { return $this->price; }
public function setPrice(int $price): void { $this->price = $price; }
}// src/RPC/V1/GetProductMethod.php
namespace App\RPC\V1;
use OV\JsonRPCAPIBundle\Core\Annotation\JsonRPCAPI;
use OV\JsonRPCAPIBundle\Core\ApiMethodInterface;
use App\RPC\V1\GetProduct\Request;
use App\RPC\V1\GetProduct\Response;
#[JsonRPCAPI(methodName: 'getProduct', type: 'POST')]
class GetProductMethod implements ApiMethodInterface
{
public function call(Request $request): Response
{
$response = new Response();
$response->setTitle('Iphone 15');
$response->setPrice(2000);
return $response;
}
}curl -X POST http://localhost/api/v1 \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "method": "getProduct", "params": {"id": 1, "title": "test"}, "id": "1"}'Ответ:
{
"jsonrpc": "2.0",
"result": {
"success": true,
"title": "Iphone 15",
"price": 2000
},
"id": "1"
}HTTP POST /api/v{version}
|
v
ApiController
|
v
RequestRawDataHandler --- парсит HTTP-запрос (JSON body / query params)
|
v
BatchStrategyFactory --- определяет: одиночный или пакетный запрос
|
v
RequestHandler
|--- Поиск MethodSpec по имени метода
|--- Создание объекта Request из параметров
|--- Валидация типизированных свойств
|--- PreProcessors (если есть)
|--- Method::call(Request) -> Response
|--- PostProcessors (если есть)
|
v
ResponseService --- сериализация ответа в JSON-RPC 2.0 формат
src/RPC/V1/
GetProductMethod.php # Класс метода с #[JsonRPCAPI] атрибутом
GetProduct/
Request.php # DTO входящего запроса
Response.php # DTO ответа
Классы, реализующие ApiMethodInterface и помеченные атрибутом #[JsonRPCAPI], автоматически обнаруживаются и регистрируются бандлом.
| Пример | Описание | Файлы |
|---|---|---|
| Базовый | Простейший пример создания API-метода | Request, Response, Method |
| Pre/Post-процессоры | Выполнение логики до и после вызова метода | Request, Response, Method, AbstractMethod |
| Массив объектов | Возврат коллекции объектов в ответе | Request, Response, Method, Product |
| Бинарный ответ | Возврат изображений, документов и других бинарных данных | Request, PlainResponse, Method |
| Раздел | Описание |
|---|---|
| Обработка ошибок | Коды ошибок, JRPCException, кастомные ошибки, additionalInfo |
| Notification-запросы | Запросы без id, параметр strict_notifications |
| Валидация параметров | Автоматическая валидация типов, nullable, формат ошибок |
| Базовый класс JsonRpcRequest | Метод toArray(), рекурсивная сериализация |
| Partial updates (JSON Merge Patch) | PartialRequestInterface, wasProvided(), RFC 7396 семантика |
| Troubleshooting / FAQ | Типичные проблемы и их решения |
| CHANGELOG | История изменений по версиям |
Опциональная подсистема Request/Response логирования с маскировкой sensitive-данных через PSR-3 logger. По умолчанию выключена. Подробности — docs/logging.md.
Бандл поддерживает PATCH-семантику по RFC 7396 для Update-методов, где клиент шлёт только изменённые поля.
Проблема: при стандартном паттерне if ($request->getX() !== null) { $entity->setX($request->getX()); } нельзя различить «поле не передано» и «поле передано как null» — оба случая дают null в DTO. Это значит, что нельзя очистить поле через PATCH.
Решение: Request DTO реализует PartialRequestInterface, и фреймворк отслеживает, какие поля реально пришли в payload. Сервис-слой использует wasProvided('x') вместо !== null:
use OV\JsonRPCAPIBundle\Core\Request\PartialUpdateRequest;
class UpdateUserRequest extends PartialUpdateRequest
{
private ?int $id = null;
private ?string $email = null;
private ?string $bio = null;
// getters/setters...
}public function call(UpdateUserRequest $request): Response
{
$user = $this->userRepository->find($request->getId());
if ($request->wasProvided('email')) {
$user->setEmail($request->getEmail()); // null = очистить
}
if ($request->wasProvided('bio')) {
$user->setBio($request->getBio());
}
// ...
}Семантика payload-а:
| Payload | wasProvided |
Поведение в сервисе |
|---|---|---|
{"email": "new@x.com"} |
true |
установить новое значение |
{"email": null} |
true |
очистить поле (null) |
{} (ключ отсутствует) |
false |
не трогать поле |
Опт-ин: только DTO, реализующие PartialRequestInterface, получают трекинг. Существующие DTO работают без изменений (полная обратная совместимость).
Подробности и edge-кейсы — в docs/partial_updates.md.
Версия API определяется из URL (/api/v1, /api/v2) или явно через параметр version в атрибуте:
#[JsonRPCAPI(methodName: 'getProduct', type: 'POST', version: 2)]Если version не указан, он извлекается из пространства имён класса (например, App\RPC\V1 -> версия 1).
Бандл поддерживает пакетные JSON-RPC запросы согласно спецификации:
curl -X POST http://localhost/api/v1 \
-H "Content-Type: application/json" \
-d '[
{"jsonrpc": "2.0", "method": "sum", "params": [1, 2, 4], "id": "1"},
{"jsonrpc": "2.0", "method": "notify_hello", "params": [7]},
{"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": "2"}
]'Процессоры позволяют выполнять логику до и после вызова метода API (логирование, аудит, уведомления и т.д.):
use OV\JsonRPCAPIBundle\Core\PreProcessorInterface;
use OV\JsonRPCAPIBundle\Core\PostProcessorInterface;
#[JsonRPCAPI(methodName: 'getProduct', type: 'POST')]
class GetProductMethod implements PreProcessorInterface, PostProcessorInterface
{
public function getPreProcessors(): array
{
return [
static::class => ['logRequest'],
];
}
public function getPostProcessors(): array
{
return [
static::class => ['logResponse'],
];
}
public function logRequest(string $processorClass, ?object $request = null): void
{
// Вызывается ПЕРЕД call()
}
public function logResponse(string $processorClass, ?object $request = null, ?OvResponseInterface $response = null): void
{
// Вызывается ПОСЛЕ call()
}
public function call(Request $request): Response
{
// Основная логика
}
}Подробнее: docs/examples/pre-and-post-processors.md
bin/console ov:swagger:generateГенерирует файл public/openapi/api_v1.yaml, который можно использовать в Swagger UI.
Скалярные свойства:
use OV\JsonRPCAPIBundle\Core\Annotation\SwaggerProperty;
class Response
{
#[SwaggerProperty(default: 'true', example: 'true')]
private bool $success;
#[SwaggerProperty(format: 'email', example: 'user@example.com')]
private string $email;
}Массивы:
use OV\JsonRPCAPIBundle\Core\Annotation\SwaggerArrayProperty;
class Response
{
#[SwaggerArrayProperty(type: 'string')]
private array $errors = [];
#[SwaggerArrayProperty(type: Product::class, ofClass: true)]
private array $products = [];
}Теги для группировки:
#[JsonRPCAPI(methodName: 'getProduct', type: 'POST', tags: ['products'])]Подробнее:
Ограничение доступа к методам по ролям через атрибут roles:
#[JsonRPCAPI(
methodName: 'deleteUser',
type: 'POST',
roles: ['ROLE_ADMIN', 'ROLE_SUPER_ADMIN']
)]
class DeleteUserMethod
{
public function call(Request $request): Response { /* ... */ }
}При отсутствии нужной роли бандл возвращает HTTP 403.
Бандл совместим с любым способом аутентификации Symfony:
Запуск тестов:
./vendor/bin/phpunit tests/Покрытие требует драйвера (xdebug или pcov). В phpunit.xml.dist уже настроен <source>-блок для отчётов покрытия PHPUnit 10+.
Тестовый набор включает:
- Unit-тесты — все Core-компоненты, сервисы, модели запросов/ответов, DI, Swagger-модели.
- Интеграционные тесты — полный цикл обработки запросов через контроллер.
- Тесты команд — генерация Swagger YAML.
- Security regression-тесты (
tests/Security/) — DoS-лимиты (payload, batch, DTO depth, array size), sanitization ошибок, CORS origin matching, проверки видимости сеттеров, path-containment команды.
Гайд по написанию тестов для собственных RPC-методов — docs/testing.md.
| Параметр | По умолчанию | Описание |
|---|---|---|
access_control_allow_origin_list |
[] |
Разрешённые CORS-домены. ['*'] — wildcard; список конкретных origin'ов матчится с заголовком запроса Origin. |
cors_strict |
true |
При true — для origin'ов вне whitelist'а CORS-заголовок не отдаётся. При false — fallback к legacy comma-joined заголовку (невалидный, только для обратной совместимости). |
strict_notifications |
true |
Строгое следование JSON-RPC 2.0 для Notification-запросов (без id). При true — сервер не возвращает ответ (по спеку). При false — лояльный режим: ответ возвращается, если результат непустой (поведение 3.x). |
allow_extra_fields |
false |
При false — лишние поля в params, отсутствующие в Request DTO, вызывают INVALID_PARAMS. Можно переопределить per-method через #[JsonRPCAPI(allowExtraFields: true)]. |
expose_internal_errors |
false |
При false (production-safe) — uncaught non-JRPCException исключения возвращаются клиенту как Internal error., а оригинал пишется в LoggerInterface. При true — сырое сообщение отдаётся клиенту (для dev). |
max_payload_bytes |
1048576 |
Максимальный размер сырого тела запроса в байтах. Большие запросы — INVALID_REQUEST. |
max_json_depth |
64 |
Максимальная глубина вложенности JSON при декодировании. Глубже — PARSE_ERROR. |
max_batch_size |
50 |
Максимальное число запросов в одном JSON-RPC batch'е. Больше — единый INVALID_REQUEST. |
max_dto_depth |
10 |
Максимальная глубина рекурсии при гидратации вложенных Request DTO. Защита от stack/memory exhaustion. |
max_array_param_size |
1000 |
Максимальное число элементов массива-параметра, обрабатываемого через addX()-адеры. |
swagger |
— | Конфигурация Swagger по версиям API |
swagger.*.api_version |
'1' |
Номер версии API |
swagger.*.base_path |
— | URL production-сервера |
swagger.*.test_path |
null |
URL тестового сервера |
swagger.*.base_path_variables |
[] |
Переменные для подстановки в base_path |
swagger.*.test_path_variables |
[] |
Переменные для подстановки в test_path |
swagger.*.auth_token_name |
— | Имя заголовка для токена авторизации |
swagger.*.auth_token_test_value |
— | Тестовое значение токена |
swagger.*.info |
— | Информация об API (title, description, contact, license) |
Security hardening: рекомендации по значениям, обоснование и тюнинг для high-volume API — docs/security_hardening.md.
| Параметр | Тип | Обязательный | По умолчанию | Описание |
|---|---|---|---|---|
methodName |
string | да | — | Имя JSON-RPC метода |
type |
string | да | — | HTTP-метод (POST, GET, PUT, PATCH, DELETE) |
version |
?int | нет | null |
Версия API (если null — определяется из namespace) |
summary |
string | нет | '' |
Краткое описание для Swagger |
description |
string | нет | '' |
Подробное описание для Swagger |
tags |
?array | нет | null |
Теги для группировки в Swagger |
roles |
array | нет | [] |
Требуемые роли для доступа |
ignoreInSwagger |
bool | нет | false |
Исключить метод из Swagger-документации |
group |
?string | нет | null |
Группа для пути в Swagger (например, 'products' → /products/get_product) |
| Код | Константа | Описание |
|---|---|---|
-32700 |
PARSE_ERROR |
Ошибка парсинга JSON |
-32600 |
INVALID_REQUEST |
Невалидный JSON-RPC запрос |
-32601 |
METHOD_NOT_FOUND |
Метод не найден |
-32602 |
INVALID_PARAMS |
Невалидные параметры |
-32603 |
INTERNAL_ERROR |
Внутренняя ошибка |
-32000 |
SERVER_ERROR |
Серверная ошибка |
Leonid Groshev — OtezVikentiy@gmail.com — otezvikentiy.tech