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 'Дані недійсні: ' . $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. Ні, входом має бути boolean, тобто лише true або false. Дозволити null ми б мали явно за допомогою Expect::bool()->nullable().

Елемент можна зробити обов'язковим за допомогою Expect::bool()->required(). Значення за замовчуванням змінимо, наприклад, на false за допомогою Expect::bool()->default(false) або скорочено Expect::bool(false).

А що, якби ми хотіли крім boolean приймати ще 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 = [])

А також усі типи, підтримувані класом Validators, наприклад Expect::type('scalar') або скорочено Expect::scalar(). Також назви класів чи інтерфейсів, наприклад Expect::type('AddressEntity').

Можна використовувати і union запис:

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

Значення за замовчуванням завжди null, за винятком array та list, де це порожній масив. (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

List — це індексований масив:

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

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // ПОМИЛКА: 123 не є рядком
$processor->process($schema, ['key' => 'a']); // ПОМИЛКА: не є list
$processor->process($schema, [1 => 'a', 0 => 'b']); // ПОМИЛКА: також не є list

Параметром може бути і схема, тому ми можемо записати:

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() приймає варіанти як окремі параметри, а не масив. Якщо ви хочете передати йому масив значень, використовуйте unpacking оператор anyOf(...$variants).

Значення за замовчуванням — null. Методом firstIsDefault() зробимо перший елемент значенням за замовчуванням:

// значення за замовчуванням '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' => '']);
// ПОМИЛКА: option 'required' is missing

$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' expects to be string, null given.

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

Масив усіх властивостей структури повертає метод getShape().

За замовчуванням у вхідних даних не можуть бути жодні зайві елементи:

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

$processor->process($schema, ['additional' => 1]);
// ПОМИЛКА: Unexpected item '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(), // значення за замовчуванням null
]);

Можна також визначити індексований масив, відомий як кортеж (tuple):

$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('The item %path% is deprecated'),
]);

$processor->process($schema, ['old' => 1]); // OK
$processor->getWarnings(); // ["The item 'old' is deprecated"]

Діапазони: 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() можна вказати регулярний вираз, якому має відповідати весь вхідний рядок (тобто, ніби він був обгорнутий символами ^ та $):

// рівно 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, 'Even items in array');

$processor->process($schema, ['a', 'b', 'c']);
// Failed assertion "Even items in array" for item with value array.

Метод можна викликати повторно і так додати більше обмежень. Його можна чергувати з викликами transform() та castTo().

Трансформації: transform()

Успішно валідовані дані можна модифікувати за допомогою власної функції:

// перетворення на великі літери:
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)); // перетворення на великі літери

Метод 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);

// створить '$obj = new Info' і запише в $obj->processRefund та $obj->refundAmount

Якщо клас має конструктор, елементи структури передаються як іменовані параметри конструктора:

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

// створить $obj = new Info(processRefund: ..., refundAmount: ...)

Перетворення типу в поєднанні зі скалярним параметром створить об'єкт і передасть значення як єдиний параметр конструктора:

Expect::string()->castTo(DateTime::class);
// створить 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' => 'franta',
];

$normalized = $processor->process($schema, $data);
// $normalized instanceof Config
// $normalized = {'name' => 'franta', '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