В 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 иногда превращается в фичу - обратной связи буду рад.