В 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)


  1. Rsa97
    19.08.2026 09:01

    Но тем самым вы жёстко привязали свою версию JSON-RPC к HTTP, в то время, как

    It is transport agnostic in that the concepts can be used within the same process, over sockets, over http, or in many various message passing environments.

    Если уж вы хотите полностью соблюдать стандарт, то любые бинарные данные должны передаваться через преобразование в строково-безопасный вид, например в base64.


    1. otezvikentiy Автор
      19.08.2026 09:01

      Замечание про дух спецификации справедливое, но фича устроена ровно наоборот: ядро протокола не тронуто. В 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, при валидации.


  1. subzey
    19.08.2026 09:01

    как раз этот CSRF-вектор и закрывал

    А вот кстати, с 2019 года и 70 версии Firefox это уже не проблема.

    Любой POST запрос в браузере, включая не-корсовые и отправку формы, посылает заголовок Origin

    При помощи no-referrer значения самого поля можно скрыть (заменить на null), но заставить браузер вообще не посылать Origin или как-то его подменить нельзя. Чтобы защититься от CSRF, на серверной стороне уже достаточно проверять его значение


    1. 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/токен выбирает приложение - исходя из того, кто его клиенты.