Giter Site home page Giter Site logo

nette / schema Goto Github PK

View Code? Open in Web Editor NEW
860.0 25.0 26.0 136 KB

πŸ“ Validating data structures against a given Schema.

Home Page: https://doc.nette.org/schema

License: Other

PHP 100.00%
nette nette-framework validation schema json data-structures php

schema's Introduction

Nette Schema


Downloads this Month Tests Coverage Status Latest Stable Version License

Introduction

A practical library for validation and normalization of data structures against a given schema with a smart & easy-to-understand API.

Documentation can be found on the website.

Installation:

composer require nette/schema

It requires PHP version 8.1 and supports PHP up to 8.4.

Do you like Nette Schema? Are you looking forward to the new features?

Buy me a coffee

Thank you!

Basic Usage

In variable $schema we have a validation schema (what exactly this means and how to create it we will say later) and in variable $data we have a data structure that we want to validate and normalize. This can be, for example, data sent by the user through an API, configuration file, etc.

The task is handled by the Nette\Schema\Processor class, which processes the input and either returns normalized data or throws an Nette\Schema\ValidationException exception on error.

$processor = new Nette\Schema\Processor;

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

Method $e->getMessages() returns array of all message strings and $e->getMessageObjects() return all messages as Nette\Schema\Message objects.

Defining Schema

And now let's create a schema. The class Nette\Schema\Expect is used to define it, we actually define expectations of what the data should look like. Let's say that the input data must be a structure (e.g. an array) containing elements processRefund of type bool and refundAmount of type int.

use Nette\Schema\Expect;

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

We believe that the schema definition looks clear, even if you see it for the very first time.

Lets send the following data for validation:

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

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

The output, i.e. the value $normalized, is the object stdClass. If we want the output to be an array, we add a cast to schema Expect::structure([...])->castTo('array').

All elements of the structure are optional and have a default value null. Example:

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

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

The fact that the default value is null does not mean that it would be accepted in the input data 'processRefund' => null. No, the input must be boolean, i.e. only true or false. We would have to explicitly allow null via Expect::bool()->nullable().

An item can be made mandatory using Expect::bool()->required(). We change the default value to false using Expect::bool()->default(false) or shortly using Expect::bool(false).

And what if we wanted to accept 1 and 0 besides booleans? Then we list the allowed values, which we will also normalize to boolean:

$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

Now you know the basics of how the schema is defined and how the individual elements of the structure behave. We will now show what all the other elements can be used in defining a schema.

Data Types: type()

All standard PHP data types can be listed in the schema:

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

And then all types supported by the Validators via Expect::type('scalar') or abbreviated Expect::scalar(). Also class or interface names are accepted, e.g. Expect::type('AddressEntity').

You can also use union notation:

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

The default value is always null except for array and list, where it is an empty array. (A list is an array indexed in ascending order of numeric keys from zero, that is, a non-associative array).

Array of Values: arrayOf() listOf()

The array is too general structure, it is more useful to specify exactly what elements it can contain. For example, an array whose elements can only be strings:

$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 is not a string

The second parameter can be used to specify keys (since version 1.2):

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

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

The list is an indexed array:

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

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 123]); // ERROR: 123 is not a string
$processor->process($schema, ['key' => 'a']); // ERROR: is not a list
$processor->process($schema, [1 => 'a', 0 => 'b']); // ERROR: is not a list

The parameter can also be a schema, so we can write:

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

The default value is an empty array. If you specify a default value and call mergeDefaults(), it will be merged with the passed data.

Enumeration: anyOf()

anyOf() is a set of values ​​or schemas that a value can be. Here's how to write an array of elements that can be either 'a', true, or null:

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

$processor->process($schema, ['a', true, null, 'a']); // OK
$processor->process($schema, ['a', false]); // ERROR: false does not belong there

The enumeration elements can also be schemas:

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

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

The anyOf() method accepts variants as individual parameters, not as array. To pass it an array of values, use the unpacking operator anyOf(...$variants).

The default value is null. Use the firstIsDefault() method to make the first element the default:

// default is 'hello'
Expect::anyOf(Expect::string('hello'), true, null)->firstIsDefault();

Structures

Structures are objects with defined keys. Each of these key => value pairs is referred to as a "property":

Structures accept arrays and objects and return objects stdClass (unless you change it with castTo('array'), etc.).

By default, all properties are optional and have a default value of null. You can define mandatory properties using required():

$schema = Expect::structure([
	'required' => Expect::string()->required(),
	'optional' => Expect::string(), // the default value is null
]);

$processor->process($schema, ['optional' => '']);
// ERROR: option 'required' is missing

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

If you do not want to output properties with only a default value, use skipDefaults():

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

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

Although null is the default value of the optional property, it is not allowed in the input data (the value must be a string). Properties accepting null are defined using nullable():

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

$processor->process($schema, ['optional' => null]);
// ERROR: 'optional' expects to be string, null given.

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

By default, there can be no extra items in the input data:

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

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

Which we can change with otherItems(). As a parameter, we will specify the schema for each extra element:

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

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

Deprecations

You can deprecate property using the deprecated([string $message]) method. Deprecation notices are returned by $processor->getWarnings():

$schema = Expect::structure([
	'old' => Expect::int()->deprecated('The item %path% is deprecated'),
]);

$processor->process($schema, ['old' => 1]); // OK
$processor->getWarnings(); // ["The item 'old' is deprecated"]

Ranges: min() max()

Use min() and max() to limit the number of elements for arrays:

// array, at least 10 items, maximum 20 items
Expect::array()->min(10)->max(20);

For strings, limit their length:

// string, at least 10 characters long, maximum 20 characters
Expect::string()->min(10)->max(20);

For numbers, limit their value:

// integer, between 10 and 20 inclusive
Expect::int()->min(10)->max(20);

Of course, it is possible to mention only min(), or only max():

// string, maximum 20 characters
Expect::string()->max(20);

Regular Expressions: pattern()

Using pattern(), you can specify a regular expression which the whole input string must match (i.e. as if it were wrapped in characters ^ a $):

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

Custom Assertions: assert()

You can add any other restrictions using assert(callable $fn).

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

$schema = Expect::arrayOf('string')
	->assert($countIsEven); // the count must be even

$processor->process($schema, ['a', 'b']); // OK
$processor->process($schema, ['a', 'b', 'c']); // ERROR: 3 is not even

Or

Expect::string()->assert('is_file'); // the file must exist

You can add your own description for each assertion. It will be part of the error message.

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

The method can be called repeatedly to add multiple constraints. It can be intermixed with calls to transform() and castTo().

Transformation: transform()

Successfully validated data can be modified using a custom function:

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

The method can be called repeatedly to add multiple transformations. It can be intermixed with calls to assert() and castTo(). The operations will be executed in the order in which they are declared:

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

The transform() method can both transform and validate the value simultaneously. This is often simpler and less redundant than chaining transform() and assert(). For this purpose, the function receives a Nette\Schema\Context object with an addError() method, which can be used to add information about validation issues:

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()

Successfully validated data can be cast:

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

In addition to native PHP types, you can also cast to classes. It distinguishes whether it is a simple class without a constructor or a class with a constructor. If the class has no constructor, an instance of it is created and all elements of the structure are written to its properties:

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

If the class has a constructor, the elements of the structure are passed as named parameters to the constructor:

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

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

Casting combined with a scalar parameter creates an object and passes the value as the sole parameter to the constructor:

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

Normalization: before()

Prior to the validation itself, the data can be normalized using the method before(). As an example, let's have an element that must be an array of strings (eg ['a', 'b', 'c']), but receives input in the form of a string a b c:

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

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

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

Mapping to Objects: from()

You can generate structure schema from the class. Example:

class Config
{
	public string $name;
	public ?string $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}

Anonymous classes are also supported:

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

Because the information obtained from the class definition may not be sufficient, you can add a custom schema for the elements with the second parameter:

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

schema's People

Contributors

dg avatar f3l1x avatar greeny avatar integer avatar jantvrdik avatar josefsabl avatar mabar avatar xificurk avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

schema's Issues

Warnings produced by PHP 8.3.2

Version: 1.2.5

Bug Description

PHP 8.3.2
produces below warnings
Warning: Private methods cannot be final as they are never overridden by other classes in /var/www/vendor/nette/schema/src/Schema/Helpers.php on line 19

Warning: Private methods cannot be final as they are never overridden by other classes in /var/www/vendor/nette/utils/src/Utils/Reflection.php on line 18

Warning: Private methods cannot be final as they are never overridden by other classes in /var/www/vendor/nette/utils/src/Utils/Validators.php on line 18

this is casued by @internal annotation in Schema/Helpers.php

Steps To Reproduce

Use the package with PHP 8.3.2

Expected Behavior

No warnings

Possible Solution

remove @internal annotation

Feature request: Unmark Context as internal

Class \Nette\Schema\Context is marked as @internal.

/**
* @internal
*/
final class Context

It causes that I cannot make custom validator object because the ->normalize() method requires Context instance in argument declared by \Nette\Schema\Schema interface.

PhpStorm is here firing inspection warning:

Class 'Context' is marked as @internal  
Inspection info: Reports the usages of the entities that are marked as @internal.
In most cases, such usages should be removed or replaced with other constructs.

In addition Context is not fully internal, because custom validators need it for for collecting validation errors. I didn't found possibility to do it with more clean way.

Example of use:
https://github.com/redbitcz/subreg-api-php/blob/60f377ac68f3c1871b926eca336f5eb8d3368455/src/Schema/DateTime.php#L44-L64

Suggest to feature

Unmark \Nette\Schema\Context as internal.

I understand the Context class has currently too open public interface which is potentially sensitive. Maybe move collecting errors to more specifics object which will not be @internal marked.

Default value on i.e. string is not working as expected.

Version: v1.2.3

Bug Description

When expecting a string with a default value set, the default value is only returned when key does not exist in data array. I would expect it would return default value "string()" when I have specified null in property "test" in data array. The property "test" is not nullable on purpose, because default value should be returned instead.

$config = new Configuration([
    'test' => Expect::string('hey')
]);

$config->merge([
    'test' => null,
]);

$value = $config->get('test');

The exception I get is

Uncaught Nette\Schema\ValidationException: The item 'test' expects to be string, null given.

Expected Behavior

I would expect the variable $value would be equal to "hey".

I think the problem is in the loop at master:src/Schema/Elements/Structure.php:175.

Range with nullable

Version: 1.0.2

'limit' => Expect::int(null)->min(1)->nullable(),

When limit is null, exception throwed "The option 'limit' expects to be int or null in range 1.., null given."

Dump of variable $expected:

int|null:1..

expected:

int:1..|null

then everything is fine

Possibility to validate callback signature

Hi,

time to time I would like to validate callback signature and/or its return value. In some scenarios you setup hook and such hook invocation may throw TypeError lately because of incompatible signature. Another case is that you have to check type of returned value from callback in runtime. Both of such cases could be validated in setup time for example by:

Expect::signature(fn(int $level): string => '');

Such "signature validation" can by done by PHP typesystem itself by interface:

interface LogLevelTranslator
{
    function __invoke(int $level): string;
}

and it is probably cleaner way but too verbose.

Would you consider to merge such functionality? Or maybe into Nette\Utils\Validators?

Schema from PHPSTAN array shape

We have an API that maps the URL to PHP method and the POST JSON data to method arguments, for example curl -XPOST https://mysite/api/device/add -d'{"id": 1, "data": [1,2,3]}' would call

class Device 
{
    /** @param array<int> $data */
    function add(int $id, array $data)
}

Thanks to PHP strict typing, it checks the type of basic types (int, string, booo) but it does not check content of arrays. We are already using PHPStan to pass static analysis tests and it would be cool to enforce the already existing rules via Nette\Schema.

Using reflection, I can get the phpdoc but there is no way (I think) that I could validate it using Nette/Schema. To accomplish that, a new method fromPhpstanArrayShape(string $shape) (with a much better name) would have to be created.

// this is just a sample with simple array shape but we sometimes use more coplicated ones as well
$schema = Expect::fromPhpstanArrayShape('array<int>'); // = Expect::arrayOf('int')

$data = [1, 'one', 2];

// this would throw an exception since the second element is not integer
$normalized = $processor->process($schema, $data); 

Do you think this is something that could be useful?

Definition generated from Schema

What about some kind of Definition generation direct from Schema? It can be useful for implement some business logic depend on defined schema. Maybe something like this:

Basic type:

$schema = Expect::string()->required()->nullable();
$definition = $schema->getDefinition();
$definition->getType();
$definition->isRequired();
$definition->isNullable();

Array:

$schema = Expect::structure([
    'name' => Expect::string()->required(),
    'surname' => Expect::string()
]);
$definition = $schema->getDefinition();
foreach ($definition->getItems() as $item) {
    $item->isRequired();
    ...
}

Example usage:

// Some kind of input processing in presenter
$input = (new UberUserInput('id', $_GET))->setSchema(Expect::integer()->required());

try {
    $input->validate();
} catch (...) { ... }
// Generating form for user input
$form = new Form();
$form->addText($input->getName());
if ($input->getSchemaDefinition()->isRequired()) {
    $form[$input->getName()]->setRequired();
}

Type error with Helpers::getCastStrategy and DTO with constructor sice v1.2.5

Version: v1.2.5

Bug Description

Since the v1.2.5 my code throws type error, because Helpers::getCastStrategy expects array or single value, but gets stdClass when using data object with constructor.

testArrayOfUsers
   TypeError: RBCB\Schema\User::__construct(): Argument #1 ($xxxx) must be of type ?string, stdClass given, called in 
   vendor/nette/schema/src/Schema/Helpers.php on line 186

Steps To Reproduce

$json = Json::decode($data);
foreach ($json->data as $user) {
       $schema = Expect::from(new User());
       $user = $processor->process($schema, $user);
       yield $user;
}

And the DTO

class User
{
    public function __construct(
        public ?string $yyyy = null,
        public ?string $xxxx = null,
        public ?string $zzz= null,
     .... 
}

Expected Behavior

Im unsure if I need to cast it manually, so let me know if I use the schema correctly or not.

Possible Solution

Editing the method_exists($type, '__construct') for supporting stdClass.

Allow options to be marked as deprecated

Problem

I would like the ability to mark certain options as being 'deprecated'. These are elements that are still currently allowed but may be removed in future versions. Using a deprecated option should cause a silenced E_USER_DEPRECATED error to triggered when used.

Proposed implementation

Add a new method called deprecated() to the Base trait - something like this:

/**
 * Marks this option as deprecated
 *
 * @param ?string $message Optional deprecation message (any '%s' in the string will be replaced with the option path)
 *
 * @return $this
 */
public function deprecated(?string $message = null): self
{
    $this->deprecated = $message ?? "Option '%s' is deprecated";
    return $this;
}

Users can then flag options as deprecated like so:

 $schema = Expect::structure([
-    'foo' => Expect::string(),
+    'foo' => Expect::string()->deprecated('"%s" was deprecated in v2.1 and will be removed in v3.0; use "bar" instead'),
+    'bar' => Expect::string(),
 ]);

At some point during the complete() method call we'd raise a silenced deprecation error if any value was provided:

// TODO: Replace $valueWasProvided with the actual logic needed
if ($valueWasProvided && $this->deprecated !== null) {
    $s = implode("', '", array_map(function ($key) use ($context) {
        return implode('Β β€ΊΒ ', array_merge($context->path, [$key]));
    }, $hint ? [$extraKeys[0]] : $extraKeys));

   @trigger_error(sprintf($this->deprecated, $s), E_USER_DEPRECATED);
}

Why a silenced error?

The @trigger_error('...', E_USER_DEPRECATED) pattern is borrowed from Symfony and other projects:

Without the @-silencing operator, users would need to opt-out from deprecation notices. Silencing swaps this behavior and allows users to opt-in when they are ready to cope with them (by adding a custom error handler like the one used by the Web Debug Toolbar or by the PHPUnit bridge).

See https://symfony.com/doc/4.4/contributing/code/conventions.html#deprecating-code for more details.

Outstanding questions

  • Should an exception be thrown if a deprecated option lacks a default or is marked as required? I don't think so, but I'm not sure.
  • Should there be an alternate way to detect deprecated options? Symfony's approach works really well but not everyone uses custom error handlers that would detect these. I don't personally need an alternate method but perhaps we could record them in the Context too if that's desired?

Am I willing to implement this?

Yes - but I could use a little guidance on the best place to put the deprecation check and how to properly determine if a value is given or omitted.

Possibility to use Schema to create an array/object of Schema's default values.

Intention

My intention is to use the Schema both to normalize the data and to obtain default values. In the second case, there is no need to input any data, just obtain an array of default values, if there are any.

My use case

I have several Personas, each of which can have very different parameters (saved as JSON in database).

The structure of these parameters is defined by the Persona's Schema.

I use the Persona's Schema to normalize these parameters entered by user's input.

To create a new Persona, however, I need to generate default values for its parameters from its Schema, so that I do not have to define them separately, because – why not use the existing Schema for this?

Example of my Schema for one of the Persona's:

Expect::structure([
	'nickname' => Expect::string()->default('Maniac'),
	'skills' => Expect::anyOf('low', 'medium', 'high')->default('low')->required(true)
]);

I need to use this Schema for normalization, which works great, but also to get the default values that are defined there. I need to get this result from this Schema:

[
   'nickname' => 'Maniac',
   'skills' => 'low'
]

When creating a new Persona, I encode this into JSON and save into database as default parameters of the Persona.

My idea is to have something like:

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

// default values
$defaults = $processor->getDefaults($schema);
// or
$defaults = $processor->process($schema);  // that is, no input data are specified, so it returns default values instead of normalized data

Assert with custom message.

When you have more asserts for one value it is difficult to find which assert failed.

Same issue if you have more complex logic in your assert. For example you asserts relations between two fields of structure. In this case you get error like this
Failed assertion #0 for option 'dataΒ β€ΊΒ itemsΒ β€ΊΒ 0' with value object. which is not so useful.

What do you think about this feature. May I prepare PR?

Programmatically access path of field(s) with error(s)

I believe it is not so uncommon to use such library when validating API parameters which is inherently connected to reporting errors back to the caller.

In such situation it is not uncommon to provide path* within the request payload and nette/schema does that in way that is not directly usable to be passed back to the response as it is available only (as far as I investigated) to the exception message.

So it would be nice to be able to access the path programmatically.

  • Error response in our company is a array of errors with each having path, error message and error code.

Optional structure with required fields

Let's say I validate the data structure of a blog post. This blog post has a body and an author. And as we support anonymous comments, the user is NOT mandatory. The author is a structure as well and if it IS PROVIDED it MUST have an e-mail.

I have a schema like this:

- comment: structure
  - body: string (required)
  - author: structure (not required)
    - email: string (required)
    - name: string (not required)

At the moment I am unable to validate the data with Nette/Schema like that because if I mark the email as required I still get a validation exception even if the author (not required) is not provided at all.

My validation schema code is like:

Expect::structure([
    'body' => Expect::string()->required(),
    'author' => Expect::structure([
        'email' => Expect::string()->required(),
        'name' => Expect::string(),
    ]),
]);

And for data like this:

- body: Hello World!

I get The mandatory option 'author > email' is missing.. I get the same result even if I explicitly set the structure as not required like this: 'author' => Expect::structure([ ... ])->setRequired(false).

This example is obviously all made up to make things clear but I've hit this issue three times in different places. One of them being writing MongoDB extension for DI and trying to validate the driverOptions (see the context, structure inside structure, but context not mandatory)

Is this a bug? Is it a feature? Or am I missing something?

We are obviously able to work around this by explicitly checking the values in all sorts of places but it is a shame we have to pollute our codebase, especially when we streamlined it pretty much by implementing Nette\Schema in the first place :-)

`Expect::anyOf` should not disable `before()`

Version: v1.0.2

Bug Description

before() helper is not called on things nested in Expect::anyOf.

Steps To Reproduce

test(function() { // normalization through anyOf
	$schema = Expect::anyOf(Expect::string()->before(function($v) {
		return (string) $v;
	}));
	Assert::same('1',  $schema->normalize(1, new Context));
});

Expected Behavior

before() should work consistently inside anyOf.

String expectation with min(0) and a nullable flag does not accept null

Version: 1.0.2

Bug Description

String expectation with min(0) and a nullable flag does not accept null.

Steps To Reproduce

Expectation is defined as

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

When validating with ['position' => null], the error message raised is

"The option 'position' expects to be string or null in range 0.., null given."

Expected Behavior

The null value should be accepted according to the error message.

If failing for other reasons, null should be mentioned first in the error message for it to make more sense (only strings have "ranges")

Possible Solution

Nullable does not take precedence over string length constraint even when minimal string length is 0

Add DateTime and DateTimeRange expectations

It would be handy to provide expectations for datetime too, typically validation & parsing in given format. Apart from format typically we use min and max validations as well as cross validations for intervals between two or more provided values.

Allow recursive schema validation in tree structure

I'm trying to solve recursive schema validation in menu library when you have tree structure of menu (submenu items).

Here's what I did as temporary solution:

final class MenuExtension extends CompilerExtension
{

	public function getConfigSchema(): Schema
	{
		return Expect::arrayOf(Expect::structure([
			'loader' => Expect::string(DefaultMenuLoader::class),
			'items' => Expect::array()->required(),
		]));
	}

	public function getItemSchema(): Schema
	{
		return Expect::structure([
			'title' => Expect::string(),
			'link' => Expect::string(),
			'items' => Expect::array(),
		]);
	}

	public function loadConfiguration(): void
	{
		$config = $this->getConfig();
		$builder = $this->getContainerBuilder();
		$processor = new Processor;

		foreach ($config as $menuName => $menu) {
			$container->addSetup('addMenu', [
				$this->loadMenuConfiguration($builder, $processor, $menuName, $menu),
			]);
		}
	}

	private function loadMenuConfiguration(
		ContainerBuilder $builder,
		Processor $processor,
		string $menuName,
		stdClass $config
	): ServiceDefinition {
		$loader = $config->loader;

		if (!Strings::startsWith($config->loader, '@')) {
			$loader = $builder->addDefinition($this->prefix('menu.'. $menuName. '.loader'))
				->setType($config->loader)
				->setAutowired(false);
		}

		if ($loader->getType() === DefaultMenuLoader::class) {
			$loader->setArguments([$this->normalizeMenuItems($processor, $config->items)]);
		}
	}

	private function normalizeMenuItems(Processor $processor, array $items): array
	{
		array_walk($items, function(array &$item, string $key) use ($processor): void {
			$item = $processor->process($this->getItemSchema(), $item);

			if ($item->title === null) {
				$item->title = $key;
			}

			$item->items = $this->normalizeMenuItems($processor, $item->items);
		});

		return $items;
	}

}

Full file is here

  • It would be nice if I could use something like Expect::schemaFactory([$this, 'getItemSchema']) so function getItemSchema() could be called recursively as long as submenu's are present.
  • It would be nice if I could get Processor in CompilerExtension so I don't need to create one when I have custom config.

min/max/assert not applyed on default value

Hi,

It seems the controles like min/max or assert() are not appplyed on the default value.
This could be problematic if the default value is dynamic.

In my use case i should controle the application key is a hexa 64 chars, and by default we use the environement variable APP_KEY.

I also tryed to move the min/max/assert function after the chained ->default(), but the result is the same :(

return Expect::structure([ 'key' => Expect::xdigit()->min(64)->max(64)->default(env('APP_KEY')), ]);

Is that a bug or an "expected" behaviour ?

Keep up the good work.

Access the $items property in the Structure class

Hi,

Nice piece of code. Do you think there is a way to access the $items property in the Structure::class ?
I use this value in 2 cases :

  1. Force the castTo('array') for all the structure and sub-structures.
  2. I need to enforce the key value used to be only in the range ['a-z0-9_'].

For the moment i do a reflection and i access the private property, but a public function getItems() could be usefull. Or perhaps an "iterator" inside the structure class.

What do you think about this feature, this could add more flexibility using this great library.

Keep up the good work.

Validator from class

The Expect::from is nice, but it does not really work for immutable objects like the following:

<?php

use Money\Money;

final class InvoiceItem {
	public function __construct(
		public readonly string $name,
		public readonly Money $price,
		public readonly int $amount,
	) {
	}
}
Version without readonly
<?php

use Money\Money;

final class InvoiceItem {
	public function __construct(
		private string $name,
		private Money $price,
		private int $amount,
	) {
	}

	public function getName(): string {
		return $this->name;
	}

	public function getPrice(): Money {
		return $this->price;
	}

	public function getAmount(): int {
		return $this->amount;
	}
}

It would be nice to have Expect::fromClass(InvoiceItem::class), that would check the constructor arguments instead of the public properties like Expect::from does.

I can try to implement this, if you think this is a good idea.

Custom error message mapping

The error messages created by the library are quite nice (they contain path, expected type and actual values). However in usability in real world APIs (at least at our company) we need programatically to map these error messages to either our error codes (numbers or strings) or custom error messages for example to provide more details, links or just keep consistent with our documentation language.

arrayOf, listOf defaults are always in array

Version: 1.0.0

$schema = Expect::listOf('string')->default([
	'foo',
	'bar',
]);

$processor = new Processor();

$array = $processor->process($schema, [
	'foo',
	'bar'
]);

var_dump($array);

dumps:

array(4) {
  [0]=>
  string(3) "foo"
  [1]=>
  string(3) "bar"
  [2]=>
  string(3) "foo"
  [3]=>
  string(3) "bar"
}
$schema = Expect::arrayOf('string')->default([
	'foo',
	'bar',
]);

$processor = new Processor();

$array = $processor->process($schema, [
	'foo',
	'bar'
]);

var_dump($array);

dumps:

array(4) {
  [0]=>
  string(3) "foo"
  [1]=>
  string(3) "bar"
  [2]=>
  string(3) "foo"
  [3]=>
  string(3) "bar"
}

Is it the right behavior?

Merging default values doesn't work on non associative array.

Hi,

I encounter a bug with the merge function when the schema use a list of string.
Here is an example :

`
$schema = Expect::structure([
'paths' => Expect::arrayOf('string')->default(['path_1']),
]);

$data = ['paths' => ['path_1']];
`
after the processSchema() i got the following result :

['path_1', 'path_1']
but i expect the values to be merged, and only have ['path_1'] as a result. This wrong behavior is when the array has numeric keys. If a use an associative array (ex : ['default' => 'path_1']) the merge work fine and i got only on element for the result.

Keep up the good work, this library is amazing :)

Adding new PHP Versions as experimental

Hi,
i tried to start an early php 8.4 adoption but since i do not know where the tail will finally end, i stopped it.
The problem is that several nette packages are bound to a max php version.

To make changes independently i would suggest to add "experimental" support to the nette packages, this would it make easier to fix upcoming issues with php 7.4 or further php versions.

If something like in: https://github.com/ItsReddi/schema/pull/1/files (tests.yaml) would be available in all nette packages it is maybe easier for the community to fix upcoming issues.

Make processor class non-final so it can easily be mocked in unit tests

The Processor class is marked as final and this makes it difficult to mock in unit tests. In a unit test, I want to hard-code the result of the processing with fixture data as I don't want to test nette/schema, I want to test my code. This is typically done with a mocking library like Mockery, but because the class is final this is not easy to do cleanly.

<?php
class ContentParserTest extends TestCase
{
    #[Test]
    public function parsesNestedContentFields(): void
    {
        $factory = Mockery::mock(Factory::class);
        $processor = Mockery::mock(Processor::class);

        $parser = new ContentParser($processor, $factory);
        
        ... other test stuff
    }
}

This results in:

Mockery\Exception: The class \Nette\Schema\Processor is marked final and its methods cannot be replaced. Classes marked final can be passed in to \Mockery::mock() as instantiated objects to create a partial mock, but only if the mock is not subject to type hinting checks.

Alternatively, consider making an interface for the Processor, leave the Processor class as final and implement the interface. That way unit tests and methods can use the interface and you can still prevent subclassing.

Support custom cast function

It would like to be able to cast a value using a callable.

class Foo {
  public static function fromString($bar) {
    // some logic
  }
}

$schema = Expect::structure([
  'foo' => Expect::string()->castTo(fn ($v) => Foo::fromString($v));
  'foo2' => Expect::string()->castTo('Foo::fromString');
});

I'm willing to make a PR for this if you accept the feature to be integrated in the project.

Invalid documentation link

Version: master

Bug Description

In readme.md is this sentence Documentation can be found on the website.. "website" links to https://doc.nette.org/schema. But this page returns 404.

Tuple validation

I know a tuple isn't a PHP structure, but you can still format and validate your data like it is. For instance, I want to validate a list of tuples:

[
  [date, date, [ints], int],
  [date, date, [ints], int],
  [date, date, [ints], int],
  // etc, 20 or 100 more lines, so I don't want assoc arrays (Expect::structure())
]

The outside would be a listOf(), but a list of what? Tuples. Numeric arrays with a set amount of items that have specific types: elements 0 and 1 are dates, element 2 is a listOf(int()), element 3 is an int().

Maybe something like

$schema = Expect::listOf(Expect::tuple(
  Custom::date(),
  Custom::date(),
  Expect::listOf(Expect::int()),
  Expect::int(),
));

Add extend() method to Structure class

An extend() method would be nice for Structure so you could define a base schema and then define variants with additional fields. Right now you can kind of do this like:

$baseItems = [
  'foo' => Expect::string(),
  'bar' => Expect::string(),
];

$base = Expect::structure($baseItems);
$variant = Expect::structure([
  ...$baseItems,
  'baz' => Expect::string()
];

This has a downside in that you need to expose the array and pass around the array rather than passing around Structure objects. When composing schemas it would be really nice to have the following API:

$base = Expect::structure([
  'foo' => Expect::string(),
  'bar' => Expect::string(),
]);

// make a new structure with additional or overridden fields
$variant = $base->extend([
  'baz' => Expect::string(),
]);

This would be similar to a feature present in Zod https://zod.dev/?id=extend

False-positive security bug because of non-standard LICENSE.md file

Version: 1.2.1

Bug Description

Since you're not using a standard LICENSE.md format it makes things like Enlightn fail and say my project is using packages that I'm not legally allowed to use. Fix your license.

Steps To Reproduce

  1. laravel new app --jet (With --jet since it's Laravel Jetstream requiring this package in my case)
  2. composer require enlightn/enlightn
  3. php artisan englightn Enlightn will fail saying your package is illegal to use.

Expected Behavior

Use of a standard LICENSE.md format that doesn't make security checks fail.

Possible Solution

Use a standard LICENSE.md format...

String to DateTime returns \Nette\Utils\Arrays error

Bug Description

I am not sure, if I am using this right, but I am trying to convert input string to result DateTime object

Steps To Reproduce

https://fiddle.nette.org/nette/#4f2cd78abf

class Foo {
    public \DateTime $bar;
}

$processor = new \Nette\Schema\Processor;
$processor->process(
    \Nette\Schema\Expect::structure([
        'bar' => \Nette\Schema\Expect::string()->castTo('DateTime')
    ])->castTo(Foo::class),
    [
        'bar' => '2021-01-01',
    ]
);

Will result in the error:

Nette\Utils\Arrays::toObject(): Argument #1 ($array) must be of type iterable, string given, called in ...../vendor/nette/schema/src/Schema/Elements/Base.php on line 174

Expected Behavior

Input string will be successfully mapped to target class DateTime property.

Expect::structure() and normalization

Version: 1.0.0

Bug Description

We encountered a strange behavior when using normalization with Expect::structure(). It seemed that when specifying a before() method on a property inside the structure and passing an object to the Nette\Schema\Processor::process() method, it would not get normalized.

I managed to track it down to the Nette\Schema\Elements\Structure::normalize() method, specifically the is_array method in the condition, which was indeed the case:

public function normalize($value, Context $context)
{
    $value = $this->doNormalize($value, $context);
    if (is_array($value)) {
        foreach ($value as $key => $val) {
            $itemSchema = $this->items[$key] ?? $this->otherItems;
            if ($itemSchema) {
                $context->path[] = $key;
                $value[$key] = $itemSchema->normalize($val, $context);
                array_pop($context->path);
            }
        }
    }
    return $value;
}

Steps To Reproduce

This code snippet reproduces the problem:

use Nette\Schema\Expect;
use Nette\Schema\Processor;
use Nette\Utils\ArrayHash;

// The example from https://doc.nette.org/en/3.0/schema#toc-custom-normalization, just wrapped it in a structure
$schema = Expect::structure([
    'data' => Expect::arrayOf('string')->before(function ($v) { return explode(' ', $v); })
]);

$values = ['data' => 'a b c'];
$processor = new Processor();

// Simple array
// stdClass { data => [ 'a', 'b', 'c' ] }
$arrayResult = $processor->process($schema, $values);

// Non-iterable class
// Nette\Schema\ValidationException: The option 'data' expects to be array, string 'a b c' given
$objectResult = $processor->process($schema, (object) $values);

// An ArrayHash, or any class that implements \Traversable
// Nette\Schema\ValidationException: The option 'data' expects to be array, string 'a b c' given
$traversableResult = $processor->process($schema, ArrayHash::from($values));

Expected Behavior

If the structure is an object, it's properties get properly normalized.

Possible Solution

I tried my best with rewriting the Nette\Schema\Elements\Structure::normalize() method to support objects, and I came up with this:

public function normalize($value, Context $context)
{
    $value = $this->doNormalize($value, $context);
    if (is_array($value) || is_object($value)) {
        // When non-iterable object is received, iterate through its public properties
        $properties = is_iterable($value) ? $value : get_object_vars($value);

        foreach ($properties as $key => $val) {
            $itemSchema = $this->items[$key] ?? $this->otherItems;
            if ($itemSchema) {
                $context->path[] = $key;

                if (is_object($value)) {
                    $value->{$key} = $itemSchema->normalize($val, $context);
                } else {
                    $value[$key] = $itemSchema->normalize($val, $context);
                }

                array_pop($context->path);
            }
        }
    }
    return $value;
}

With this modification, all of the examples I showed in Steps to Reproduce section work as expected:

use Nette\Schema\Expect;
use Nette\Schema\Processor;
use Nette\Utils\ArrayHash;

$schema = Expect::structure([
    'data' => Expect::arrayOf('string')->before(function ($v) { return explode(' ', $v); })
]);

$values = ['data' => 'a b c'];
$processor = new Processor();

// Simple array
$arrayResult = $processor->process($schema, $values);

// Non-iterable class
$objectResult = $processor->process($schema, (object) $values);

// An ArrayHash, or any class that implements \Traversable
$traversableResult = $processor->process($schema, ArrayHash::from($values));


dump($arrayResult);          // stdClass { data => [ 'a', 'b', 'c' ] }
dump($objectResult);         // stdClass { data => [ 'a', 'b', 'c' ] }
dump($traversableResult);    // stdClass { data => [ 'a', 'b', 'c' ] }

Edit: I created a PR in case the solution would be acceptable.

Required with parameter to set to true or false.

It would be useful if required() method had fingerprint like this:

public function required(bool $required = true): self

Imagine you want to generate the schema programmatically either to require all options or to allow for only partial result. Imagine some create operation versus update operation where either I have to provide all fields or only ones I want to edit.

public static function getSchema(bool $partialAllowed) {
        return Expect::structure(
            [
                'name' => Expect::string()->required(!$partialAllowed),
            ]
        );
}

I am implementing restfull api, particularly post, put and patch methods and this would hugely declutter my code.

I will send PR shortly.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    πŸ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. πŸ“ŠπŸ“ˆπŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❀️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.