Schema: Convalida dei dati

Una libreria pratica per la validazione e la normalizzazione delle strutture di dati rispetto a un determinato schema, con un'API intelligente e di facile comprensione.

Installazione:

composer require nette/schema

Uso di base

Nella variabile $schema abbiamo uno schema di validazione (cosa significa esattamente e come crearlo lo diremo più avanti) e nella variabile $data abbiamo una struttura di dati che vogliamo validare e normalizzare. Può trattarsi, ad esempio, di dati inviati dall'utente tramite un'API, un file di configurazione, ecc.

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

$processor = new Nette\Schema\Processor;

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

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

Definizione dello schema

Creiamo ora uno schema. Per definirlo, si usa la classe Nette\Schema\Expect. In pratica, si definiscono le aspettative su come devono apparire i dati. Diciamo che i dati di input devono essere una struttura (ad esempio un array) contenente elementi processRefund di tipo bool e refundAmount di tipo int.

use Nette\Schema\Expect;

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

La definizione dello schema appare chiara, anche se la si vede per la prima volta.

Inviamo i seguenti dati per la convalida:

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

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

L'output, cioè il valore $normalized, è l'oggetto stdClass. Se vogliamo che l'output sia un array, aggiungiamo un cast allo schema 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
// $normalized = {'processRefund' => null, 'refundAmount' => 17}

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

Un elemento può essere reso obbligatorio utilizzando Expect::bool()->required(). Cambiamo il valore predefinito in false usando Expect::bool()->default(false) o a breve usando Expect::bool(false).

E se volessimo accettare 1 and 0 oltre ai booleani? Allora elenchiamo i valori consentiti, che normalizzeremo anch'essi a booleani:

$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 si conoscono le basi di come viene definito lo schema e come si comportano i singoli elementi della struttura. Ora mostreremo quali sono gli altri elementi che possono essere utilizzati nella definizione di uno schema.

Tipi di dati: tipo()

Tutti i tipi di dati standard di PHP possono essere elencati nello schema:

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

E poi tutti i tipi supportati dai validatori tramite Expect::type('scalar') o Expect::scalar() abbreviato. Sono accettati anche nomi di classi o interfacce, ad esempio Expect::type('AddressEntity').

Si può anche usare la notazione unione:

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

Il valore predefinito è sempre null, tranne che per array e list, dove è un array vuoto. (Un elenco è un array indicizzato in ordine crescente di chiavi numeriche a partire da zero, cioè un array non associativo).

Array di valori: arrayOf() listOf()

L'array è una struttura troppo generica, è più utile specificare esattamente quali elementi può contenere. 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

Il secondo parametro può essere utilizzato per specificare le chiavi (dalla versione 1.2):

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

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

L'elenco è 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: non è un elenco

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

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

Il valore predefinito è un array vuoto. Se si specifica un valore predefinito, questo verrà unito ai dati passati. Questo può essere disabilitato usando mergeDefaults(false) (dalla versione 1.1).

Enumerazione: anyOf()

anyOf() è un insieme di valori o schemi che un valore può essere. Ecco come scrivere 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 a questa categoria

Anche gli elementi dell'enumerazione possono essere 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 individuali, non come array. Per passargli un array di valori, utilizzare l'operatore di spacchettamento anyOf(...$variants).

Il valore predefinito è null. Utilizzare il metodo firstIsDefault() per rendere il primo elemento il valore predefinito:

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

Strutture

Le strutture sono oggetti con chiavi definite. Ciascuna di queste coppie chiave ⇒ valore viene definita “proprietà”:

Le strutture accettano array e oggetti e restituiscono oggetti stdClass (a meno che non si cambi con castTo('array'), ecc.).

Per impostazione predefinita, tutte le proprietà sono opzionali e hanno un valore predefinito di null. È possibile definire proprietà obbligatorie utilizzando 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 si vuole che vengano emesse proprietà con un valore predefinito, utilizzare 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). Le proprietà che accettano null sono definite con nullable():

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

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

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

Per impostazione predefinita, non ci possono essere elementi extra nei dati di input:

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

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

Che possiamo cambiare con otherItems(). Come parametro, specificheremo lo schema per ogni elemento extra:

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

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

Deprecazioni

È possibile deprecare una proprietà utilizzando il metodo deprecated([string $message]) . Gli avvisi di deprecazione sono restituiti da $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()

Utilizzate min() e max() per limitare il numero di elementi degli array:

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

Per le stringhe, limitare la loro lunghezza:

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

Per i numeri, limitarne il valore:

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

Naturalmente, è possibile citare solo min(), o solo max():

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

Espressioni regolari: pattern()

Utilizzando pattern(), è possibile specificare un'espressione regolare a cui deve corrispondere l'intera stringa di input (cioè come se fosse avvolta da caratteri ^ a $):

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

Asserzioni personalizzate: assert()

È possibile aggiungere qualsiasi altra restrizione utilizzando assert(callable $fn).

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

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

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

Oppure

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

È possibile aggiungere la propria descrizione per ogni asserzione. Sarà 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 richiamato più volte per aggiungere più vincoli. Può essere mescolato con le chiamate a transform() e castTo().

Trasformazione: transform()

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

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

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

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

Il metodo transform() può trasformare e convalidare il valore contemporaneamente. Spesso è più semplice e meno ridondante che concatenare transform() e assert(). A questo scopo, la funzione riceve un oggetto Context con un metodo addError(), che può essere usato per aggiungere informazioni sui problemi di validazione:

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);
	});

Casting: castTo()

I dati convalidati con successo possono essere lanciati:

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

Oltre ai tipi nativi di PHP, è possibile eseguire il cast su 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, ne viene creata un'istanza e tutti gli elementi della struttura vengono scritti nelle sue proprietà:

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

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

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

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

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

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

Normalizzazione: before()

Prima della validazione vera e propria, i dati possono essere normalizzati con il metodo before(). A titolo di esempio, abbiamo un elemento che deve essere un array di stringhe (es. ['a', 'b', 'c']), ma che riceve un 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, restituisce ['a', 'b', 'c']

Mappatura a oggetti: from()

È possibile generare uno schema di struttura dalla classe. Esempio:

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}

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, è possibile aggiungere uno schema personalizzato per gli elementi con il secondo parametro:

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