Schema: валидация данных

Практичная библиотека для проверки и нормализации структур данных по заданной схеме с интеллектуальным и простым в понимании API.

Установка:

composer require nette/schema

Использование

В переменной $schema у нас есть схема валидации (что именно это значит и как её создать, мы расскажем позже), а в переменной $data у нас есть структура данных, которую мы хотим валидировать и нормализовать. Это могут быть, например, данные, отправленные пользователем через API, конфигурационный файл и т. д.

Задачей занимается класс Nette\Schema\Processor, который обрабатывает входные данные и либо возвращает нормализованные данные, либо выбрасывает исключение Nette\Schema\ValidationException при ошибке.

$processor = new Nette\Schema\Processor;

try {
	$normalized = $processor->process($schema, $data);
} catch (Nette\Schema\ValidationException $e) {
	echo 'Data is invalid: ' . $e->getMessage();
}

Метод $e->getMessages() возвращает массив всех строк сообщений, а $e->getMessageObjects() возвращает все сообщения в виде объектов Nette\Schema\Message.

Определение схемы

А теперь давайте создадим схему. С помощью класса Nette\Schema\Expect мы фактически определяем, как должны выглядеть данные. Предположим, что входные данные должны представлять собой структуру (например, массив), содержащий элементы processRefund типа bool и refundAmount типа int.

use Nette\Schema\Expect;

$schema = Expect::structure([
	'processRefund' => Expect::bool(),
	'refundAmount' => Expect::int(),
]);

Мы считаем, что определение схемы выглядит понятным, даже если вы видите его в первый раз.

Отправим следующие данные для проверки:

$data = [
	'processRefund' => true,
	'refundAmount' => 17,
];

$normalized = $processor->process($schema, $data); // OK, проходит

Выход, т. е. значением $normalized, является объект stdClass. Если мы хотим, чтобы результатом был массив, мы добавляем приведение к схеме Expect::structure([...])->castTo('array').

Все элементы структуры являются необязательными и имеют значение по умолчанию null. Пример:

$data = [
	'refundAmount' => 17,
];

$normalized = $processor->process($schema, $data); // OK, проходит
// $normalized = {'processRefund' => null, 'refundAmount' => 17}

Тот факт, что значением по умолчанию является null, не означает, что оно будет принято во входных данных 'processRefund' => null. Нет, входные данные должны быть булевыми, т. е. только true или false. Нам пришлось бы явно разрешить null через Expect::bool()->nullable().

Элемент можно сделать обязательным, используя Expect::bool()->required(). Мы меняем значение по умолчанию на false, используя Expect::bool()->default(false) или коротко Expect::bool(false).

А что если мы захотим принимать 1 и 0 помимо булевых чисел? Перечислим допустимые значения, которые мы также нормализуем в boolean:

$schema = Expect::structure([
	'processRefund' => Expect::anyOf(true, false, 1, 0)->castTo('bool'),
	'refundAmount' => Expect::int(),
]);

$normalized = $processor->process($schema, $data);
is_bool($normalized->processRefund); // true

Теперь вы знаете основы того, как определяется схема и как ведут себя отдельные элементы структуры. Теперь мы покажем, какие ещё элементы могут быть использованы при определении схемы.

Типы данных: type()

Все стандартные типы данных PHP могут быть перечислены в схеме:

Expect::string($default = null)
Expect::int($default = null)
Expect::float($default = null)
Expect::bool($default = null)
Expect::null()
Expect::array($default = [])

А затем все типы, поддерживаемые валидаторами через Expect::type('scalar') или сокращенно Expect::scalar(). Также принимаются имена классов или интерфейсов, например: Expect::type('AddressEntity').

Вы также можете использовать нотацию объединения:

Expect::type('bool|string|array')

Значение по умолчанию всегда null, за исключением array и list, где это пустой массив. (Список — это массив, индексированный в порядке возрастания числовых ключей от нуля, то есть неассоциативный массив).

Массив значений: arrayOf() listOf()

Массив — слишком общая структура, полезнее указать, какие именно элементы он может содержать. Например, массив, элементами которого могут быть только строки:

$schema = Expect::arrayOf('string');

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello', 'b' => 'world']); // OK
$processor->process($schema, ['key' => 123]); // ОШИБКА: 123 не строка

Второй параметр может использоваться для указания ключей (начиная с версии 1.2):

$schema = Expect::arrayOf('string', 'int');

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello']); // ОШИБКА: 'a' не int

Список представляет собой индексированный массив:

$schema = Expect::listOf('string');

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // ОШИБКА: 123 не строка
$processor->process($schema, ['key' => 'a']); // ОШИБКА: не список
$processor->process($schema, [1 => 'a', 0 => 'b']); // ОШИБКА: не список

Параметр также может быть схемой, поэтому мы можем написать:

Expect::arrayOf(Expect::bool())

Значение по умолчанию — пустой массив. Если вы укажете значение по умолчанию, оно будет объединено с переданными данными. Это можно отключить с помощью mergeDefaults(false) (начиная с версии 1.1).

Перечисление: anyOf()

anyOf() — это набор значений или схем, которыми может быть значение. Вот как записать массив элементов, которые могут быть либо 'a', либо true, либо null:

$schema = Expect::listOf(
	Expect::anyOf('a', true, null),
);

$processor->process($schema, ['a', true, null, 'a']); // OK
$processor->process($schema, ['a', false]); // ОШИБКА: false тут не место

Элементы перечисления также могут быть схемами:

$schema = Expect::listOf(
	Expect::anyOf(Expect::string(), true, null),
);

$processor->process($schema, ['foo', true, null, 'bar']); // OK
$processor->process($schema, [123]); // ОШИБКА

Метод anyOf() принимает варианты как отдельные параметры, а не как массив. Чтобы передать ему массив значений, используйте оператор распаковки anyOf(...$variants).

Значение по умолчанию — null. Используйте метод firstIsDefault(), чтобы сделать первый элемент элементом по умолчанию:

// default is 'hello'
Expect::anyOf(Expect::string('hello'), true, null)->firstIsDefault();

Структуры

Структуры — это объекты с определенными ключами. Каждая из этих пар ключ ⇒ значение называется “свойством”:

Структуры принимают массивы и объекты и возвращают объекты stdClass.

По умолчанию все свойства являются необязательными и имеют значение по умолчанию null. Вы можете определить обязательные свойства, используя required():

$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // значение по умолчанию — null
]);

$processor->process($schema, ['optional' => '']);
// ОШИБКА: опция 'required' отсутствует

$processor->process($schema, ['required' => 'foo']);
// OK, возвращает {'required' => 'foo', 'optional' => null}

Если вы не хотите выводить свойства только со значением по умолчанию, используйте skipDefaults():

$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(),
])->skipDefaults();

$processor->process($schema, ['required' => 'foo']);
// OK, возвращает {'required' => 'foo'}

Хотя null является значением по умолчанию свойства optional, оно не допускается во входных данных (значение должно быть строкой). Свойства, принимающие значение null, определяются с помощью nullable():

$schema = Expect::structure([
	'optional' => Expect::string(),
	'nullable' => Expect::string()->nullable(),
]);

$processor->process($schema, ['optional' => null]);
// ОШИБКА: 'optional' ожидается как строка, а предоставляется null.

$processor->process($schema, ['nullable' => null]);
// OK, возвращает {'optional' => null, 'nullable' => null}

Массив всех свойств структуры возвращается методом getShape().

По умолчанию, во входных данных не может быть лишних элементов:

$schema = Expect::structure([
	'key' => Expect::string(),
]);

$processor->process($schema, ['additional' => 1]);
// ОШИБКА: Неожиданный элемент 'additional'

Подобные элементы изменить с помощью otherItems(). В качестве параметра мы укажем схему для каждого дополнительного элемента:

$schema = Expect::structure([
	'key' => Expect::string(),
])->otherItems(Expect::int());

$processor->process($schema, ['additional' => 1]); // OK
$processor->process($schema, ['additional' => true]); // ОШИБКА

Вы можете создать новую структуру, производя ее от другой с помощью extend():

$dog = Expect::structure([
	'name' => Expect::string(),
	'age' => Expect::int(),
]);

$dogWithBreed = $dog->extend([
	'breed' => Expect::string(),
]);

Массив

Массив с определенными ключами. Применяются те же правила, что и для структур.

$schema = Expect::array([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // default value is null
]);

Вы также можете определить индексированный массив, известный как кортеж:

$schema = Expect::array([
	Expect::int(),
	Expect::string(),
	Expect::bool(),
]);

$processor->process($schema, [1, 'hello', true]); // OK

Устаревшие элементы

Вы можете объявить свойство устаревшим, используя метод deprecated([string $message]). Уведомления об устаревании возвращаются с помощью $processor->getWarnings():

$schema = Expect::structure([
	'old' => Expect::int()->deprecated('Элемент %path% устарел'),
]);

$processor->process($schema, ['old' => 1]); // OK
$processor->getWarnings(); // ["Элемент 'old' устарел"]

Диапазоны: min() max()

Используйте min() и max() для ограничения количества элементов в массивах:

// массив, минимум 10 элементов, максимум 20 элементов
Expect::array()->min(10)->max(20);

Для строк ограничивает их длину:

// строка, длиной не менее 10 символов, максимум 20 символов
Expect::string()->min(10)->max(20);

Для чисел ограничивает их значение:

// целое число, от 10 до 20 включительно
Expect::int()->min(10)->max(20);

Конечно, можно упомянуть только min(), или только max():

// строка, максимум 20 символов
Expect::string()->max(20);

Регулярные выражения: pattern()

Используя pattern(), вы можете указать регулярное выражение, которому должна соответствовать вся входная строка (т.е. как если бы она была завернута в символы ^ a $):

// только 9 цифр
Expect::string()->pattern('\d{9}');

Пользовательские утверждения: assert()

Вы можете добавить любые другие ограничения, используя assert(callable $fn).

$countIsEven = fn($v) => count($v) % 2 === 0;

$schema = Expect::arrayOf('string')
	->assert($countIsEven); // число должно быть чётным

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 'b', 'c']); // ОШИБКА: 3 - нечётное число

Или

Expect::string()->assert('is_file'); // файл должен существовать

Вы можете добавить собственное описание для каждого утверждения. Оно будет частью сообщения об ошибке.

$schema = Expect::arrayOf('string')
	->assert($countIsEven, 'Четные элементы в массиве');

$processor->process($schema, ['a', 'b', 'c']);
// Неудачное утверждение "Четные элементы в массиве" для элемента с массивом значений.

Этот метод можно вызывать многократно для добавления нескольких ограничений. Его можно смешивать с вызовами transform() и castTo().

Трансформация: transform()

Успешно подтвержденные данные могут быть изменены с помощью пользовательской функции:

// conversion to uppercase:
Expect::string()->transform(fn(string $s) => strtoupper($s));

Этот метод можно вызывать многократно для добавления нескольких преобразований. Он может перемежаться с вызовами assert() и castTo(). Операции будут выполняться в том порядке, в котором они объявлены:

Expect::type('string|int')
	->castTo('string')
	->assert('ctype_lower', 'All characters must be lowercased')
	->transform(fn(string $s) => strtoupper($s)); // conversion to uppercase

Метод transform() может одновременно преобразовывать и проверять значение. Это часто проще и менее избыточно, чем выстраивание цепочек transform() и assert(). Для этого функция получает объект Context с методом addError(), который может быть использован для добавления информации о проблемах валидации:

Expect::string()
	->transform(function (string $s, Nette\Schema\Context $context) {
		if (!ctype_lower($s)) {
			$context->addError('All characters must be lowercased', 'my.case.error');
			return null;
		}

		return strtoupper($s);
	});

Кастинг: castTo()

Успешно проверенные данные могут быть приведены:

Expect::scalar()->castTo('string');

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

class Info
{
	public bool $processRefund;
	public int $refundAmount;
}

Expect::structure([
	'processRefund' => Expect::bool(),
	'refundAmount' => Expect::int(),
])->castTo(Info::class);

// creates '$obj = new Info' and writes to $obj->processRefund and $obj->refundAmount

Если класс имеет конструктор, то элементы структуры передаются конструктору в качестве именованных параметров:

class Info
{
	public function __construct(
		public bool $processRefund,
		public int $refundAmount,
	) {
	}
}

// creates $obj = new Info(processRefund: ..., refundAmount: ...)

Кастинг в сочетании со скалярным параметром создает объект и передает его значение в качестве единственного параметра конструктору:

Expect::string()->castTo(DateTime::class);
// creates new DateTime(...)

Нормализация: before()

Перед самой проверкой данные могут быть нормализованы с помощью метода before(). В качестве примера, пусть есть элемент, который должен быть массивом строк (например, ['a', 'b', 'c']), но получает входные данные в виде строки a b c:

$explode = fn($v) => explode(' ', $v);

$schema = Expect::arrayOf('string')
	->before($explode);

$normalized = $processor->process($schema, 'a b c');
// OK, возвращает ['a', 'b', 'c']

Отображение на объекты: from()

Из класса можно сгенерировать структурную схему. Пример:

class Config
{
	public string $name;
	public string|null $password;
	public bool $admin = false;
}

$schema = Expect::from(new Config);

$data = [
	'name' => 'jeff',
];

$normalized = $processor->process($schema, $data);
// $normalized instanceof Config
// $normalized = {'name' => 'jeff', 'password' => null, 'admin' => false}

Поддерживаются также анонимные классы:

$schema = Expect::from(new class {
	public string $name;
	public ?string $password;
	public bool $admin = false;
});

Поскольку информации, полученной из определения класса, может быть недостаточно, можно добавить пользовательскую схему для элементов с помощью второго параметра:

$schema = Expect::from(new Config, [
	'name' => Expect::string()->pattern('\w:.*'),
]);
версия: 2.0