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. Не, входът трябва да бъде булев тип, т.е. само true или false. Бихме могли да разрешим null изрично с помощта на Expect::bool()->nullable().

Елементът може да бъде направен задължителен с помощта на Expect::bool()->required(). Стойността по подразбиране можем да променим например на false с помощта на Expect::bool()->default(false) или съкратено Expect::bool(false).

А какво, ако искахме освен булев тип да приемем и 1 и 0? Тогава ще посочим изброяване на стойности, които освен това ще оставим да бъдат нормализирани до булев тип:

$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, където е празен масив. (Списък е масив, индексиран по възходяща поредица от числови ключове от нула, т.е. неасоциативен масив).

Масиви от стойности: 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() приема вариантите като отделни параметри, а не като масив. Ако искате да му предадете масив от стойности, използвайте 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' => '']);
// ГРЕШКА: липсва опция '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(), // стойността по подразбиране е 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('Елементът %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() може да се посочи регулярен израз, на който трябва да отговаря целият входен низ (т.е. сякаш е обгърнат със знаците ^ и $):

// точно 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']);
// Failed assertion "Четен брой елементи в масива" 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', 'Всички знаци трябва да са с малки букви')
	->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('Всички знаци трябва да са с малки букви', '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