Schema: validación de datos

Una biblioteca práctica para validar y normalizar estructuras de datos contra un esquema dado con una API inteligente y comprensible.

Instalación:

composer require nette/schema

Uso básico

En la variable $schema tenemos el esquema de validación (qué significa exactamente y cómo crear dicho esquema lo diremos en breve) y en la variable $data la estructura de datos que queremos validar y normalizar. Pueden ser, por ejemplo, datos enviados por el usuario a través de una interfaz API, un archivo de configuración, etc.

La tarea la realiza la clase Nette\Schema\Processor, que procesa la entrada y devuelve los datos normalizados o lanza una excepción Nette\Schema\ValidationException en caso de error.

$processor = new Nette\Schema\Processor;

try {
	$normalized = $processor->process($schema, $data);
} catch (Nette\Schema\ValidationException $e) {
	echo 'Los datos no son válidos: ' . $e->getMessage();
}

El método $e->getMessages() devuelve un array de todos los mensajes como cadenas y $e->getMessageObjects() devuelve todos los mensajes como objetos Nette\Schema\Message.

Definición del esquema

Y ahora creemos el esquema. Para definirlo sirve la clase Nette\Schema\Expect, definimos básicamente las expectativas de cómo deben verse los datos. Digamos que los datos de entrada deben formar una estructura (por ejemplo, un array) que contenga los elementos processRefund de tipo bool y refundAmount de tipo int.

use Nette\Schema\Expect;

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

Creemos que la definición del esquema parece comprensible, incluso si la ve por primera vez.

Enviemos los siguientes datos para validación:

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

$normalized = $processor->process($schema, $data); // OK, pasa la validación

La salida, es decir, el valor $normalized, es un objeto stdClass. Si quisiéramos que la salida fuera un array, complementaríamos el esquema con la conversión de tipos Expect::structure([...])->castTo('array').

Todos los elementos de la estructura son opcionales y tienen un valor predeterminado de null. Ejemplo:

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

$normalized = $processor->process($schema, $data); // OK, pasa la validación
// $normalized = {'processRefund' => null, 'refundAmount' => 17}

El hecho de que el valor predeterminado sea null no significa que se acepte en los datos de entrada 'processRefund' => null. No, la entrada debe ser un booleano, es decir, solo true o false. Tendríamos que permitir null explícitamente usando Expect::bool()->nullable().

Un elemento se puede hacer obligatorio usando Expect::bool()->required(). Cambiamos el valor predeterminado, por ejemplo, a false usando Expect::bool()->default(false) o de forma abreviada Expect::bool(false).

¿Y si quisiéramos aceptar 1 y 0 además de booleanos? Entonces especificamos una enumeración de valores, que además dejamos normalizar a 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

Ahora ya conoce los conceptos básicos de cómo se define un esquema y cómo se comportan los elementos individuales de la estructura. Ahora mostraremos qué otros elementos se pueden usar al definir un esquema.

Tipos de datos: type()

En el esquema se pueden especificar todos los tipos de datos estándar de PHP:

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

Y además todos los tipos soportados por la clase Validators, por ejemplo Expect::type('scalar') o abreviado Expect::scalar(). También nombres de clases o interfaces, por ejemplo Expect::type('AddressEntity').

También se puede usar la notación de unión:

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

El valor predeterminado siempre es null excepto para array y list, donde es un array vacío. (Una lista es un array indexado según una serie ascendente de claves numéricas desde cero, es decir, un array no asociativo).

Arrays de valores: arrayOf() listOf()

Un array representa una estructura demasiado general, es más útil especificar exactamente qué elementos puede contener. Por ejemplo, un array cuyos elementos solo pueden ser cadenas:

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

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello', 'b' => 'world']); // OK
$processor->process($schema, ['key' => 123]); // ERROR: 123 no es una cadena

Con el segundo parámetro se pueden especificar las claves (desde la versión 1.2):

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

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello']); // ERROR: 'a' no es un int

Una lista es un array indexado:

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

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // ERROR: 123 no es una cadena
$processor->process($schema, ['key' => 'a']); // ERROR: no es una lista
$processor->process($schema, [1 => 'a', 0 => 'b']); // ERROR: tampoco es una lista

El parámetro también puede ser un esquema, por lo que podemos escribir:

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

El valor predeterminado es un array vacío. Si especifica un valor predeterminado, se fusionará con los datos pasados. Esto se puede desactivar usando mergeDefaults(false) (desde la versión 1.1).

Enumeración: anyOf()

anyOf() representa una enumeración de valores o esquemas que puede tomar un valor. Así escribimos un array de elementos que pueden ser '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]); // ERROR: false no pertenece allí

Los elementos de la enumeración también pueden ser esquemas:

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

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

El método anyOf() acepta variantes como parámetros individuales, no un array. Si desea pasarle un array de valores, use el operador de desempaquetado anyOf(...$variants).

El valor predeterminado es null. Con el método firstIsDefault() hacemos que el primer elemento sea el predeterminado:

// el predeterminado es 'hello'
Expect::anyOf(Expect::string('hello'), true, null)->firstIsDefault();

Estructuras

Las estructuras son objetos con claves definidas. Cada uno de los pares clave ⇒ valor se denomina “propiedad”:

Las estructuras aceptan arrays y objetos y devuelven objetos stdClass.

De forma predeterminada, todas las propiedades son opcionales y tienen un valor predeterminado de null. Puede definir propiedades obligatorias usando required():

$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // el valor predeterminado es null
]);

$processor->process($schema, ['optional' => '']);
// ERROR: la opción 'required' falta

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

Si no desea tener propiedades con el valor predeterminado en la salida, use skipDefaults():

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

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

Aunque null es el valor predeterminado de la propiedad optional, no está permitido en los datos de entrada (el valor debe ser una cadena). Las propiedades que aceptan null se definen usando nullable():

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

$processor->process($schema, ['optional' => null]);
// ERROR: 'optional' espera ser una cadena, se dio null.

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

El método getShape() devuelve un array de todas las propiedades de la estructura.

De forma predeterminada, no puede haber elementos adicionales en los datos de entrada:

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

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

Lo cual podemos cambiar usando otherItems(). Como parámetro, especificamos un esquema según el cual se validarán los elementos adicionales:

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

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

Puede crear una nueva estructura derivándola de otra usando extend():

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

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

Arrays

Arrays con claves definidas. Todo lo que se aplica a las estructuras también se aplica a ellos.

$schema = Expect::array([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // el valor predeterminado es null
]);

También se puede definir un array indexado, conocido como tupla:

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

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

Propiedades obsoletas

Puede marcar una propiedad como obsoleta usando el método deprecated([string $message]). La información sobre el fin del soporte se devuelve mediante $processor->getWarnings():

$schema = Expect::structure([
	'old' => Expect::int()->deprecated('El elemento %path% está obsoleto'),
]);

$processor->process($schema, ['old' => 1]); // OK
$processor->getWarnings(); // ["El elemento 'old' está obsoleto"]

Rangos: min() max()

Con min() y max() se puede limitar el número de elementos en los arrays:

// array, al menos 10 elementos, máximo 20 elementos
Expect::array()->min(10)->max(20);

En las cadenas, limitar su longitud:

// cadena, al menos 10 caracteres de longitud, máximo 20 caracteres
Expect::string()->min(10)->max(20);

En los números, limitar su valor:

// número entero, entre 10 y 20 inclusive
Expect::int()->min(10)->max(20);

Por supuesto, es posible especificar solo min() o solo max():

// cadena máximo 20 caracteres
Expect::string()->max(20);

Expresiones regulares: pattern()

Con pattern() se puede especificar una expresión regular con la que debe coincidir toda la cadena de entrada (es decir, como si estuviera envuelta en los caracteres ^ y $):

// exactamente 9 números
Expect::string()->pattern('\d{9}');

Restricciones personalizadas: assert()

Cualquier otra restricción se especifica usando assert(callable $fn).

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

$schema = Expect::arrayOf('string')
	->assert($countIsEven); // el número debe ser par

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 'b', 'c']); // ERROR: 3 no es un número par

O

Expect::string()->assert('is_file'); // el archivo debe existir

A cada restricción puede agregarle su propia descripción. Esta formará parte del mensaje de error.

$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.

El método se puede llamar repetidamente para agregar más restricciones. Se puede intercalar con llamadas a transform() y castTo().

Transformaciones: transform()

Los datos validados con éxito se pueden modificar usando una función personalizada:

// conversión a mayúsculas:
Expect::string()->transform(fn(string $s) => strtoupper($s));

El método se puede llamar repetidamente para agregar más transformaciones. Se puede intercalar con llamadas a assert() y castTo(). Las operaciones se realizan en el orden en que se declaran:

Expect::type('string|int')
	->castTo('string')
	->assert('ctype_lower', 'Todos los caracteres deben estar en minúsculas')
	->transform(fn(string $s) => strtoupper($s)); // conversión a mayúsculas

El método transform() puede transformar y validar simultáneamente el valor. Esto suele ser más simple y menos duplicado que encadenar transform() y assert(). Para este propósito, la función recibe un objeto Context con el método addError(), que se puede usar para agregar información sobre problemas de validación:

Expect::string()
	->transform(function (string $s, Nette\Schema\Context $context) {
		if (!ctype_lower($s)) {
			$context->addError('Todos los caracteres deben estar en minúsculas', 'my.case.error');
			return null;
		}

		return strtoupper($s);
	});

Conversión de tipos: castTo()

Los datos validados con éxito se pueden convertir de tipo:

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

Además de los tipos nativos de PHP, también se puede convertir a clases. Aquí se distingue si se trata de una clase simple sin constructor o una clase con constructor. Si la clase no tiene constructor, se crea su instancia y todos los elementos de la estructura se escriben en las propiedades:

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

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

// crea '$obj = new Info' y escribe en $obj->processRefund y $obj->refundAmount

Si la clase tiene constructor, los elementos de la estructura se pasan como parámetros con nombre al constructor:

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

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

La conversión de tipos en combinación con un parámetro escalar crea un objeto y pasa el valor como único parámetro al constructor:

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

Normalización: before()

Antes de la validación misma, los datos se pueden normalizar usando el método before(). Como ejemplo, mencionemos un elemento que debe ser un array de cadenas (por ejemplo, ['a', 'b', 'c']), pero acepta la entrada en forma de cadena a b c:

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

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

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

Mapeo a objetos: from()

Podemos hacer que el esquema de la estructura se genere a partir de una clase. Ejemplo:

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}

También se admiten clases anónimas:

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

Dado que la información obtenida de la definición de la clase puede no ser suficiente, puede complementar los elementos con su propio esquema usando el segundo parámetro:

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