Symfony JSON-RPC API Bundle три года спустя: что боевая эксплуатация сделала с кодом

от автора

В августе 2023-го я писал здесь про свой бандл для JSON-RPC API на Symfony — “Symfony Json RPC API Bundle — простое API со всем необходимым”. Тогда это был компактный инструмент: три класса на метод, встроенная валидация, авторизация по токену.

С тех пор прошло три года, бандл дорос с 1.x до 5.1 и живёт в нескольких продакшенах — от внутренних инструментов в финтехе до HRM-системы. Нагрузки там будничные, зато требования к корректности, логам и аудиту вполне взрослые — и именно они, а не RPS, двигали развитие. Эта статья — честный отчёт о том, что боевая эксплуатация и внимательное чтение спецификации сделали с кодом. Спойлер: я нашёл у себя 17 отклонений от спеки, узнал, что приватные поля умеют утекать в ответы, а два origin’а в CORS-заголовке через запятую — это не фича.

Коротко для тех, кто не читал первую статью

otezvikentiy/json-rpc-api — бандл, который берёт на себя транспорт JSON-RPC 2.0: один эндпоинт /api/v{version}, методы объявляются атрибутами, параметры приезжают типизированным DTO, валидация выводится из типов свойств. Метод — это обычный сервис:

#[JsonRPCAPI(methodName: 'createTask', type: 'POST', summary: 'Create a task', tags: ['tasks'])]final class CreateTaskMethod implements ApiMethodInterface{    public function __construct(private readonly TaskStorage $storage)    {    }    public function call(CreateTaskRequest $request): CreateTaskResponse    {        $task = $this->storage->create($request->getTitle(), $request->getAssigneeEmail());        return new CreateTaskResponse(true, $task->id, $task->title, $task->status->value, $task->assigneeEmail);    }}
curl -s -X POST http://localhost:8000/api/v1 \  -H "Content-Type: application/json" \  -d '{"jsonrpc":"2.0","method":"createTask","params":{"title":"Try the demo"},"id":1}'# {"jsonrpc":"2.0","result":{"success":true,"id":1,"title":"Try the demo","status":"todo","assigneeEmail":null},"id":1}

Request-класс задаёт контракт: параметры конструктора обязательные, свойства с сеттерами — опциональные, типы проверяются автоматически. Плюс батчи, версионирование через неймспейсы (App\RPC\V2 -> /api/v2) и генерация OpenAPI 3.1 командой ov:swagger:generate.

Дальше — о том, чего в 2023-м не было, и почему оно появилось.

Урок 1. Дефолты должны быть безопасными (v4.0)

Версия 1.x была написана в презумпции доброжелательного клиента: что пришлют, то и обработаем. Прод быстро объясняет, что клиенты бывают разные, а в финтехе на API смотрят ещё и аудиторы.

Версия 4.0 (май 2026) стала security-релизом, и главный сдвиг был идеологический — безопасное поведение по умолчанию, а не по конфигурации:

  • Лимиты против DoS из коробки: размер тела (1 МиБ), глубина JSON (64), размер батча (50), глубина вложенных DTO, размер массивов-параметров. Всё крутится конфигом, но дефолт — защищённый. Батч на миллион вызовов или JSON-матрёшка в тысячу уровней теперь отбиваются до того, как согнут воркер.

  • Санитизация ошибок: любой Throwable, кроме доменного JRPCException, уезжает клиенту как безликий -32603 Internal error, а полный стектрейс — только в лог. До этого текст исключения — с путями файлов и именами классов — мог оказаться в HTTP-ответе. Ключ expose_internal_errors существует, но по умолчанию выключен.

  • CORS, который работает как whitelist. Мой любимый баг этого релиза: список из нескольких origin’ов склеивался в заголовок через запятую — Access-Control-Allow-Origin: https://a.com, https://b.com. Спецификация CORS такого не знает, браузеры молча игнорируют. С 4.0 бандл читает Origin запроса, сравнивает со списком строго и возвращает ровно один совпавший origin (плюс корректный Vary: Origin — чтобы shared-кэш не отдал чужой заголовок).

Урок 2. Логи, которые можно показать аудитору (v4.1-4.2)

Логирование запросов-ответов в финтехе — не опция, а требование. Но лог полного тела запроса — это лог паролей, токенов и номеров карт.

В 4.1 появилась подсистема логирования с маскированием по regex-паттернам имён JSON-ключей, а к 5.0 маскирование стало включённым по умолчанию: 29 паттернов из коробки (password, token, jwt, api_key, card_number, cvv, ssn, …), маскирование рекурсивное на любой глубине, тела обрезаются по длине. Сломанный regex в конфиге валит компиляцию контейнера — а не молча отключает маскирование в рантайме.

Request: [createTask] {"title":"Sensitive","assigneeEmail":"***"} context_id: 0198...

Каждая пара запрос-ответ связана сквозным context_id, формат/маскер/генератор id подменяются интерфейсами. Отдельная честная оговорка в документации: маскер смотрит только на имена ключей — секрет в поле с нейтральным именем или в позиционных параметрах он не распознает.

Урок 3. Спецификацию надо читать с тестами в руках (v5.0)

Самая поучительная часть. Перед 5.0 я сделал то, что стоило сделать в самом начале: написал постоянный сьют соответствия спецификации — 39 тестов по разделам 4-6 JSON-RPC 2.0, включая каждый пример из раздела Examples. Сьют нашёл 17 отклонений от спеки. Шестнадцать исправлены в 5.0.

Несколько самых показательных — в назидание всем, кто пишет “простой транспортный слой”:

  • Позиционные параметры не работали вообще. {"method":"subtract","params":[42,23]} — канонический пример из спецификации — возвращал -32602. Валидатор видел список с ключами 0..n и считал, что поле params отсутствует, а все элементы — лишние. Почему никто не заметил? Старые тесты гоняли мок валидатора, который на любой вход отвечал “нарушений нет”. Теперь сьют гоняет настоящий.

  • Приватные поля утекали в ответ. Сериализация читала свойства объекта через Reflection — все, включая private без геттеров. Приватное поле с паролем или внутренним токеном честно уезжало клиенту. С 5.0 в JSON попадает только то, что класс сам сделал публичным: публичное свойство или публичный геттер.

  • {"id": null} и отсутствие id — разные вещи. Notification — это отсутствие ключа, а не null; isset() их не различает. Из-за этого запрос с id: null молча оставался без ответа. Та же ловушка была с params: null.

  • Циклический граф в ответе ронял воркер. Двунаправленная связь (заказ -> пользователь -> его заказы) приводила к segfault: ни ответа, ни лога. Теперь это -32603 с внятным сообщением и потолком вложенности.

  • Content-Type стал обязательным для запросов с телом — и это не педантизм: form-encoded запрос является “простым” по спецификации CORS и уходит без preflight, то есть без проверки HTML-форма на чужом сайте могла дёргать RPC-методы от имени залогиненного пользователя. Классический CSRF-вектор, закрытый одной проверкой заголовка.

Туда же в 5.0 уехала строгая валидация без коэрции типов: "42" для int-поля — это -32602, а не тихий каст. Контракт запроса — буквально PHP-типы вашего DTO.

Полный список изменений — в CHANGELOG, для миграции есть подробные гайды (upgrade-4.0, upgrade-5.0).

Инженерная зрелость как фича

Три года назад “протестировано” означало “есть тесты”. Сейчас это измеримо:

  • 768 тестов, покрытие 99% — и порог покрытия зашит в CI, упасть ниже нельзя незаметно.

  • Мутационное тестирование (Infection) гейтом в CI: ~1500 мутантов, MSI-порог. Это защита от тестов, которые “есть, но ничего не проверяют” — ровно та болезнь, из-за которой три года жил баг с позиционными параметрами.

  • Матрица совместимости честная: CI гоняет PHP 8.2/8.3/8.4/8.5 x Symfony 6.4/7/8 плюс сборку на минимальных версиях зависимостей. Заявленная поддержка — это проверенная поддержка.

  • Семвер с явной BC-политикой: в README перечислено, что именно входит в обещание обратной совместимости (конфиг, атрибут, wire-формат, интерфейсы расширения), а что — внутренности, которые могут меняться в минорах.

  • Flex-рецепт — конфиги и env-переменные приезжают при composer require (рецепт для 5.x сейчас на ревью в recipes-contrib).

Посмотреть руками

Специально для этой серии статей я собрал открытый демо-проект: symfony-jsonrpc-api-demo — таск-трекер на Symfony 7.4, где всё описанное работает вместе: CRUD на v1, пагинированный listTasks на v2 (версионирование без единого условия в коде), батчи, лимиты, маскирование логов и сгенерированные OpenAPI-спеки для обеих версий. 26 функциональных тестов через обычный WebTestCase, никакой магии. Клонировать, composer install, php -S localhost:8000 -t public — и можно тыкать curl’ом примеры из README.

Что дальше

Планы на серию: разборы отдельных тем — батчи против N round-trip’ов, версионирование API без боли, безопасность JSON-RPC эндпоинта, генерация OpenAPI из атрибутов. Если какая-то тема нужнее прочих — скажите в комментариях.

Бандл: github.com/OtezVikentiy/symfony-jsonrpc-api-bundle. Вопросы и идеи — в Discussions, баги — в Issues. Обратной связи буду рад — в том числе жёсткой: как видно из статьи, она этому проекту идёт на пользу.


Оригинал статьи — у меня на сайте.

ссылка на оригинал статьи https://habr.com/ru/articles/1068946/