Schema : validation de données

Bibliothèque pratique pour la validation et la normalisation des structures de données par rapport à un schéma donné avec une API intelligente et compréhensible.

Installation :

composer require nette/schema

Utilisation de base

Dans la variable $schema, nous avons le schéma de validation (ce que cela signifie exactement et comment créer un tel schéma, nous le verrons dans un instant) et dans la variable $data, la structure de données que nous voulons valider et normaliser. Il peut s'agir, par exemple, de données envoyées par l'utilisateur via une API, d'un fichier de configuration, etc.

La tâche est assurée par la classe Nette\Schema\Processor, qui traite l'entrée et retourne soit les données normalisées, soit lève une exception Nette\Schema\ValidationException en cas d'erreur.

$processor = new Nette\Schema\Processor;

try {
	$normalized = $processor->process($schema, $data);
} catch (Nette\Schema\ValidationException $e) {
	echo 'Les données ne sont pas valides : ' . $e->getMessage();
}

La méthode $e->getMessages() retourne un tableau de tous les messages sous forme de chaînes et $e->getMessageObjects() retourne tous les messages sous forme d'objets Nette\Schema\Message.

Définition du schéma

Et maintenant, créons le schéma. La classe Nette\Schema\Expect sert à le définir, nous définissons en fait les attentes sur l'apparence des données. Disons que les données d'entrée doivent former une structure (par exemple, un tableau) contenant les éléments processRefund de type bool et refundAmount de type int.

use Nette\Schema\Expect;

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

Nous pensons que la définition du schéma semble compréhensible, même si vous la voyez pour la toute première fois.

Envoyons les données suivantes à la validation :

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

$normalized = $processor->process($schema, $data); // OK, passe la validation

La sortie, c'est-à-dire la valeur $normalized, est un objet stdClass. Si nous voulions que la sortie soit un tableau, nous ajouterions au schéma un cast Expect::structure([...])->castTo('array').

Tous les éléments de la structure sont facultatifs et ont une valeur par défaut de null. Exemple :

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

$normalized = $processor->process($schema, $data); // OK, passe la validation
// $normalized = {'processRefund' => null, 'refundAmount' => 17}

Le fait que la valeur par défaut soit null ne signifie pas qu'elle serait acceptée dans les données d'entrée 'processRefund' => null. Non, l'entrée doit être un booléen, c'est-à-dire seulement true ou false. Pour autoriser null, nous devrions l'autoriser explicitement à l'aide de Expect::bool()->nullable().

Un élément peut être rendu obligatoire à l'aide de Expect::bool()->required(). Nous changeons la valeur par défaut, par exemple, en false à l'aide de Expect::bool()->default(false) ou en abrégé Expect::bool(false).

Et si nous voulions accepter 1 et 0 en plus du booléen ? Alors nous indiquons une énumération de valeurs, que nous laissons de plus normaliser en booléen :

$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

Maintenant, vous connaissez les bases de la définition d'un schéma et du comportement des éléments individuels de la structure. Nous allons maintenant montrer quels autres éléments peuvent être utilisés lors de la définition d'un schéma.

Types de données : type()

Tous les types de données PHP standard peuvent être spécifiés dans le schéma :

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

Et aussi tous les types pris en charge par la classe Validators, par exemple Expect::type('scalar') ou en abrégé Expect::scalar(). Également les noms de classes ou d'interfaces, par exemple Expect::type('AddressEntity').

La notation d'union peut également être utilisée :

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

La valeur par défaut est toujours null sauf pour array et list, où c'est un tableau vide. (Une liste est un tableau indexé par une série ascendante de clés numériques à partir de zéro, c'est-à-dire un tableau non associatif).

Tableaux de valeurs : arrayOf() listOf()

Un tableau représente une structure trop générale, il est plus utile de spécifier exactement quels éléments il peut contenir. Par exemple, un tableau dont les éléments ne peuvent être que des chaînes :

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

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello', 'b' => 'world']); // OK
$processor->process($schema, ['key' => 123]); // ERREUR : 123 n'est pas une chaîne

Le deuxième paramètre peut spécifier les clés (depuis la version 1.2) :

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

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello']); // ERREUR : 'a' n'est pas un int

Une liste est un tableau indexé :

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

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // ERREUR : 123 n'est pas une chaîne
$processor->process($schema, ['key' => 'a']); // ERREUR : n'est pas une liste
$processor->process($schema, [1 => 'a', 0 => 'b']); // ERREUR : n'est pas non plus une liste

Le paramètre peut également être un schéma, nous pouvons donc écrire :

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

La valeur par défaut est un tableau vide. Si vous spécifiez une valeur par défaut, elle sera fusionnée avec les données transmises. Cela peut être désactivé à l'aide de mergeDefaults(false) (depuis la version 1.1).

Énumération : anyOf()

anyOf() représente une énumération de valeurs ou de schémas que la valeur peut prendre. Voici comment écrire un tableau d'éléments qui peuvent être soit 'a', true ou null :

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

$processor->process($schema, ['a', true, null, 'a']); // OK
$processor->process($schema, ['a', false]); // ERREUR : false n'appartient pas ici

Les éléments de l'énumération peuvent également être des schémas :

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

$processor->process($schema, ['foo', true, null, 'bar']); // OK
$processor->process($schema, [123]); // ERREUR

La méthode anyOf() accepte les variantes comme paramètres individuels, pas un tableau. Si vous voulez lui passer un tableau de valeurs, utilisez l'opérateur de décomposition anyOf(...$variants).

La valeur par défaut est null. Avec la méthode firstIsDefault(), nous faisons du premier élément la valeur par défaut :

// la valeur par défaut est 'hello'
Expect::anyOf(Expect::string('hello'), true, null)->firstIsDefault();

Structures

Les structures sont des objets avec des clés définies. Chaque paire clé ⇒ valeur est appelée une « propriété » :

Les structures acceptent les tableaux et les objets et retournent des objets stdClass.

Par défaut, toutes les propriétés sont facultatives et ont une valeur par défaut de null. Vous pouvez définir des propriétés obligatoires à l'aide de required() :

$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // la valeur par défaut est null
]);

$processor->process($schema, ['optional' => '']);
// ERREUR : l'option 'required' est manquante

$processor->process($schema, ['required' => 'foo']);
// OK, retourne {'required' => 'foo', 'optional' => null}

Si vous ne voulez pas avoir de propriétés avec une valeur par défaut dans la sortie, utilisez skipDefaults() :

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

$processor->process($schema, ['required' => 'foo']);
// OK, retourne {'required' => 'foo'}

Bien que null soit la valeur par défaut de la propriété optional, il n'est pas autorisé dans les données d'entrée (la valeur doit être une chaîne). Les propriétés acceptant null sont définies à l'aide de nullable() :

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

$processor->process($schema, ['optional' => null]);
// ERREUR : 'optional' s'attend à être une chaîne, null donné.

$processor->process($schema, ['nullable' => null]);
// OK, retourne {'optional' => null, 'nullable' => null}

La méthode getShape() retourne le tableau de toutes les propriétés de la structure.

Par défaut, aucun élément supplémentaire ne peut être présent dans les données d'entrée :

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

$processor->process($schema, ['additional' => 1]);
// ERREUR : Élément inattendu 'additional'

Ce que nous pouvons changer à l'aide de otherItems(). Comme paramètre, nous indiquons le schéma selon lequel les éléments supplémentaires seront validés :

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

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

Vous pouvez créer une nouvelle structure en dérivant d'une autre à l'aide de extend() :

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

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

Tableaux

Tableaux avec des clés définies. Tout ce qui s'applique aux structures s'applique également ici.

$schema = Expect::array([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // la valeur par défaut est null
]);

Il est également possible de définir un tableau indexé, connu sous le nom de tuple :

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

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

Propriétés obsolètes

Vous pouvez marquer une propriété comme obsolète à l'aide de la méthode deprecated([string $message]). Les informations sur la fin de prise en charge sont retournées via $processor->getWarnings() :

$schema = Expect::structure([
	'old' => Expect::int()->deprecated('L\'élément %path% est obsolète'),
]);

$processor->process($schema, ['old' => 1]); // OK
$processor->getWarnings(); // ["L'élément 'old' est obsolète"]

Plages : min() max()

À l'aide de min() et max(), on peut limiter le nombre d'éléments dans les tableaux :

// tableau, au moins 10 éléments, au plus 20 éléments
Expect::array()->min(10)->max(20);

Pour les chaînes, limiter leur longueur :

// chaîne, au moins 10 caractères de long, au plus 20 caractères
Expect::string()->min(10)->max(20);

Pour les nombres, limiter leur valeur :

// entier, entre 10 et 20 inclus
Expect::int()->min(10)->max(20);

Bien sûr, il est possible de n'indiquer que min(), ou seulement max() :

// chaîne d'au plus 20 caractères
Expect::string()->max(20);

Expressions régulières : pattern()

À l'aide de pattern(), on peut indiquer une expression régulière à laquelle toute la chaîne d'entrée doit correspondre (c'est-à-dire comme si elle était entourée des caractères ^ et $):

// exactement 9 chiffres
Expect::string()->pattern('\d{9}');

Contraintes personnalisées : assert()

Toute autre contrainte peut être spécifiée à l'aide de assert(callable $fn).

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

$schema = Expect::arrayOf('string')
	->assert($countIsEven); // le nombre doit être pair

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 'b', 'c']); // ERREUR : 3 n'est pas un nombre pair

Ou

Expect::string()->assert('is_file'); // le fichier doit exister

À chaque contrainte, vous pouvez ajouter votre propre description. Celle-ci fera partie du message d'erreur.

$schema = Expect::arrayOf('string')
	->assert($countIsEven, 'Éléments pairs dans le tableau');

$processor->process($schema, ['a', 'b', 'c']);
// Échec de l'assertion "Éléments pairs dans le tableau" pour l'élément avec la valeur array.

La méthode peut être appelée de manière répétée pour ajouter plusieurs contraintes. Elle peut être entrelacée avec les appels transform() et castTo().

Transformations : transform()

Les données validées avec succès peuvent être modifiées à l'aide d'une fonction personnalisée :

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

La méthode peut être appelée de manière répétée pour ajouter plusieurs transformations. Elle peut être entrelacée avec les appels assert() et castTo(). Les opérations sont effectuées dans l'ordre où elles sont déclarées :

Expect::type('string|int')
	->castTo('string')
	->assert('ctype_lower', 'Tous les caractères doivent être en minuscules')
	->transform(fn(string $s) => strtoupper($s)); // conversion en majuscules

La méthode transform() peut simultanément transformer et valider la valeur. C'est souvent plus simple et moins répétitif que d'enchaîner transform() et assert(). À cette fin, la fonction reçoit un objet Context avec la méthode addError(), qui peut être utilisée pour ajouter des informations sur les problèmes de validation :

Expect::string()
	->transform(function (string $s, Nette\Schema\Context $context) {
		if (!ctype_lower($s)) {
			$context->addError('Tous les caractères doivent être en minuscules', 'my.case.error');
			return null;
		}

		return strtoupper($s);
	});

Conversion de type : castTo()

Les données validées avec succès peuvent être converties :

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

En plus des types PHP natifs, il est possible de convertir vers des classes. On distingue alors s'il s'agit d'une classe simple sans constructeur ou d'une classe avec constructeur. Si la classe n'a pas de constructeur, son instance est créée et tous les éléments de la structure sont écrits dans les propriétés :

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

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

// crée '$obj = new Info' et écrit dans $obj->processRefund et $obj->refundAmount

Si la classe a un constructeur, les éléments de la structure sont passés comme paramètres nommés au constructeur :

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

// crée $obj = new Info(processRefund: ..., refundAmount: ...)

La conversion de type combinée à un paramètre scalaire crée un objet et passe la valeur comme unique paramètre au constructeur :

Expect::string()->castTo(DateTime::class);
// crée new DateTime(...)

Normalisation : before()

Avant la validation elle-même, les données peuvent être normalisées à l'aide de la méthode before(). Prenons comme exemple un élément qui doit être un tableau de chaînes (par exemple ['a', 'b', 'c']), mais qui accepte une entrée sous forme de chaîne a b c :

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

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

$normalized = $processor->process($schema, 'a b c');
// OK et retourne ['a', 'b', 'c']

Mappage vers des objets : from()

Nous pouvons laisser le schéma de structure être généré à partir d'une classe. Exemple :

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}

Les classes anonymes sont également prises en charge :

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

Comme les informations obtenues à partir de la définition de la classe peuvent ne pas être suffisantes, vous pouvez compléter les éléments avec votre propre schéma via le deuxième paramètre :

$schema = Expect::from(new Config, [
	'name' => Expect::string()->pattern('\w:.*'),
]);
version: 2.0