Schema: validazione dei dati

Una libreria pratica per la validazione e la normalizzazione delle strutture dati rispetto a uno schema dato con un'API intelligente e comprensibile.

Installazione:

composer require nette/schema

Utilizzo di base

Nella variabile $schema abbiamo lo schema di validazione (cosa significa esattamente e come creare tale schema lo diremo tra poco) e nella variabile $data la struttura dati che vogliamo validare e normalizzare. Può trattarsi, ad esempio, di dati inviati dall'utente tramite un'interfaccia API, un file di configurazione, ecc.

Il compito è gestito dalla classe Nette\Schema\Processor, che elabora l'input e restituisce i dati normalizzati oppure, in caso di errore, lancia un'eccezione Nette\Schema\ValidationException.

$processor = new Nette\Schema\Processor;

try {
	$normalized = $processor->process($schema, $data);
} catch (Nette\Schema\ValidationException $e) {
	echo 'I dati non sono validi: ' . $e->getMessage();
}

Il metodo $e->getMessages() restituisce un array di tutti i messaggi come stringhe e $e->getMessageObjects() restituisce tutti i messaggi come oggetti Nette\Schema\Message.

Definizione dello schema

E ora creiamo lo schema. Per definirlo si usa la classe Nette\Schema\Expect, definiamo in pratica le aspettative su come devono apparire i dati. Diciamo che i dati di input devono formare una struttura (ad esempio un array) contenente gli elementi processRefund di tipo bool e refundAmount di tipo int.

use Nette\Schema\Expect;

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

Crediamo che la definizione dello schema appaia comprensibile, anche se la vedi per la prima volta.

Inviamo i seguenti dati per la validazione:

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

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

L'output, ovvero il valore $normalized, è un oggetto stdClass. Se volessimo che l'output fosse un array, aggiungeremmo allo schema il cast Expect::structure([...])->castTo('array').

Tutti gli elementi della struttura sono opzionali e hanno un valore predefinito null. Esempio:

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

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

Il fatto che il valore predefinito sia null non significa che sarebbe accettato nei dati di input 'processRefund' => null. No, l'input deve essere un booleano, quindi solo true o false. Dovremmo consentire null esplicitamente usando Expect::bool()->nullable().

Un elemento può essere reso obbligatorio con Expect::bool()->required(). Cambiamo il valore predefinito ad esempio in false con Expect::bool()->default(false) o abbreviato Expect::bool(false).

E se volessimo accettare anche 1 e 0 oltre al booleano? Allora specifichiamo un elenco di valori, che inoltre facciamo normalizzare in booleano:

$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

Ora conosci le basi di come si definisce uno schema e come si comportano i singoli elementi della struttura. Ora mostreremo quali altri elementi possono essere utilizzati nella definizione dello schema.

Tipi di dati: type()

Nello schema è possibile specificare tutti i tipi di dati PHP standard:

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

E inoltre tutti i tipi supportati dalla classe Validators, ad esempio Expect::type('scalar') o abbreviato Expect::scalar(). Anche nomi di classi o interfacce, ad esempio Expect::type('AddressEntity').

È possibile utilizzare anche la notazione union:

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

Il valore predefinito è sempre null ad eccezione di array e list, dove è un array vuoto. (List è un array indicizzato secondo una serie crescente di chiavi numeriche a partire da zero, cioè un array non associativo).

Array di valori: arrayOf() listOf()

Array rappresenta una struttura troppo generica, è più utile specificare quali elementi può contenere esattamente. Ad esempio, un array i cui elementi possono essere solo stringhe:

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

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello', 'b' => 'world']); // OK
$processor->process($schema, ['key' => 123]); // ERRORE: 123 non è una stringa

Con il secondo parametro è possibile specificare le chiavi (dalla versione 1.2):

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

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello']); // ERRORE: 'a' non è un int

List è un array indicizzato:

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

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // ERRORE: 123 non è una stringa
$processor->process($schema, ['key' => 'a']); // ERRORE: non è una lista
$processor->process($schema, [1 => 'a', 0 => 'b']); // ERRORE: anche questo non è una lista

Il parametro può essere anche uno schema, quindi possiamo scrivere:

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

Il valore predefinito è un array vuoto. Se specifichi un valore predefinito, verrà unito ai dati passati. Questo può essere disattivato con mergeDefaults(false) (dalla versione 1.1).

Enumerazione: anyOf()

anyOf() rappresenta un elenco di valori o schemi che un valore può assumere. In questo modo scriviamo un array di elementi che possono essere 'a', true o null:

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

$processor->process($schema, ['a', true, null, 'a']); // OK
$processor->process($schema, ['a', false]); // ERRORE: false non appartiene lì

Gli elementi dell'enumerazione possono essere anche schemi:

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

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

Il metodo anyOf() accetta le varianti come parametri singoli, non un array. Se vuoi passargli un array di valori, usa l'operatore unpacking anyOf(...$variants).

Il valore predefinito è null. Con il metodo firstIsDefault() rendiamo il primo elemento predefinito:

// il predefinito è 'hello'
Expect::anyOf(Expect::string('hello'), true, null)->firstIsDefault();

Strutture

Le strutture sono oggetti con chiavi definite. Ogni coppia chiave ⇒ valore è indicata come “proprietà”:

Le strutture accettano array e oggetti e restituiscono oggetti stdClass.

Per impostazione predefinita, tutte le proprietà sono opzionali e hanno un valore predefinito null. Puoi definire proprietà obbligatorie usando required():

$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // il valore predefinito è null
]);

$processor->process($schema, ['optional' => '']);
// ERRORE: l'opzione 'required' è mancante

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

Se non vuoi avere nell'output proprietà con il valore predefinito, usa skipDefaults():

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

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

Sebbene null sia il valore predefinito della proprietà optional, non è consentito nei dati di input (il valore deve essere una stringa). Definiamo proprietà che accettano null usando nullable():

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

$processor->process($schema, ['optional' => null]);
// ERRORE: 'optional' si aspetta di essere string, null dato.

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

Il metodo getShape() restituisce un array di tutte le proprietà della struttura.

Per impostazione predefinita, non possono esserci elementi aggiuntivi nei dati di input:

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

$processor->process($schema, ['additional' => 1]);
// ERRORE: Elemento inaspettato 'additional'

Possiamo cambiarlo usando otherItems(). Come parametro specifichiamo lo schema secondo cui verranno validati gli elementi aggiuntivi:

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

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

Puoi creare una nuova struttura derivandola da un'altra usando extend():

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

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

Array

Array con chiavi definite. Vale per esso tutto ciò che vale per le strutture.

$schema = Expect::array([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // il valore predefinito è null
]);

È possibile definire anche un array indicizzato, noto come tupla:

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

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

Proprietà deprecate

Puoi contrassegnare una proprietà come deprecata usando il metodo deprecated([string $message]). Le informazioni sulla fine del supporto vengono restituite tramite $processor->getWarnings():

$schema = Expect::structure([
	'old' => Expect::int()->deprecated('L\'elemento %path% è deprecato'),
]);

$processor->process($schema, ['old' => 1]); // OK
$processor->getWarnings(); // ["L'elemento 'old' è deprecato"]

Intervalli: min() max()

Con min() e max() è possibile limitare il numero di elementi negli array:

// array, almeno 10 elementi, massimo 20 elementi
Expect::array()->min(10)->max(20);

Nelle stringhe limitare la loro lunghezza:

// stringa, lunga almeno 10 caratteri, massimo 20 caratteri
Expect::string()->min(10)->max(20);

Nei numeri limitare il loro valore:

// numero intero, tra 10 e 20 inclusi
Expect::int()->min(10)->max(20);

Naturalmente è possibile specificare solo min() o solo max():

// stringa massimo 20 caratteri
Expect::string()->max(20);

Espressioni regolari: pattern()

Con pattern() è possibile specificare un'espressione regolare a cui deve corrispondere l'intera stringa di input (cioè come se fosse racchiusa tra i caratteri ^ e $):

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

Restrizioni personalizzate: assert()

Qualsiasi altra restrizione può essere specificata usando assert(callable $fn).

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

$schema = Expect::arrayOf('string')
	->assert($countIsEven); // il numero deve essere pari

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 'b', 'c']); // ERRORE: 3 non è un numero pari

Oppure

Expect::string()->assert('is_file'); // il file deve esistere

Ad ogni restrizione puoi aggiungere una descrizione personalizzata. Questa farà parte del messaggio di errore.

$schema = Expect::arrayOf('string')
	->assert($countIsEven, 'Elementi pari nell\'array');

$processor->process($schema, ['a', 'b', 'c']);
// Asserzione fallita "Elementi pari nell'array" per l'elemento con valore array.

Il metodo può essere chiamato ripetutamente per aggiungere più restrizioni. Può essere intervallato con chiamate a transform() e castTo().

Trasformazioni: transform()

I dati validati con successo possono essere modificati utilizzando una funzione personalizzata:

// conversione in maiuscolo:
Expect::string()->transform(fn(string $s) => strtoupper($s));

Il metodo può essere chiamato ripetutamente per aggiungere più trasformazioni. Può essere intervallato con chiamate a assert() e castTo(). Le operazioni vengono eseguite nell'ordine in cui sono dichiarate:

Expect::type('string|int')
	->castTo('string')
	->assert('ctype_lower', 'Tutti i caratteri devono essere minuscoli')
	->transform(fn(string $s) => strtoupper($s)); // conversione in maiuscolo

Il metodo transform() può contemporaneamente trasformare e validare il valore. Questo è spesso più semplice e meno duplicato rispetto alla concatenazione di transform() e assert(). A tal fine, la funzione riceve un oggetto Context con il metodo addError(), che può essere utilizzato per aggiungere informazioni sui problemi di validazione:

Expect::string()
	->transform(function (string $s, Nette\Schema\Context $context) {
		if (!ctype_lower($s)) {
			$context->addError('Tutti i caratteri devono essere minuscoli', 'my.case.error');
			return null;
		}

		return strtoupper($s);
	});

Casting: castTo()

I dati validati con successo possono essere convertiti:

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

Oltre ai tipi PHP nativi, è possibile eseguire il cast anche a classi. Si distingue se si tratta di una classe semplice senza costruttore o di una classe con costruttore. Se la classe non ha un costruttore, viene creata la sua istanza e tutti gli elementi della struttura vengono scritti nelle proprietà:

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

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

// crea '$obj = new Info' e scrive in $obj->processRefund e $obj->refundAmount

Se la classe ha un costruttore, gli elementi della struttura vengono passati come parametri nominati al costruttore:

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

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

Il casting in combinazione con un parametro scalare crea un oggetto e passa il valore come unico parametro al costruttore:

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

Normalizzazione: before()

Prima della validazione stessa, i dati possono essere normalizzati usando il metodo before(). Come esempio, prendiamo un elemento che deve essere un array di stringhe (ad esempio ['a', 'b', 'c']), ma accetta input sotto forma di stringa a b c:

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

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

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

Mappatura su oggetti: from()

Possiamo far generare lo schema della struttura da una classe. Esempio:

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}

Sono supportate anche le classi anonime:

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

Poiché le informazioni ottenute dalla definizione della classe potrebbero non essere sufficienti, puoi aggiungere uno schema personalizzato agli elementi con il secondo parametro:

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