buildViolation('Число должно быть конечным.')->setCode('FINITE_NUMBER_REQUIRED')->addViolation(); } }); } public static function number(float $min, float $max, bool $integer = false, bool $positive = false): Assert\Sequentially { $constraints = [ new Assert\Type(type: $integer ? 'integer' : ['integer', 'float'], message: $integer ? 'Ожидается целое JSON-число, а не строка.' : 'Ожидается JSON-число, а не строка.'), new Assert\NotNull(message: 'Укажите число.'), self::finite(), ]; if ($positive) { $constraints[] = new Assert\Positive(message: 'Значение должно быть больше нуля.'); } $constraints[] = new Assert\Range(min: $min, max: $max, notInRangeMessage: 'Допустимо значение от {{ min }} до {{ max }}.', invalidMessage: 'Укажите число.'); if (!$integer) { $constraints[] = new Assert\Callback(static function (mixed $value, ExecutionContextInterface $context): void { if (abs($value * 100 - round($value * 100)) > 0.00001) { $context->buildViolation('Допустимо не более двух знаков после запятой.')->setCode('DECIMAL_PRECISION')->addViolation(); } }); } return new Assert\Sequentially($constraints); } public static function flag(): Assert\Sequentially { return new Assert\Sequentially([ new Assert\Type(type: 'bool', message: 'Ожидается true или false, а не строка.'), new Assert\NotNull(message: 'Укажите согласие.'), new Assert\IsTrue(message: 'Нужно подтвердить согласие.'), ]); } public static function date(): Assert\Sequentially { return new Assert\Sequentially([ new Assert\Type(type: 'string', message: 'Дата должна быть строкой YYYY-MM-DD.'), new Assert\NotBlank(message: 'Укажите дату.', normalizer: 'trim'), new Assert\Date(message: 'Укажите существующую дату в формате YYYY-MM-DD.'), ]); } public static function collection(array $fields): Assert\Collection { return new Assert\Collection(fields: $fields, allowExtraFields: false, allowMissingFields: false, extraFieldsMessage: 'Это поле не поддерживается.', missingFieldsMessage: 'Обязательное поле отсутствует.'); } public static function rows(array $fields): Assert\Sequentially { return new Assert\Sequentially([ new Assert\Type(type: 'array', message: 'Ожидается JSON-массив строк.'), new Assert\NotNull(message: 'Передайте массив строк.'), new Assert\Callback(static function (mixed $value, ExecutionContextInterface $context): void { if (!array_is_list($value)) { $context->buildViolation('Строки должны быть списком с последовательными индексами.')->setCode('LIST_REQUIRED')->addViolation(); } }), new Assert\Count(min: 1, max: 10, minMessage: 'Добавьте хотя бы одну строку.', maxMessage: 'Допускается не более 10 строк.'), new Assert\All(constraints: [new Assert\Sequentially([ new Assert\Type(type: 'array', message: 'Строка должна быть JSON-объектом.'), new Assert\NotNull(message: 'Строка не может быть null.'), self::collection($fields), ])]), ]); } /** @return list */ public static function forScenario(string $scenario): array { return match ($scenario) { 'contact' => [self::collection([ 'name' => self::text(2, 80), 'email' => self::email(), 'message' => self::text(10, 1000), 'consent' => self::flag(), ])], 'registration' => [self::collection([ 'username' => new Assert\Sequentially([self::text(3, 24), new Assert\Regex(pattern: '/^[A-Za-z0-9_-]+$/D', message: 'Используйте латинские буквы, цифры, дефис или подчёркивание.')]), 'email' => self::email(), 'password' => self::text(8, 64, false), 'confirmPassword' => self::text(1, 64, false), 'terms' => self::flag(), ]), new Assert\Callback(static function (mixed $data, ExecutionContextInterface $context): void { if (is_string($data['password'] ?? null) && is_string($data['confirmPassword'] ?? null) && $data['password'] !== $data['confirmPassword']) { $context->buildViolation('Пароли не совпадают.')->atPath('[confirmPassword]')->setCode('PASSWORDS_DIFFER')->addViolation(); } })], 'estimate' => [self::collection([ 'title' => self::text(2, 80), 'quantity' => self::number(0, 10000, positive: true), 'unitPrice' => self::number(0, 1000000), 'discount' => self::number(0, 50), ])], 'period' => [self::collection(['start' => self::date(), 'end' => self::date()]), new Assert\Callback(static function (mixed $data, ExecutionContextInterface $context): void { $start = self::parsedDate($data['start'] ?? null); $end = self::parsedDate($data['end'] ?? null); if (!$start || !$end) { return; } if ($end < $start) { $context->buildViolation('Дата окончания не должна быть раньше начала.')->atPath('[end]')->setCode('PERIOD_ORDER')->addViolation(); } elseif ($start->diff($end)->days > 366) { $context->buildViolation('Разница между датами не должна превышать 366 дней.')->atPath('[end]')->setCode('PERIOD_TOO_LONG')->addViolation(); } })], 'order' => [self::collection([ 'customerEmail' => self::email(), 'delivery' => new Assert\Sequentially([self::text(1, 20), new Assert\Choice(choices: ['pickup', 'courier'], message: 'Выберите самовывоз или курьерскую доставку.')]), 'items' => self::rows(['sku' => self::text(2, 24), 'quantity' => self::number(1, 99, integer: true, positive: true), 'price' => self::number(0, 1000000)]), ])], 'import' => [self::collection([ 'rows' => self::rows(['name' => self::text(2, 80), 'email' => self::email(), 'age' => self::number(18, 120, integer: true)]), ])], default => throw new BadRequest('unsupported_scenario', 'Неизвестный сценарий.'), }; } public static function parsedDate(mixed $value): ?\DateTimeImmutable { if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2}$/D', $value)) { return null; } $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value, new \DateTimeZone('UTC')); return $date && $date->format('Y-m-d') === $value && checkdate((int) substr($value, 5, 2), (int) substr($value, 8, 2), (int) substr($value, 0, 4)) ? $date : null; } }