03 — Параметры и DTO
Три способа принимать параметры, выбираются по сигнатуре handler’а:
| Паттерн | Сигнатура | Когда |
|---|---|---|
| DTO | __invoke(MyRequest $req) |
Больше одного поля, особенно если нужна валидация. |
#[Rpc\Param] |
__invoke(#[Rpc\Param] int $userId) |
Один-два скаляра, или DTO ощущается избыточным. |
RpcRequest |
__invoke(RpcRequest $req) |
Схемы, меняющиеся в рантайме, или прокси, форвардящие как есть. |
Все три можно смешивать с injectable-параметрами (Context, Request).
Плоский объект params в JSON-запросе
Сколько бы PHP-параметров ни было у handler’а, клиент всегда шлёт один
JSON-объект в теле HTTP-запроса — внутри ключа params JSON-RPC (без
вложенного «мешка» на имя DTO-параметра):
{
"params": {
"email": "x@y",
"limit": 25,
"user_id": 42
}
}
Бандл маппит ключи так:
| Форма handler’а | Что в params |
MCP / OpenRPC inputSchema |
|---|---|---|
| Один DTO | Ключи = поля конструктора DTO | Те же плоские ключи |
| DTO + scalar(ы) | Поля DTO и scalar-ключи рядом | Те же плоские ключи |
Только scalars (#[Rpc\Param] или auto-promote) |
Один ключ на business-параметр | Те же плоские ключи |
| Несколько DTO | Объединение ctor-ключей всех DTO (плоско) | Те же плоские ключи — разрешено; дубликат JSON-ключа ломает сборку |
Только RpcRequest |
Что читаете вручную | Пустая / без авто-схемы |
Примеры:
// Один DTO — params: {"email": "...", "limit": 25}
public function __invoke(GetUserRequest $req, Context $ctx): array
// DTO + scalar — params: {"street": "...", "city": "...", "autoId": 7}
public function __invoke(AddressDto $address, #[Assert\Positive] int $autoId, Context $ctx): array
// Только scalars — params: {"user_id": 42, "reason": null}
public function __invoke(#[Rpc\Param('user_id')] int $userId, ?string $reason, Context $ctx): array
// Два DTO — params: {"street": "...", "city": "...", "email": "..."}
// (AddressDto + ContactDto, если имена полей ctor не пересекаются)
public function __invoke(AddressDto $address, ContactDto $contact, Context $ctx): array
Auto-promotion: «голый» builtin / mixed (не Context, не Request, не
RpcRequest, не class-DTO) ведёт себя как #[Rpc\Param] с name =
имя PHP-параметра — попадает в MCP/OpenRPC без атрибута.
Владение ключом проверяется при сборке контейнера — каждый JSON-ключ в
params должен принадлежать ровно одному business-параметру. Коллизия ломает
сборку (поле city в двух DTO, или DTO id и scalar $id). Лимита на число
DTO нет — запрещены только повторяющиеся ключи.
Паттерн 1 — DTO
final readonly class GetUserRequest
{
public function __construct(
#[Assert\Email]
public string $email,
#[Assert\Range(min: 1, max: 100)]
public int $limit = 25,
) {}
}
#[Rpc\Method('user.get')]
final class GetUser
{
public function __invoke(GetUserRequest $req, Context $ctx): array
{
// $req полностью валидирован; $req->email — гарантированно непустая email-строка.
}
}
Dispatcher:
- Денормализует JSON-объект в DTO через Symfony
DenormalizerInterface. - Валидирует получившийся instance через Symfony Validator.
- Бросает
InvalidParamsException(-32602) при любой ошибке, со списком per-field violations вerror.data.
Ошибки денормализации отдаются клиенту обобщёнными безопасными текстами —
собственные сообщения serializer’а содержат имена классов DTO и другие
внутренности, поэтому до клиента не доходят (исходное исключение остаётся в
previous для серверных логов):
{
"error": {
"code": -32602,
"message": "Invalid params",
"data": [
{"path": "age", "message": "This value should be of type int.", "code": null},
{"path": "createdAt", "message": "This value is not valid.", "code": null}
]
}
}
- Обязательное поле, которого нет в params, даёт
This field is missing. - Неверное значение (включая явный
nullдля non-nullable поля), чей ожидаемый тип встроенный (int,string,bool,array, …), даётThis value should be of type <type>.— union-типы склеиваются через|, напримерint|null. - Неверное значение, для которого ожидается класс (дата, enum, вложенный DTO)
или тип неизвестен, —
This value is not valid. - Любая другая ошибка serializer’а (например, исключение из custom
denormalizer’а) отдаёт
Invalid paramsбезdata.
Отказ от unknown-полей
По дефолту неизвестные ключи дают Invalid params:
{"params": {"email": "x@y", "limit": 25, "deprecatedField": 1}}
{
"error": {
"code": -32602,
"message": "Unknown parameter(s): deprecatedField. Set ...",
"data": [{"path": "deprecatedField", "message": "Unknown parameter", "code": null}]
}
}
Тот же переключатель действует на позиционные параметры: массив [...]
длиннее объявленных параметров метода (или аргументов конструктора DTO, см.
ниже) отклоняется, а не теряет лишние значения молча:
{
"error": {
"code": -32602,
"message": "Too many positional parameters: expected at most 2, got 3"
}
}
Методы без бизнес-параметров (инжектятся только Context / RpcRequest /
Request) освобождены от обеих проверок — они сами читают params из
envelope.
Ловит опечатки клиентов. Отключите per-method, когда нужна обратная совместимость (неизвестные ключи и лишние позиционные значения тогда игнорируются):
#[Rpc\Method('user.legacy_get', rejectUnknown: false)]
Или глобально:
json_rpc_server:
params:
reject_unknown: false
Позиционные параметры для DTO-метода
По дефолту метод с одним DTO требует именованных параметров ({...}).
Позиционные ([...]) отклоняются — они привязывают порядок аргументов
конструктора DTO к публичному API.
Чтобы разрешить:
#[Rpc\Method('user.get', allowPositionalDto: true)]
Или глобально:
json_rpc_server:
params:
allow_positional_dto: true
При разрешении "params": ["x@y", 25] маппится позиционно на аргументы
конструктора DTO. Значений больше, чем аргументов конструктора, — это -32602,
если rejectUnknown не отключён.
Вложенный DTO vs массив DTO
Поле DTO может быть другим DTO или списком DTO:
final readonly class TeamRequest
{
/**
* @param list<MemberDto> $members
*/
public function __construct(
#[Assert\Valid]
public array $members,
) {}
}
Что клиент кладёт в params:
{"params": {"members": [{"name": "alice"}, {"name": "bob"}]}}
- Runtime (PHP): Symfony Serializer денормализует элементы в
MemberDto, если в PHPDoc конструктора указаноlist<MemberDto>(тот же doc, что и без бандла). Для валидации элементов —#[Assert\Valid]. -
MCP / OpenRPC (документация):
JsonSchemaBuilderчитает PHPDoc черезsymfony/property-infoи добавляет ключ JSON Schemaitems— это под-схема одного элемента, а не JSON-массив из запроса клиента.Не путать:
Назначение Форма params.membersв JSON запросаТо, что реально шлёт Postman / фронт JSON-массив объектов: [{"name":"alice"}, …]inputSchema.properties.members.itemsПравило из JSON Schema JSON-объект — схема элемента: { "type": "object", "properties": { "name": … } }itemsздесь не[]— пустой JSON-массив вitemsозначал бы tuple-validation (draft-07), бандл так не строит схему. Дляlist<MemberDto>сервер отдаёт, например:"members": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"], "additionalProperties": false } }Для
list<string>и других scalar-элементов в схеме пока только{ "type": "array" }безitems— вложенныйitemsстроится для object-типов.
Ассоциативные map (array<string, T>)
В PHP поле объявлено как array, а клиент в JSON шлёт объект
{ "ключ": значение, … }, а не массив […]. Типичные примеры: filters,
tags, карты метаданных.
Укажите PHPDoc на promoted-свойстве или параметре конструктора:
final readonly class ListRequest
{
public function __construct(
/** @var array<string, FilterSpec> */
public array $filters = [],
) {}
}
Пример запроса (фрагмент params):
{"params": {"filters": {"groupId": {"mode": "include", "value": [1]}}}}
Сгенерированная схема OpenRPC / MCP (type: object, не type: array + items):
"filters": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": { "mode": { "type": "string" }, "value": { "type": "array" } }
}
}
@var — на свойстве (promoted ctor param) или на matching @param у
конструктора; docblock только на классе не читается. Union в значении
(array<string, A|B>) → additionalProperties.oneOf.
Паттерн 2 — #[Rpc\Param]
Для метода с одним-двумя скалярами DTO избыточен. Используйте #[Rpc\Param]:
#[Rpc\Method('user.findById')]
final class FindById
{
public function __invoke(
#[Rpc\Param('user_id')] // переименовать JSON-ключ
#[Assert\Positive] // стандартный валидатор
int $userId,
#[Rpc\Param('reason', required: false)]
?string $reason = null,
Context $ctx,
): array {
// $userId провалидирован (positive). $reason может быть null.
}
}
Эффекты:
name:— JSON-ключ, по которому ищется значение ({"user_id": 42}↔$userId). По умолчанию совпадает с именем PHP-параметра.- Validator-атрибуты (
#[Assert\Positive],#[Assert\Email], …) на том же параметре проверяются. Violations всплывают как -32602 с именем параметра вpath. - Параметр попадает в MCP
inputSchemaи OpenRPC документ — даже DTO-less методы остаются discoverable.
required: — это информация для JSON Schema. Реальная обязательность
определяется PHP-сигнатурой: default value или nullable тип делает параметр
optional.
Паттерн 3 — RpcRequest
Для методов, которым нужно инспектировать сырой envelope (кастомный роутинг, прокси, обобщённые схемы):
#[Rpc\Method('legacy.proxy')]
final class LegacyProxy
{
public function __invoke(RpcRequest $req): array
{
// $req->id, $req->method, $req->params, $req->isNotification
$value = $req->params->requireString('targetMethod');
// ...
}
}
Аксессоры RpcParams
$req->params — это RpcParams, типизированный доступ к JSON-RPC params.
Похож на InputBag из Symfony:
| Метод | Возвращает | При отсутствии или null |
|---|---|---|
getString($key, $default) |
string |
возвращает $default |
getInt($key, $default) |
int |
возвращает $default |
getFloat($key, $default) |
float |
возвращает $default |
getBool($key, $default) |
bool |
возвращает $default |
getArray($key, $default) |
array |
возвращает $default |
requireString($key) |
string |
бросает InvalidParamsException |
requireInt($key) |
int |
бросает InvalidParamsException |
requireFloat($key) |
float |
бросает InvalidParamsException |
requireBool($key) |
bool |
бросает InvalidParamsException |
requireArray($key) |
array |
бросает InvalidParamsException |
Все типизированные геттеры строгие: при wrong-shape value бросают -32602, не делают silent coercion.
Позиционный доступ: $req->params->at(0), $req->params->isList(),
$req->params->count().
Инжектируемые параметры
Распознаются по типу и резолвятся диспатчером — никогда не приходят из JSON envelope’а:
| Тип | Что это |
|---|---|
Knetesin\JsonRpcServerBundle\Context\Context |
Per-call context: methodName, requestId, user, roles. См. Context. |
Symfony\Component\HttpFoundation\Request |
HTTP-запрос. Берётся из RequestStack. Бросает если нет активного запроса (например, в unit-тесте вне контекста). |
Knetesin\JsonRpcServerBundle\Request\RpcRequest |
Декодированный JSON-RPC envelope. |
Можно сочетать — __invoke(MyDto $req, Context $ctx, Request $http)
работает.
Даты и date-time
Бандл поставляется с Knetesin\JsonRpcServerBundle\Type\Date для “даты без времени” (PHP
такого типа не имеет):
final readonly class CreateEventRequest
{
public function __construct(
public Date $startsOn, // только дата
public \DateTimeImmutable $startsAt, // дата+время
) {}
}
Input лояльный — бандл принимает ISO, кастомные форматы, “yesterday” или unix-timestamp’ы в зависимости от конфигурации:
json_rpc_server:
serializer:
datetime_format: 'iso8601' # или 'timestamp' / 'timestamp_ms' / custom php date()
date_format: 'Y-m-d'
timezone: 'UTC'
Например, при datetime_format: timestamp_ms:
- Output:
DateTimeImmutable→ integer (Unix ms) - Input: любое из
1773483072345,"2026-03-14T10:11:12+00:00","yesterday" - MCP/OpenRPC схемы корректно отдают
{type: 'integer'}
Детали в Configuration reference.