Schema: validação de dados

Biblioteca prática para validação e normalização de estruturas de dados em relação a um esquema fornecido com uma API inteligente e compreensível.

Instalação:

composer require nette/schema

Uso básico

Na variável $schema, temos o esquema de validação (o que isso significa exatamente e como criar tal esquema será explicado em breve) e na variável $data, a estrutura de dados que queremos validar e normalizar. Podem ser, por exemplo, dados enviados pelo usuário através de uma interface API, um arquivo de configuração, etc.

A tarefa é realizada pela classe Nette\Schema\Processor, que processa a entrada e retorna os dados normalizados ou lança uma exceção Nette\Schema\ValidationException em caso de erro.

$processor = new Nette\Schema\Processor;

try {
	$normalized = $processor->process($schema, $data);
} catch (Nette\Schema\ValidationException $e) {
	echo 'Dados não são válidos: ' . $e->getMessage();
}

O método $e->getMessages() retorna um array de todas as mensagens como strings e $e->getMessageObjects() retorna todas as mensagens como objetos Nette\Schema\Message.

Definindo o esquema

E agora criaremos o esquema. A classe Nette\Schema\Expect é usada para defini-lo, definimos na verdade as expectativas de como os dados devem parecer. Digamos que os dados de entrada devem formar uma estrutura (por exemplo, um array) contendo os elementos processRefund do tipo bool e refundAmount do tipo int.

use Nette\Schema\Expect;

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

Acreditamos que a definição do esquema parece compreensível, mesmo que você a veja pela primeira vez.

Enviaremos os seguintes dados para validação:

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

$normalized = $processor->process($schema, $data); // OK, passa na validação

A saída, ou seja, o valor $normalized, é um objeto stdClass. Se quiséssemos que a saída fosse um array, adicionaríamos a conversão de tipo ao esquema Expect::structure([...])->castTo('array').

Todos os elementos da estrutura são opcionais e têm um valor padrão de null. Exemplo:

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

$normalized = $processor->process($schema, $data); // OK, passa na validação
// $normalized = {'processRefund' => null, 'refundAmount' => 17}

O fato de o valor padrão ser null não significa que 'processRefund' => null seria aceito nos dados de entrada. Não, a entrada deve ser um booleano, ou seja, apenas true ou false. Teríamos que permitir null explicitamente usando Expect::bool()->nullable().

Um item pode ser tornado obrigatório usando Expect::bool()->required(). Mudamos o valor padrão para false, por exemplo, usando Expect::bool()->default(false) ou abreviadamente Expect::bool(false).

E se quiséssemos aceitar 1 e 0 além de booleano? Então listamos os valores, que também deixamos normalizar para 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

Agora você conhece os fundamentos de como definir um esquema e como os elementos individuais da estrutura se comportam. Agora mostraremos quais outros elementos podem ser usados na definição do esquema.

Tipos de dados: type()

Todos os tipos de dados PHP padrão podem ser especificados no esquema:

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

E também todos os tipos suportados pela classe Validators, por exemplo, Expect::type('scalar') ou abreviadamente Expect::scalar(). Também nomes de classes ou interfaces, por exemplo, Expect::type('AddressEntity').

Também é possível usar a notação de união:

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

O valor padrão é sempre null, exceto para array e list, onde é um array vazio. (Uma lista é um array indexado por uma série ascendente de chaves numéricas a partir de zero, ou seja, um array não associativo).

Array de valores: arrayOf() listOf()

Um array representa uma estrutura muito geral, é mais útil especificar exatamente quais elementos ele pode conter. Por exemplo, um array cujos elementos só podem ser strings:

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

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello', 'b' => 'world']); // OK
$processor->process($schema, ['key' => 123]); // ERRO: 123 não é string

O segundo parâmetro pode especificar as chaves (desde a versão 1.2):

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

$processor->process($schema, ['hello', 'world']); // OK
$processor->process($schema, ['a' => 'hello']); // ERRO: 'a' não é int

Uma lista é um array indexado:

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

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // ERRO: 123 não é string
$processor->process($schema, ['key' => 'a']); // ERRO: não é lista
$processor->process($schema, [1 => 'a', 0 => 'b']); // ERRO: também não é lista

O parâmetro também pode ser um esquema, então podemos escrever:

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

O valor padrão é um array vazio. Se você especificar um valor padrão, ele será mesclado com os dados passados. Isso pode ser desativado usando mergeDefaults(false) (desde a versão 1.1).

Enumeração: anyOf()

anyOf() representa uma enumeração de valores ou esquemas que um valor pode assumir. É assim que escrevemos um array de elementos que podem ser '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]); // ERRO: false não pertence aqui

Os elementos da enumeração também podem ser esquemas:

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

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

O método anyOf() aceita variantes como parâmetros individuais, não um array. Se você quiser passar um array de valores para ele, use o operador de desempacotamento anyOf(...$variants).

O valor padrão é null. Com o método firstIsDefault(), tornamos o primeiro elemento o padrão:

// o padrão é 'hello'
Expect::anyOf(Expect::string('hello'), true, null)->firstIsDefault();

Estruturas

Estruturas são objetos com chaves definidas. Cada par chave ⇒ valor é referido como uma „propriedade“:

Estruturas aceitam arrays e objetos e retornam objetos stdClass.

Por padrão, todas as propriedades são opcionais e têm um valor padrão de null. Você pode definir propriedades obrigatórias usando required():

$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // valor padrão é null
]);

$processor->process($schema, ['optional' => '']);
// ERRO: opção 'required' está faltando

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

Se você não quiser ter propriedades com valor padrão na saída, use skipDefaults():

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

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

Embora null seja o valor padrão da propriedade optional, ele não é permitido nos dados de entrada (o valor deve ser uma string). Propriedades que aceitam null são definidas usando nullable():

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

$processor->process($schema, ['optional' => null]);
// ERRO: 'optional' espera ser string, null fornecido.

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

O array de todas as propriedades da estrutura é retornado pelo método getShape().

Por padrão, não pode haver itens extras nos dados de entrada:

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

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

O que podemos mudar usando otherItems(). Como parâmetro, especificamos o esquema segundo o qual os elementos extras serão validados:

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

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

Você pode criar uma nova estrutura derivando de outra usando extend():

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

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

Arrays

Arrays com chaves definidas. Tudo o que se aplica a estruturas também se aplica a ele.

$schema = Expect::array([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // valor padrão é null
]);

Também é possível definir um array indexado, conhecido como tupla:

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

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

Propriedades obsoletas

Você pode marcar uma propriedade como obsoleta usando o método deprecated([string $message]). Informações sobre o fim do suporte são retornadas usando $processor->getWarnings():

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

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

Intervalos: min() max()

Usando min() e max(), é possível limitar o número de elementos em arrays:

// array, pelo menos 10 itens, no máximo 20 itens
Expect::array()->min(10)->max(20);

Em strings, limitar seu comprimento:

// string, pelo menos 10 caracteres de comprimento, no máximo 20 caracteres
Expect::string()->min(10)->max(20);

Em números, limitar seu valor:

// inteiro, entre 10 e 20 inclusive
Expect::int()->min(10)->max(20);

Claro, é possível especificar apenas min(), ou apenas max():

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

Expressões regulares: pattern()

Usando pattern(), é possível especificar uma expressão regular que deve corresponder a toda a string de entrada (ou seja, como se estivesse envolvida pelos caracteres ^ e $):

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

Restrições personalizadas: assert()

Quaisquer outras restrições são especificadas usando assert(callable $fn).

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

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

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 'b', 'c']); // ERRO: 3 não é um número par

Ou

Expect::string()->assert('is_file'); // o arquivo deve existir

Você pode adicionar sua própria descrição a cada restrição. Ela fará parte da mensagem de erro.

$schema = Expect::arrayOf('string')
	->assert($countIsEven, 'Itens pares no array');

$processor->process($schema, ['a', 'b', 'c']);
// Falha na asserção "Itens pares no array" para o item com valor array.

O método pode ser chamado repetidamente para adicionar mais restrições. Pode ser intercalado com chamadas a transform() e castTo().

Transformações: transform()

Dados validados com sucesso podem ser modificados usando uma função personalizada:

// conversão para maiúsculas:
Expect::string()->transform(fn(string $s) => strtoupper($s));

O método pode ser chamado repetidamente para adicionar mais transformações. Pode ser intercalado com chamadas a assert() e castTo(). As operações são realizadas na ordem em que são declaradas:

Expect::type('string|int')
	->castTo('string')
	->assert('ctype_lower', 'Todos os caracteres devem estar em minúsculas')
	->transform(fn(string $s) => strtoupper($s)); // conversão para maiúsculas

O método transform() pode transformar e validar o valor simultaneamente. Isso costuma ser mais simples e menos duplicado do que encadear transform() e assert(). Para este propósito, a função recebe um objeto Context com o método addError(), que pode ser usado para adicionar informações sobre problemas de validação:

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

		return strtoupper($s);
	});

Conversão de tipo: castTo()

Dados validados com sucesso podem ser convertidos:

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

Além dos tipos nativos do PHP, também é possível converter para classes. Distingue-se se é uma classe simples sem construtor ou uma classe com construtor. Se a classe não tiver construtor, sua instância é criada e todos os elementos da estrutura são escritos nas propriedades:

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

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

// cria '$obj = new Info' e escreve em $obj->processRefund e $obj->refundAmount

Se a classe tiver um construtor, os elementos da estrutura são passados como parâmetros nomeados para o construtor:

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

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

A conversão de tipo em combinação com um parâmetro escalar cria um objeto e passa o valor como o único parâmetro para o construtor:

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

Normalização: before()

Antes da própria validação, os dados podem ser normalizados usando o método before(). Como exemplo, considere um elemento que deve ser um array de strings (por exemplo, ['a', 'b', 'c']), mas aceita entrada na forma de uma string a b c:

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

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

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

Mapeamento para objetos: from()

Podemos ter o esquema da estrutura gerado a partir de uma classe. Exemplo:

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}

Classes anônimas também são suportadas:

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

Como as informações obtidas da definição da classe podem não ser suficientes, você pode adicionar seu próprio esquema aos elementos com o segundo parâmetro:

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