В JSON нет представления для файла. Есть строки, числа, массивы, объекты - и всё. Поэтому любой JSON-RPC API рано или поздно упирается в вопрос: а как принять загрузку файла - фото, скан, PDF - если сам протокол бинарные данные не умеет?
Стандартный ответ: не принимать. Файл уезжает отдельным «обычным» контроллером, который читает $request->files, а рядом живёт JSON-RPC для всего остального. И вот у вас снова тот самый разброс ad hoc эндпоинтов, ради устранения которого JSON-RPC и брали.
В otezvikentiy/json-rpc-api 5.2 появился другой ответ. И интереснее самой фичи - то, как она появилась: её принёс не я, а внешний контрибьютор. Я веду этот бандл почти три года и до сих пор один, поэтому релиз, в котором главное написал не я, для меня событие особого рода. Но обо всём по порядку.
Задача
Формулировка из issue #8, дословно по сути: два сервиса обмениваются сканами изображений плюс структурированными метаданными (tenant, station, session) в одном вызове - что-то вроде captures.create(tenantId, stationId, image). Сегодня image невозможно выразить как параметр JSON-RPC метода, поэтому такой вызов приходится выносить из бандла в отдельный multipart-контроллер.
Хочется, чтобы метод просто объявил параметр типа UploadedFile и получил файл - как любой другой параметр.
Решение: multipart как транспортный адаптер
Ключевая идея - не трогать ядро. multipart/form-data запрос нормализуется в тот же самый конверт JSON-RPC, что и обычный, только с объектами UploadedFile уже внутри params. Всё, что ниже транспорта - гидрация, батчи, валидация - о multipart не знает вообще, ровно как оно не знает, что payload GET-запроса приехал из query string.
Форма запроса: одно текстовое поле jsonrpc несёт полный JSON-RPC конверт строкой (все скалярные параметры - внутри него), а каждая остальная часть - файл, и имя части равно имени параметра.
curl -X POST http://localhost/api/v1 \ -F 'jsonrpc={"jsonrpc":"2.0","method":"captures.create","params":{"tenantId":"t-1"},"id":1}' \ -F 'image=@scan.png'
Метод объявляет параметр обычным свойством DTO:
use Symfony\Component\HttpFoundation\File\UploadedFile; final class Request { private string $tenantId = ''; private ?UploadedFile $image = null; public function getTenantId(): string { return $this->tenantId; } public function setTenantId(string $tenantId): void { $this->tenantId = $tenantId; } public function getImage(): ?UploadedFile { return $this->image; } public function setImage(?UploadedFile $image): void { $this->image = $image; } }
И читает файл в хендлере как настоящий UploadedFile - с move(), getClientOriginalName(), всем набором Symfony.
Три решения, которые стоит объяснить
Скаляры не превращаются в поля формы. Соблазн был: раскидать все параметры по отдельным form-полям. Но поле формы - это строка, и тогда "42" и 42 снова становятся неразличимы. Ровно ту неоднозначность нетипизированного транспорта бандл осознанно терпит только для GET, где query string не оставляет выбора. Для POST типы есть, и терять их не хочется. Поэтому скаляры остаются в JSON-конверте, а form-полями едут только файлы.
Включается дважды. Один переключатель - multipart.enabled для приложения, второй - acceptsMultipart: true в атрибуте метода. Причина не в перестраховке: Content-Type проверяется до того, как известен метод, поэтому глобальный флаг сам по себе ничего не может сказать о конкретном методе. А включать транспорт для приложения и молча открывать его всем уже написанным методам, которые его не ждали, - плохо.
Валидация - симфоневская, не самодельная. Объявленный UploadedFile компилируется в Assert\Type и следом Assert\File - тем же механизмом, который для int-поля даёт Assert\Type('int'). Лимит размера (multipart.max_file_bytes, в привычной записи '10Mi') применяет именно Assert\File, и он же приносит обработку всех восьми кодов ошибок загрузки PHP. Битая загрузка (превышен upload_max_filesize, обрыв, нет временной папки) приезжает как -32602 с указанием поля, а не как нерабочий UploadedFile, доехавший до метода.
Честно про безопасность
multipart/form-data - это CORS «simple request», ровно как form-encoded. А обязательный Content-Type: application/json, введённый в 5.0, как раз этот CSRF-вектор и закрывал. Значит, включение multipart его заново открывает - но только для методов с acceptsMultipart: true, и только для них.
Бандл не делает вид, что решил это за вас. Документация громко предупреждает: перед включением убедитесь, что для затронутых методов верно хотя бы одно - аутентификация не в cookies (заголовок-токен не входит в CORS-safelist), либо session-cookie помечена SameSite=Lax/Strict, либо метод проверяет CSRF-токен. Двухуровневый opt-in ограничивает радиус, остальное - осознанное решение приложения, а не бандла.
Ограничения первой версии тоже названы прямо: batch остаётся только JSON, файлы - только на верхнем уровне params, только POST.
Как это появилось - и почему это важнее фичи
Я не писал этот код. Всё началось с issue #8: tacman описал задачу, предложил две формы решения (минимальную и по образцу GraphQL multipart spec), честно разметил, куда это упрётся в гидрации, и спросил направление до того, как писать. Мы сошлись на форме в комментариях. Через несколько дней пришёл PR #9: семь коммитов, зелёный CI по всей матрице, включая гейты покрытия и мутационного тестирования, плюс отдельная ветка на демо-приложении, чтобы фичу можно было пощупать, а не только прочитать.
В паре мест его решение оказалось лучше того наброска, что был в issue - в частности, компиляция Assert\File из конфига, которую я в исходном плане не закладывал. Ревью заняло у меня один проход: прогнать его ветку локально (822 теста, Infection MSI выше гейта, PHPStan и cs-fixer чисто), прочитать диф целиком и оставить несколько необязательных заметок. Мержить было не страшно.
Для maintainer-а, который три года тянул проект в одиночку, первый серьёзный внешний PR - аккуратный, с тестами и демо - стоит больше любого числа звёзд. Это первый признак, что вокруг проекта собирается что-то живое, и, пожалуй, лучший исход, на который можно было надеяться, открывая исходники. Спасибо, tacman.
Поставить и попробовать
composer require otezvikentiy/json-rpc-api:^5.2
Релиз с полным описанием: 5.2 на GitHub.
Документация фичи (форма запроса, конфиг, каталог ошибок, паттерны для случаев вне рамок - base64 для мелкого, двухфазная загрузка для большого): docs/multipart.md.
Демо-проект, где всё работает вместе: symfony-jsonrpc-api-demo.
Бандл: github.com/OtezVikentiy/symfony-jsonrpc-api-bundle. Вопросы и идеи - в Discussions, баги - в Issues. Как показывает эта история, хороший issue иногда превращается в фичу - обратной связи буду рад.
Комментарии (4)

subzey
19.08.2026 09:01как раз этот CSRF-вектор и закрывал
А вот кстати, с 2019 года и 70 версии Firefox это уже не проблема.
Любой POST запрос в браузере, включая не-корсовые и отправку формы, посылает заголовок Origin
При помощи no-referrer значения самого поля можно скрыть (заменить на
null), но заставить браузер вообще не посылать Origin или как-то его подменить нельзя. Чтобы защититься от CSRF, на серверной стороне уже достаточно проверять его значение
otezvikentiy Автор
19.08.2026 09:01Спасибо, по фактам всё так: с Firefox 70 (2019) Origin действительно приходит на любом браузерном POST, включая отправку формы, и страницей его не подменить - заголовок forbidden. Проверка Origin на сервере - рабочая защита, она честно указана в OWASP среди анти-CSRF-мер. Добавлю, почему в доках всё равно нет рецепта "просто проверяйте Origin": 1. JSON-RPC API редко обслуживает только браузеры. curl, мобильное приложение, серверная интеграция не шлют Origin вовсе. Политика "нет Origin - отклонить" ломает легитимных клиентов, а "нет Origin - пропустить" делает защиту работающей только для браузерного трафика. Для CSRF этого формально достаточно, но универсальным рецепт перестаёт быть.
2. Вы сами отметили деградацию до null через no-referrer: атакующий может форсировать Origin: null, а серверу он неотличим от легитимного privacy-клиента. null приходится считать недоверенным - и снова см. пункт про "просто". Плюс старая оговорка: промежуточные прокси могут вырезать заголовок целиком.
3. С 2020 года основная мера и вовсе другая - SameSite=Lax по умолчанию: cookie без явного SameSite=None на cross-site POST просто не уезжает. Поэтому статья не про то, что защиты не существует, а про зону ответственности: бандл ограничивает радиус двухуровневым opt-in и предупреждает, а связку Origin/SameSite/токен выбирает приложение - исходя из того, кто его клиенты.
Rsa97
Но тем самым вы жёстко привязали свою версию JSON-RPC к HTTP, в то время, как
Если уж вы хотите полностью соблюдать стандарт, то любые бинарные данные должны передаваться через преобразование в строково-безопасный вид, например в base64.
otezvikentiy Автор
Замечание про дух спецификации справедливое, но фича устроена ровно наоборот: ядро протокола не тронуто. В multipart-запросе полный JSON-RPC request object едет нетронутой JSON-строкой в поле jsonrpc - method, params, id и ответ ровно те же, что и в обычном запросе. Это не «версия JSON-RPC», а опциональный транспортный биндинг, выключенный по умолчанию: метод, который его явно не объявил, отвечает -32600, а application/json работает как работал.
Привязка к HTTP - свойство самого бандла, а не этой фичи: это Symfony-бандл, который отдаёт JSON-RPC поверх HTTP. Сокеты и очереди сообщений вне его рамок независимо от multipart.
Насчёт base64 - спецификация 2.0 про бинарные данные не говорит вообще ничего, так что "должны" тут уже интерпретация. Base64 в строковом параметре работал и работает без какой-либо поддержки со стороны бандла, и в документации он прямо назван правильным ответом для мелких файлов - иконка, подпись, миниатюра. Multipart добавлен для случая, где base64 объективно плох: плюс треть к размеру на проводе и весь файл в памяти на обеих сторонах - при кодировании, при json_decode, при валидации.