From 03bc384812d5a341cdad2fbed1344bd25f9a5e32 Mon Sep 17 00:00:00 2001 From: Juan M Date: Sat, 17 Nov 2018 14:42:32 +0100 Subject: [PATCH 1/6] WIP --- .coveralls.yml | 2 + .travis.yml | 16 +- CHANGES.md | 55 +- TODO.md | 12 +- composer.json | 26 +- kahlan-config.php | 4 + library/assert.php | 764 ++++++++++++++++++++++ library/exceptions.php | 164 +++++ library/filter.php | 214 ++++++ library/filter_intl.php | 77 +++ library/functions.php | 131 ++++ library/util.php | 33 + plan.php | 1290 ------------------------------------- psalm.xml | 42 ++ spec/Suite/assertSpec.php | 343 ++++++++++ 15 files changed, 1836 insertions(+), 1337 deletions(-) create mode 100644 .coveralls.yml create mode 100644 kahlan-config.php create mode 100644 library/assert.php create mode 100644 library/exceptions.php create mode 100644 library/filter.php create mode 100644 library/filter_intl.php create mode 100644 library/functions.php create mode 100644 library/util.php delete mode 100644 plan.php create mode 100644 psalm.xml create mode 100644 spec/Suite/assertSpec.php diff --git a/.coveralls.yml b/.coveralls.yml new file mode 100644 index 0000000..90ae313 --- /dev/null +++ b/.coveralls.yml @@ -0,0 +1,2 @@ +service_name: travis-ci +coverage_clover: build/logs/clover.xml diff --git a/.travis.yml b/.travis.yml index 7134a97..71e94f6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,15 @@ language: php php: - - 5.6 - - 7.0 + - 7.1 -install: - - composer self-update - - composer install +before_script: + - composer install --no-interaction --prefer-dist + - composer require --no-interaction satooshi/php-coveralls + - mkdir -p build/logs/ + +script: + - php vendor/bin/kahlan --cc=true --coverage=4 --clover=build/logs/clover.xml + +after_success: + - php vendor/bin/coveralls -v diff --git a/CHANGES.md b/CHANGES.md index c91b5d0..eeee5e8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,40 +1,47 @@ ### Last Version - * Add assert\datetime +### 3.0.0 (2017-07-XX) + + * Require PHP 7.1. + * Add `assert\datetime`. + * Add `validate` function. + * Re-arrange into `library/` directory. + * Remove `Schema`. + * Rename `Schema::compile` into `compile` function. ### 2.1.0 (2017-04-03) - * Better error messages in assert\file - * Add filter\datetime + * Better error messages in `assert\file`. + * Add `filter\datetime`. ### 2.0.0 (2016-12-03) - * Remove assert\regexp - * Now Invalid accepts template and params - * PHP >=5.6 required + * Remove `assert\regexp`. + * Now `Invalid` accepts template and params. + * PHP >=5.6 required. ### 1.3.0 (2016-10-08) - * Add InvalidList::getMessages - * Add assert\file as an alias of dict + * Add `InvalidList::getMessages`. + * Add `assert\file` as an alias of `assert\dict`. ### 1.2.0 (2016-10-26) - * Mark assert\regexp as deprecated - * Add filter\intl\alpha y filter\intl\alnum - * Add assert\dictkeys + * Mark `assert\regexp` as deprecated. + * Add `filter\intl\alpha` y `filter\intl\alnum`. + * Add `assert\dictkeys`. ### 1.1.1 (2016-09-19) - * Add assert\match as wrapper around preg_match - * Add filter\intl namespace - * Add filter\intl\chars + * Add `assert\match` as wrapper around `preg_match`. + * Add `filter\intl` namespace. + * Add `filter\intl\chars`. ### 1.1.0 (2016-09-13) - * Fix bug with calling assert\object with a non object and ask to be cloned - * Fix $path in schemas called by assert\all - * Parameter extra for assert\dict accepts a schema dict + * Fix bug with calling `assert\object` with a non object and ask to be cloned. + * Fix `$path` in schemas called by `assert\all`. + * Parameter extra for `assert\dict` accepts a schema dict. ### 1.0.1 (2014-10-19) @@ -42,16 +49,16 @@ ### 1.0.0 (2014-09-29) - * New assert\iif for simple conditionals - * Finish a basic assert\object + * New `assert\iif` for simple conditionals. + * Finish a basic `assert\object`. ### 1.0.0-RC2 (2014-09-01) - * Add base to filter\intval - * Make filter\boolval php <5.5 compatible - * Make Invalid compatible with \Exception - * New filter\sanitize and filter\vars - * Parameter extra for assert\dict accept an array + * Add base to `filter\intval`. + * Make `filter\boolval` php <5.5 compatible. + * Make `Invalid` compatible with `Exception`. + * New `filter\sanitize` and `filter\vars`. + * Parameter extra for `assert\dict` accept an array. ### 1.0.0-RC1 (2014-08-07) diff --git a/TODO.md b/TODO.md index 654f151..97ed650 100644 --- a/TODO.md +++ b/TODO.md @@ -3,12 +3,12 @@ TODO - [ ] `assert\required`/`assert\extra` to use in `assert\dict`. - ```php - $type = assert\dict(array( - 'name' => assert\all(assert\required(), 'John'), - 'name' => assert\required('John'), - )); - ``` + ```php + $type = assert\dict(array( + 'name' => assert\all(assert\required(), 'John'), + 'name' => assert\required('John'), + )); + ``` - [ ] `asset\one` like `assert\any` but strict only one; or add a `$count` parameter to `assert\any` to count the exact times that a validator was diff --git a/composer.json b/composer.json index 5ddb36a..60bb517 100644 --- a/composer.json +++ b/composer.json @@ -1,24 +1,26 @@ { "name": "guide42/plan", "type": "library", - "description": "Fast and Simple Validation", + "description": "Fast and simple validation library", "keywords": ["validator", "validation", "filter", "security"], "license": "ISC", "authors": [ - { - "name": "Juan M Martínez", - "email": "plan@jm.guide42.com", - "homepage": "http://jm.guide42.com", - "role": "Developer" - } + {"name": "Juan M", "email": "jm@guide42.com"} ], + "autoload": { + "files": [ + "library/functions.php", + "library/exceptions.php", + "library/assert.php", + "library/filter.php", + "library/filter_intl.php", + "library/util.php" + ] + }, "require": { - "php": ">=5.6" + "php": "~7.1" }, "require-dev": { - "phpunit/phpunit": "~5.7" - }, - "autoload": { - "classmap": ["plan.php"] + "kahlan/kahlan": "@stable" } } \ No newline at end of file diff --git a/kahlan-config.php b/kahlan-config.php new file mode 100644 index 0000000..5154017 --- /dev/null +++ b/kahlan-config.php @@ -0,0 +1,4 @@ +commandLine(); +$args->option('src', 'default', 'library/'); diff --git a/library/assert.php b/library/assert.php new file mode 100644 index 0000000..fc81aa9 --- /dev/null +++ b/library/assert.php @@ -0,0 +1,764 @@ + json_encode($data), + '{type}' => $type, + ); + + throw new Invalid($tpl, $var, null, null, $path); + } + + return $data; + }; +} + +/** + * Alias of `plan\assert\type('boolean')`. + */ +function bool() +{ + return assert\type('boolean'); +} + +/** + * Alias of `plan\assert\type('integer')`. + */ +function int() +{ + return assert\type('integer'); +} + +/** + * Alias of `plan\assert\type('double')`. + */ +function float() +{ + return assert\type('double'); +} + +/** + * Alias of `plan\assert\type('string')`. + */ +function str() +{ + return assert\type('string'); +} + +/** + * Wrapper for `is_scalar`. + * + * @throws Invalid + * @return Closure + */ +function scalar() +{ + return function($data, $path = null) + { + if (!is_scalar($data)) { + $tpl = '{type} is not scalar'; + $var = array( + '{type}' => gettype($data), + '{data}' => json_encode($data), + ); + + throw new Invalid($tpl, $var, null, null, $path); + } + + return $data; + }; +} + +/** + * Wrapper for `instanceof` type operator. + * + * @param string|object $class right operator of `instanceof` + * + * @throws Invalid + * @return Closure + */ +function instance($class) +{ + return function($data, $path = null) use($class) + { + if (!$data instanceof $class) { + $tpl = 'Expected {class} (is {data_class})'; + $var = array( + '{class}' => $class, + '{data_class}' => is_object($data) ? get_class($data) + : 'not an object', + ); + + throw new Invalid($tpl, $var, null, null, $path); + } + + return $data; + }; +} + +/** + * Compare $data with $literal using the identity operator. + * + * @param mixed $literal something to compare to + * + * @throws Invalid + * @return Closure + */ +function literal($literal) +{ + $type = assert\type(gettype($literal)); + + return function($data, $path = null) use($type, $literal) + { + $data = $type($data, $path); + + if ($data !== $literal) { + $tpl = '{data} is not {literal}'; + $var = array( + '{data}' => json_encode($data), + '{literal}' => json_encode($literal), + ); + + throw new Invalid($tpl, $var, null, null, $path); + } + + return $data; + }; +} + +/** + * The given schema has to be a list of possible valid values to validate from. + * If empty, will accept any value. + * + * @param array $values list of values + * + * @throws Invalid + * @return Closure + */ +function seq(array $values) +{ + $schemas = array(); + + for ($s = 0, $sl = count($values); $s < $sl; $s++) { + $schemas[] = compile($values[$s]); + } + + $type = assert\type('array'); + + return function($data, $path = null) use($type, $schemas, $sl) + { + $data = $type($data, $path); + + // Empty sequence schema allows any data, no validation done + if (empty($schemas)) { + return $data; + } + + $return = array(); + $root = $path === null ? [] : $path; + $dl = count($data); + + for ($d = 0; $d < $dl; $d++) { + $found = null; + + $path = $root; + $path[] = $d; + + for ($s = 0; $s < $sl; $s++) { + try { + $return[] = $schemas[$s]($data[$d], $path); + $found = true; + break; + } catch (Invalid $e) { + $found = false; + if (count($e->getPath()) > count($path)) { + throw $e; + } + } + } + + if ($found !== true) { + $tpl = 'Invalid value at index {index} (value is {value})'; + $var = array( + '{index}' => $d, + '{value}' => json_encode($data[$d]), + ); + + throw new Invalid($tpl, $var, null, null, $path); + } + } + + return $return; + }; +} + +/** + * Validate the structure of the data. + * + * @param array $structure key/validator array + * @param boolean|array $required if require all keys to be present + * @param boolean|array $extra if accept extra keys + * + * @throws Invalid + * @throws InvalidList + * @return Closure + */ +function dict(array $structure, $required = false, $extra = false) +{ + $compiled = array(); + $reqkeys = array(); + + foreach ($structure as $key => $value) { + $compiled[$key] = compile($value); + } + + if ($required === true) { + $reqkeys = array_keys($compiled); + } elseif (is_array($required)) { + $reqkeys = array_values($required); + } else { + $reqkeys = array(); + } + + if (is_array($extra)) { + if (util\is_sequence($extra)) { + $cextra = array_flip(array_values($extra)); + } else { + $cextra = array(); + foreach ($extra as $dextra => $vextra) { + $cextra[$dextra] = compile($vextra); + } + } + } else { + $cextra = $extra === true ?: array(); + } + + $type = assert\any( + assert\type('array'), + assert\instance(Traversable::class) + ); + + return function($data, $path = null) use($type, $compiled, $reqkeys, $cextra) + { + $data = $type($data, $path); + $root = $path === null ? [] : $path; + + $return = array(); + $errors = array(); + + foreach ($data as $dkey => $dvalue) { + $path = $root; + $path[] = $dkey; + + if (array_key_exists($dkey, $compiled)) { + try { + $return[$dkey] = $compiled[$dkey]($dvalue, $path); + } catch (Invalid $e) { + if (count($e->getPath()) > count($path)) { + // Always grab deepest exception + // It will contain the path through here + $errors[] = $e; + continue; + } + + $tpl = 'Invalid value at key {key} (value is {value})'; + $var = array( + '{key}' => $dkey, + '{value}' => json_encode($dvalue) + ); + + $errors[] = new Invalid($tpl, $var, null, $e, $path); + } + } elseif (in_array($dkey, $reqkeys)) { + $return[$dkey] = $dvalue; // no validation done + } elseif ($cextra === true || array_key_exists($dkey, $cextra)) { + if (is_callable($cextra[$dkey])) { + try { + $return[$dkey] = $cextra[$dkey]($dvalue, $path); + } catch (Invalid $e) { + $tpl = 'Extra key {key} is not valid'; + $var = array('{key}' => $dkey); + + $errors[] = new Invalid($tpl, $var, null, $e, $path); + } + } else { + $return[$dkey] = $dvalue; + } + } else { + $tpl = 'Extra key {key} not allowed'; + $var = array('{key}' => $dkey); + + $errors[] = new Invalid($tpl, $var, null, null, $path); + } + + $reqkeys = array_filter($reqkeys, function($rkey) use($dkey) { + return $rkey !== $dkey; + }); + } + + foreach ($reqkeys as $rvalue) { + $path = $root; + $path[] = $rvalue; + + $tpl = 'Required key {key} not provided'; + $var = array('{key}' => $rvalue); + + $errors[] = new Invalid($tpl, $var, null, null, $path); + } + + if (!empty($errors)) { + if (count($errors) === 1) { + throw $errors[0]; + } + throw new InvalidList($errors); + } + + return $return; + }; +} + +/** + * Runs a validator through a list of data keys. + * + * @param mixed $validator to check + * + * @throws Invalid + * @return Closure + */ +function dictkeys($validator) +{ + $schema = compile($validator); + + $type = assert\any( + assert\type('array'), + assert\instance(Traversable::class) + ); + + return function($data, $path = null) use($type, $schema) + { + $data = $type($data, $path); + + $keys = array_keys($data); + $keys = $schema($keys, $path); + + $return = array(); + + foreach ($keys as $key) { + if (!array_key_exists($key, $data)) { + $tpl = 'Value for key {key} not found in {data}'; + $var = array( + '{key}' => json_encode($key), + '{data}' => json_encode($data), + ); + + throw new Invalid($tpl, $var, null, null, $path); + } + + $return[$key] = $data[$key]; + } + + return $return; + }; +} + +/** + * Validates uploaded file structure and error. + * + * @throws Invalid + * @return Closure + */ +function file() +{ + static $errors = array( + UPLOAD_ERR_INI_SIZE => 'File {name} exceeds upload limit', + UPLOAD_ERR_FORM_SIZE => 'File {name} exceeds upload limit in form', + UPLOAD_ERR_PARTIAL => 'File {name} was only partially uploaded', + UPLOAD_ERR_NO_FILE => 'No file was uploaded', + UPLOAD_ERR_CANT_WRITE => 'File {name} could not be written on disk', + UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary directory', + UPLOAD_ERR_EXTENSION => 'File upload failed due to a PHP extension', + ); + + $type = assert\dict( + array(), + array('tmp_name', 'size', 'error', 'name', 'type'), + false + ); + + return function($data, $path = null) use($type, $errors) + { + $data = $type($data, $path); + + if ($data['error'] !== UPLOAD_ERR_OK) { + $tpl = isset($errors[$data['error']]) ? $errors[$data['error']] + : 'File {name} was not uploaded due to an unknown error'; + $var = array('{name}' => $data['name']); + + throw new Invalid($tpl, $var, null, null, $path); + } + + return $data; + }; +} + +/** + * Validate the structure of an object. + * + * @param array $structure to be validation in given $data + * @param string $class the class name of the object + * @param string $byref if false, a new object will be created + * + * @return Closure + */ +function object(array $structure, string $class = null, bool $byref = true) +{ + $type = assert\all( + assert\type('object'), + assert\iif(!is_null($class), assert\instance($class)), + filter\vars(false, true), + assert\dict($structure, false, true) + ); + + return function($data, $path = null) use($type, $byref) + { + $vars = $type($data, $path); + + if ($byref) { + $object = $data; + } else { + $object = clone $data; + } + + foreach ($vars as $key => $value) { + $object->$key = $value; + } + + return $object; + }; +} + +/** + * Validate at least one of the given _validators_ of throw an exception. + * + * @throws Invalid + * @return Closure + */ +function any(...$validators) +{ + $count = func_num_args(); + $schemas = []; + + for ($i = 0; $i < $count; $i++) { + $schemas[] = compile($validators[$i]); + } + + return function($data, $path = null) use($schemas, $count) + { + for ($i = 0; $i < $count; $i++) { + try { + return $schemas[$i]($data, $path); + } catch (InvalidList $e) { + // ignore + } catch (Invalid $e) { + // ignore + } + } + + throw new Invalid('No valid value found', null, null, null, $path); + }; +} + +/** + * Validate all given _validators_ or throw an exception. + * + * @return Closure + */ +function all(...$validators) +{ + $count = func_num_args(); + $schemas = []; + + for ($i = 0; $i < $count; $i++) { + $schemas[] = compile($validators[$i]); + } + + return function($data, $path = null) use($schemas, $count) + { + for ($i = 0; $i < $count; $i++) { + $data = $schemas[$i]($data, $path); + } + + return $data; + }; +} + +/** + * Check that the given _validator_ fail or throw an exception. + * + * @param mixed $validator to check + * + * @throws Invalid + * @return Closure + */ +function not($validator) +{ + $schema = compile($validator); + + return function($data, $path = null) use($schema) + { + try { + $schema($data, $path); + $pass = true; + } catch (Invalid $e) { + $pass = false; + } + + if ($pass) { + throw new Invalid('Validator passed', null, null, null, $path); + } + + return $data; + }; +} + +/** + * Simple condition validator. + * + * @param boolean $condition to check + * @param mixed $true validator if the condition is true + * @param mixed $false validator if the condition is false + * + * @return Closure + */ +function iif(bool $condition, $true = null, $false = null) +{ + $schema = function($data, $path = null) { return $data; }; + + if ($condition) { + if (!is_null($true)) { + $schema = compile($true); + } + } else { + if (!is_null($false)) { + $schema = compile($false); + } + } + + return function($data, $path = null) use($schema) + { + return $schema($data, $path); + }; +} + +/** + * The given $data length is between $min and $max value. + * + * @param integer|null $min the minimum value + * @param integer|null $max the maximum value + * + * @throws Invalid + * @return Closure + */ +function length(int $min = null, int $max = null) +{ + return function($data, $path = null) use($min, $max) + { + if (gettype($data) === 'string') { + $count = function($data) { return strlen($data); }; + } else { + $count = function($data) { return count($data); }; + } + + if (!is_null($min) && $count($data) < $min) { + $tpl = 'Value must be at least {limit}'; + $var = array('{limit}' => $min); + + throw new Invalid($tpl, $var, null, null, $path); + } + + if (!is_null($max) && $count($data) > $max) { + $tpl = 'Value must be at most {limit}'; + $var = array('{limit}' => $max); + + throw new Invalid($tpl, $var, null, null, $path); + } + + return $data; + }; +} + +/** + * A wrapper for validate filters using `filter_var`. + * + * @param string $name of the the filter + * + * @throws Invalid + * @return Closure + */ +function validate(string $name) +{ + $id = filter_id($name); + + return function($data, $path = null) use($name, $id) + { + if (filter_var($data, $id) === false) { + $tpl = 'Validation {name} for {value} failed'; + $var = array( + '{name}' => $name, + '{value}' => json_encode($data), + ); + + throw new Invalid($tpl, $var, null, null, $path); + } + + return $data; + }; +} + +function url() +{ + return assert\validate('validate_url'); +} + +function email() +{ + return assert\validate('validate_email'); +} + +function ip() +{ + return assert\validate('validate_ip'); +} + +function boolval() +{ + return assert\validate('boolean'); +} + +function intval() +{ + return assert\validate('int'); +} + +function floatval() +{ + return assert\validate('float'); +} + +/** + * Will validate if $data can be parsed with given $format. + * + * @param string $format to parse the string with + * @param boolean $strict if true will throw Invalid on warnings too + * + * @throws Invalid + * @throws InvalidList + * @return Closure + */ +function datetime(string $format, bool $strict = false) +{ + return function($data, $path = null) use($format, $strict) + { + // Silent the PHP Warning when a non-string is given. + $dt = @\date_parse_from_format($format, $data); + + if ($dt === false || !is_array($dt)) { + $tpl = 'Datetime format {format} for {value} failed'; + $var = array( + '{format}' => $format, + '{value}' => json_encode($data), + ); + + throw new Invalid($tpl, $var, null, null, $path); + } + + if ($dt['error_count'] + ($strict ? $dt['warning_count'] : 0) > 0) { + $problems = $dt['errors']; + if ($strict) { + $problems = array_merge($problems, $dt['warnings']); + } + + $errors = array(); + foreach ($problems as $pos => $problem) { + $tpl = 'Datetime format {format} for {value} failed' + . ' on position {pos}: {problem}'; + $var = array( + '{format}' => $format, + '{value}' => json_encode($data), + '{pos}' => $pos, + '{problem}' => $problem, + ); + + $errors[] = new Invalid($tpl, $var, null, null, $path); + } + + if (count($errors) === 1) { + throw $errors[0]; + } + throw new InvalidList($errors); + } + + if ($dt['month'] !== false + && $dt['day'] !== false + && $dt['year'] !== false + && !checkdate($dt['month'], $dt['day'], $dt['year']) + ) { + $tpl = 'Date in {value} is not valid'; + $var = array( + '{value}' => json_encode($data), + ); + + throw new Invalid($tpl, $var, null, null, $path); + } + + return $data; + }; +} + +/** + * A wrapper around `preg_match` in a match/notmatch fashion. + * + * @param string $pattern regular expression to match + * + * @throws Invalid + * @return Closure + */ +function match(string $pattern) +{ + return function($data, $path = null) use($pattern) + { + if (!preg_match($pattern, $data)) { + $tpl = 'Value {value} doesn\'t follow {pattern}'; + $var = array( + '{pattern}' => $pattern, + '{value}' => json_encode($data), + ); + + throw new Invalid($tpl, $var, null, null, $path); + } + + return $data; + }; +} diff --git a/library/exceptions.php b/library/exceptions.php new file mode 100644 index 0000000..379eb52 --- /dev/null +++ b/library/exceptions.php @@ -0,0 +1,164 @@ + + */ + protected $errors; + + /** + * List of messages. + * + * @var array + */ + protected $messages; + + /** + * @param array $errors are a list of `\plan\Invalid` exceptions + * @param Exception $previous previous exception + */ + public function __construct(array $errors, Exception $previous = null) + { + /** + * Extracts error message. + * + * @param Invalid $error the exception + * + * @return string + */ + $extract = function(Invalid $error) + { + return $error->getMessage(); + }; + + $this->errors = $errors; + $this->messages = array_map($extract, $this->errors); + + parent::__construct(implode(', ', $this->messages), null, $previous); + } + + /** + * Retrieve a list of Invalid errors. The returning array will have one + * level deep only. + * + * @return array + */ + public function getFlatErrors() + { + /** + * Reducer that flat the errors. + * + * @param array $carry previous error list + * @param Invalid $item to append to $carry + * + * @return array + */ + $reduce = function(array $carry, Invalid $item) + { + if ($item instanceof InvalidList) { + $carry = array_merge($carry, $item->getFlatErrors()); + } else { + $carry[] = $item; + } + + return $carry; + }; + + return iterator_to_array(array_reduce($this->errors, $reduce, [])); + } + + /** + * Retrieve a list of error messages. + * + * @return array + */ + public function getMessages() + { + return $this->messages; + } + + /** + * (non-PHPdoc) + * @see IteratorAggregate::getIterator() + */ + public function getIterator() + { + return new ArrayIterator($this->errors); + } +} + +/** + * Base exception for errors thrown during assertion. + */ +class Invalid extends Exception +{ + /** + * Message template. + * + * @var string + */ + protected $template; + + /** + * Parameters to message template. + * + * @var array + */ + protected $params = []; + + /** + * Path from the root to the exception. + * + * @var array + */ + protected $path = []; + + /** + * @param string $template template for final message + * @param array $params parameters to the template + * @param string $code error identity code + * @param Exception $previous previous exception + * @param array $path list of indexes/keys inside the tree + */ + public function __construct( + string $template, + array $params = null, + string $code = null, + Exception $previous = null, + array $path = null + ) { + if (!empty($params) && !util\is_sequence($params)) { + $message = strtr($template, $params); + } else { + $message = $template; + } + + parent::__construct($message, $code, $previous); + + $this->template = $template; + $this->params = is_null($params) ? [] : $params; + $this->path = is_null($path) ? [] : $path; + } + + /** + * Retrieve the path. + * + * @return array + */ + public function getPath() + { + return array_values($this->path); + } +} diff --git a/library/filter.php b/library/filter.php new file mode 100644 index 0000000..5c102e8 --- /dev/null +++ b/library/filter.php @@ -0,0 +1,214 @@ + json_encode($data), + '{type}' => $type, + ); + + throw new Invalid($tpl, $var, null, null, $path); + } + + return $data; + }; +} + +/** + * Wrapper for `boolval`. + * + * @return Closure + */ +function boolval() +{ + return function($data, $path = null) + { + return \boolval($data); + }; +} + +/** + * Wrapper for `intval`. + * + * @param integer $base numerical base + * + * @return Closure + */ +function intval(int $base = 10) +{ + return function($data, $path = null) use($base) + { + return \intval($data, $base); + }; +} + +/** + * Wrapper for `floatval`. + * + * @return Closure + */ +function floatval() +{ + return function($data, $path = null) + { + return \floatval($data); + }; +} + +/** + * A wrapper for sanitize filters using `filter_var`. + * + * @param string $name of the filter + * + * @throws Invalid + * @return Closure + */ +function sanitize(string $name) +{ + $id = filter_id($name); + + return function($data, $path = null) use($name, $id) + { + $newdata = filter_var($data, $id); + + if ($newdata === false) { + $tpl = 'Sanitization {name} for {value} failed'; + $var = array( + '{name}' => $name, + '{value}' => json_encode($data), + ); + + throw new Invalid($tpl, $var, null, null, $path); + } + + return $newdata; + }; +} + +/** + * Alias of `plan\filter\sanitize('url')`. + */ +function url() +{ + return filter\sanitize('url'); +} + +/** + * Alias of `plan\filter\sanitize('email')`. + */ +function email() +{ + return filter\sanitize('email'); +} + +/** + * Will take an object and return an associative array from it's properties. + * + * @param boolean $recursive if true will process with recursion + * @param boolean $inscope if true will only return public properties + * + * @return Closure + */ +function vars(bool $recursive = false, bool $inscope = true) +{ + $closure = function($data, $path = null) use($recursive, $inscope, &$closure) + { + if (!is_object($data)) { + return $data; + } + + if ($inscope) { + $vars = get_object_vars($data); + } else { + $vars = (array) $data; + + $clkey = "\0" . get_class($data) . "\0"; + $cllen = strlen($clkey); + + $replace = array(); + + foreach ($vars as $key => $value) { + // XXX Why not this? + // $tmp = explode("\0", $key); + // $key = $tmp[count($tmp) - 1]; + if ($key[0] === "\0") { + unset($vars[$key]); + + if ($key[1] === '*') { + $key = substr($key, 3); + } elseif (substr($key, 0, $cllen) === $clkey) { + $key = substr($key, $cllen); + } + + $replace[$key] = $value; + } + } + + if (!empty($replace)) { + $vars = array_replace($vars, $replace); + } + } + + if ($recursive) { + // This is a ingenius way of doing recursion because we don't send + // the $path variable. If in the future this function throw an + // exception it should be doing manually: + // + // $root = $path === null ? [] : $path; + // foreach ($vars as $key => $value) { + // $path = $root; + // $path[] = $key; + // $vars[$key] = $closure($value, $path); + // } + $vars = array_map($closure, $vars); + } + + return $vars; + }; + + return $closure; +} + +/** + * Will parse given $format into a \DateTime object. + * + * @param string $format to parse the string with + * @param boolean $strict if true will throw Invalid on warnings too + * + * @return Closure + */ +function datetime(string $format, bool $strict = false) +{ + $type = assert\datetime($format, $strict); + + return function($data, $path = null) use($type, $format) + { + $data = $type($data, $path); + $date = date_create_immutable_from_format($format, $data); + + return $date; + }; +} diff --git a/library/filter_intl.php b/library/filter_intl.php new file mode 100644 index 0000000..9738fcc --- /dev/null +++ b/library/filter_intl.php @@ -0,0 +1,77 @@ +getFlatErrors(); + } catch (Invalid $e) { + $errors = [$e]; + } + + return new class($valid, $result, $errors) + { + /** + * @var boolean + */ + protected $valid; + + /** + * @var mixed + */ + protected $result; + + /** + * @var array + */ + protected $errors; + + public function __construct(bool $valid, $result, array $errors) + { + $this->valid = $valid; + $this->result = $result; + $this->errors = $errors; + } + + public function isValid() + { + return $this->valid; + } + + public function getResult() + { + if (!$this->valid) { + throw new InvalidList($this->errors); + } + + return $this->result; + } + + public function getErrors() + { + return $this->errors; + } + }; + }; +} diff --git a/library/util.php b/library/util.php new file mode 100644 index 0000000..58dd59c --- /dev/null +++ b/library/util.php @@ -0,0 +1,33 @@ + - * - * Permission to use, copy, modify, and/or distribute this software for any - * purpose with or without fee is hereby granted, provided that the above - * copyright notice and this permission notice appear in all copies. - * - * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES - * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF - * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY - * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES - * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION - * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN - * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - */ - -namespace plan; - -class Schema -{ - /** - * This is the root validator. It's what we get from compiled schemas. If - * it has children, will be the validator in charge of call them. - * - * @var callable - */ - protected $compiled; - - /** - * @param mixed $schema the plan schema - */ - public function __construct($schema) - { - $this->compiled = self::compile($schema); - } - - public function __invoke($data) - { - $validator = $this->compiled; - - try { - return $validator($data); - } catch (InvalidList $e) { - throw $e; - } catch (Invalid $e) { - throw new InvalidList([$e]); - } - } - - /** - * Compile the schema depending on it's type. Will return always a callable - * or throw a \LogicException otherwise. If $schema is already a callable - * will return it without modification. If not will wrap it around the - * proper validation function. - * - * @param mixed $schema the plan schema - * - * @throws \LogicException - * @return callable - */ - public static function compile($schema) - { - if (\is_scalar($schema)) { - $validator = assert\literal($schema); - } - - elseif (\is_array($schema)) { - if (empty($schema) || util\is_sequence($schema)) { - $validator = assert\seq($schema); - } else { - $validator = assert\dict($schema); - } - } - - elseif (\is_callable($schema)) { - $validator = $schema; - } - - else { - throw new \LogicException( - \sprintf('Unsupported type %s', \gettype($schema)) - ); - } - - return $validator; - } -} - -class Invalid extends \Exception -{ - /** - * Message template. - * - * @var string - */ - protected $template; - - /** - * Parameters to message template. - * - * @var array - */ - protected $params = []; - - /** - * Path from the root to the exception. - * - * @var array - */ - protected $path = []; - - /** - * @param string $template template for final message - * @param array $params parameters to the template - * @param string $code error identity code - * @param \Exception $previous previous exception - * @param array $path list of indexes/keys inside the tree - */ - public function __construct($template, array $params=null, $code=null, - $previous=null, array $path=null - ) { - if (!\is_null($params) && !util\is_sequence($params)) { - $message = \strtr($template, $params); - } else { - $message = $template; - } - - parent::__construct($message, $code, $previous); - - $this->template = $template; - $this->params = $params === null ? [] : $params; - $this->path = $path === null ? [] : $path; - } - - /** - * Retrieve the path. - * - * @return array - */ - public function getPath() - { - return \array_values($this->path); - } -} - -class InvalidList extends \Exception implements \IteratorAggregate -{ - /** - * List of exceptions. - * - * @var array - */ - protected $errors; - - /** - * List of messages. - * - * @var array - */ - protected $messages; - - /** - * @param array $errors are a list of `\plan\Invalid` exceptions - * @param string $previous previous exception - */ - public function __construct(array $errors, $previous=null) - { - /** - * Extracts error message. - * - * @param Invalid $error the exception - * - * @return string - */ - $extract = function(Invalid $error) - { - return $error->getMessage(); - }; - - $this->errors = $errors; - $this->messages = \array_map($extract, $this->errors); - - $message = 'Multiple invalid: ' . \json_encode($this->messages); - - parent::__construct($message, null, $previous); - } - - /** - * Retrieve a list of error messages. - * - * @return array - */ - public function getMessages() - { - return $this->messages; - } - - /** - * (non-PHPdoc) - * @see \IteratorAggregate::getIterator() - */ - public function getIterator() - { - return new \ArrayIterator($this->errors); - } -} - -namespace plan\assert; - -use plan\Schema; -use plan\Invalid; -use plan\InvalidList; - -use plan\assert; -use plan\filter; -use plan\util; - -/** - * Check that the input data is of the given $type. The data type will not be - * casted. - * - * @param string $type something that `gettype` could return - * - * @throws \plan\Invalid - * @return \Closure - */ -function type($type) -{ - return function($data, $path=null) use($type) - { - if (\gettype($data) !== $type) { - $tpl = '{data} is not {type}'; - $var = array( - '{data}' => \json_encode($data), - '{type}' => $type, - ); - - throw new Invalid($tpl, $var, null, null, $path); - } - - return $data; - }; -} - -/** - * Alias of `plan\assert\type('boolean')`. - */ -function bool() -{ - return assert\type('boolean'); -} - -/** - * Alias of `plan\assert\type('integer')`. - */ -function int() -{ - return assert\type('integer'); -} - -/** - * Alias of `plan\assert\type('double')`. - */ -function float() -{ - return assert\type('double'); -} - -/** - * Alias of `plan\assert\type('string')`. - */ -function str() -{ - return assert\type('string'); -} - -/** - * Wrapper for `is_scalar`. - * - * @throws \plan\Invalid - * @return \Closure - */ -function scalar() -{ - return function($data, $path=null) - { - if (!\is_scalar($data)) { - $tpl = '{data} is not scalar'; - $var = array( - '{data}' => \json_encode($data), - ); - - throw new Invalid($tpl, $var, null, null, $path); - } - - return $data; - }; -} - -/** - * Wrapper for `instanceof` type operator. - * - * @param string|object $class right operator of `instanceof` - * - * @throws \plan\Invalid - * @return \Closure - */ -function instance($class) -{ - return function($data, $path=null) use($class) - { - if (!$data instanceof $class) { - $tpl = 'Expected {class} (is {data_class})'; - $var = array( - '{class}' => $class, - '{data_class}' => \is_object($data) ? \get_class($data) - : 'not an object', - ); - - throw new Invalid($tpl, $var, null, null, $path); - } - - return $data; - }; -} - -/** - * Compare $data with $literal using the identity operator. - * - * @param mixed $literal something to compare to - * - * @throws \plan\Invalid - * @return \Closure - */ -function literal($literal) -{ - $type = assert\type(\gettype($literal)); - - return function($data, $path=null) use($type, $literal) - { - $data = $type($data, $path); - - if ($data !== $literal) { - $tpl = '{data} is not {literal}'; - $var = array( - '{data}' => \json_encode($data), - '{literal}' => \json_encode($literal), - ); - - throw new Invalid($tpl, $var, null, null, $path); - } - - return $data; - }; -} - -/** - * The given schema has to be a list of possible valid values to validate from. - * If empty, will accept any value. - * - * @param array $values list of values - * - * @throws \plan\Invalid - * @return \Closure - */ -function seq(array $values) -{ - $compiled = array(); - - for ($s = 0, $sl = \count($values); $s < $sl; $s++) { - $compiled[] = Schema::compile($values[$s]); - } - - $type = assert\type('array'); - - return function($data, $root=null) use($type, $compiled, $sl) - { - $data = $type($data, $root); - - // Empty sequence schema, - // allow any data - if (empty($compiled)) { - return $data; - } - - $return = array(); - $root = $root === null ? [] : $root; - $dl = \count($data); - - for ($d = 0; $d < $dl; $d++) { - $found = null; - - $path = $root; - $path[] = $d; - - for ($s = 0; $s < $sl; $s++) { - try { - $return[] = $compiled[$s]($data[$d], $path); - $found = true; - break; - } catch (Invalid $e) { - $found = false; - if (\count($e->getPath()) > \count($path)) { - throw $e; - } - } - } - - if ($found !== true) { - $tpl = 'Invalid value at index {index} (value is {value})'; - $var = array( - '{index}' => $d, - '{value}' => \json_encode($data[$d]), - ); - - throw new Invalid($tpl, $var, null, null, $path); - } - } - - return $return; - }; -} - -/** - * Validate the structure of the data. - * - * @param array $structure key/validator array - * @param boolean $required if require all keys to be present - * @param boolean $extra if accept extra keys - * - * @throws \plan\Invalid - * @throws \plan\InvalidList - * @return \Closure - */ -function dict(array $structure, $required=false, $extra=false) -{ - $compiled = array(); - $reqkeys = array(); - - foreach ($structure as $key => $value) { - $compiled[$key] = Schema::compile($value); - } - - if ($required === true) { - $reqkeys = \array_keys($compiled); - } elseif (\is_array($required)) { - $reqkeys = \array_values($required); - } else { - $reqkeys = array(); - } - - if (\is_array($extra)) { - if (util\is_sequence($extra)) { - $cextra = \array_flip(\array_values($extra)); - } else { - $cextra = array(); - foreach ($extra as $dextra => $vextra) { - $cextra[$dextra] = Schema::compile($vextra); - } - } - } else { - $cextra = $extra === true ?: array(); - } - - $type = assert\any( - assert\type('array'), - assert\instance('\Traversable') - ); - - return function($data, $root=null) use($type, $compiled, $reqkeys, $cextra) - { - $data = $type($data, $root); - $root = $root === null ? [] : $root; - - $return = array(); - $errors = array(); - - foreach ($data as $dkey => $dvalue) { - $path = $root; - $path[] = $dkey; - - if (\array_key_exists($dkey, $compiled)) { - try { - $return[$dkey] = $compiled[$dkey]($dvalue, $path); - } catch (Invalid $e) { - if (\count($e->getPath()) > \count($path)) { - // Always grab deepest exception - // It will contain the path through here - $errors[] = $e; - continue; - } - - $tpl = 'Invalid value at key {key} (value is {value})'; - $var = array( - '{key}' => $dkey, - '{value}' => \json_encode($dvalue) - ); - - $errors[] = new Invalid($tpl, $var, null, $e, $path); - - unset($tpl); - unset($var); - } - } elseif (\in_array($dkey, $reqkeys)) { - $return[$dkey] = $dvalue; // no validation done - } elseif ($cextra === true || \array_key_exists($dkey, $cextra)) { - if (\is_callable($cextra[$dkey])) { - try { - $return[$dkey] = $cextra[$dkey]($dvalue, $path); - } catch (Invalid $e) { - $tpl = 'Extra key {key} is not valid'; - $var = array('{key}' => $dkey); - - $errors[] = new Invalid($tpl, $var, null, $e, $path); - } - } else { - $return[$dkey] = $dvalue; - } - } else { - $tpl = 'Extra key {key} not allowed'; - $var = array('{key}' => $dkey); - - $errors[] = new Invalid($tpl, $var, null, null, $path); - } - - $reqkeys = \array_filter($reqkeys, function($rkey) use($dkey) { - return $rkey !== $dkey; - }); - } - - foreach ($reqkeys as $rvalue) { - $path = $root; - $path[] = $rvalue; - - $tpl = 'Required key {key} not provided'; - $var = array('{key}' => $rvalue); - - $errors[] = new Invalid($tpl, $var, null, null, $path); - } - - if (!empty($errors)) { - if (\count($errors) === 1) { - throw $errors[0]; - } - throw new InvalidList($errors); - } - - return $return; - }; -} - -/** - * Validates uploaded file structure and error. - * - * @throws \plan\Invalid - * @return \Closure - */ -function file() -{ - static $errors = array( - UPLOAD_ERR_INI_SIZE => 'File {name} exceeds upload limit', - UPLOAD_ERR_FORM_SIZE => 'File {name} exceeds upload limit in form', - UPLOAD_ERR_PARTIAL => 'File {name} was only partially uploaded', - UPLOAD_ERR_NO_FILE => 'No file was uploaded', - UPLOAD_ERR_CANT_WRITE => 'File {name} could not be written on disk', - UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary directory', - UPLOAD_ERR_EXTENSION => 'File upload failed due to a PHP extension', - ); - - $type = assert\dict( - array(), - array('tmp_name', 'size', 'error', 'name', 'type'), - false - ); - - return function($data, $root=null) use($type, $errors) - { - $data = $type($data, $root); - - if ($data['error'] !== UPLOAD_ERR_OK) { - $tpl = isset($errors[$data['error']]) ? $errors[$data['error']] - : 'File {name} was not uploaded due to an unknown error'; - $var = array('{name}' => $data['name']); - - throw new Invalid($tpl, $var, null, null, $path); - } - - return $data; - }; -} - -/** - * Runs a validator through a list of data keys. - * - * @param mixed $validator to check - * - * @throws \plan\Invalid - * @return \Closure - */ -function dictkeys($validator) -{ - $compiled = Schema::compile($validator); - - $type = assert\any( - assert\type('array'), - assert\instance('\Traversable') - ); - - return function($data, $root=null) use($type, $compiled) - { - $data = $type($data, $root); - - $keys = \array_keys($data); - $keys = $compiled($keys, $root); - - $return = array(); - - foreach ($keys as $key) { - if (!\array_key_exists($key, $data)) { - $tpl = 'Value for key {key} not found in {data}'; - $var = array( - '{key}' => \json_encode($key), - '{data}' => \json_encode($data), - ); - - throw new Invalid($tpl, $var, null, null, $root); - } - - $return[$key] = $data[$key]; - } - - return $return; - }; -} - -/** - * Validate the structure of an object. - * - * @param array $structure to be validation in given $data - * @param string $class the class name of the object - * @param string $byref if false, a new object will be created - * - * @return \Closure - */ -function object(array $structure, $class=null, $byref=true) -{ - $type = assert\all( - assert\type('object'), - assert\iif(null !== $class, assert\instance($class)), - filter\vars(false, true), - assert\dict($structure, false, true) - ); - - return function($data, $path=null) use($type, $byref) - { - $vars = $type($data, $path); - - if ($byref) { - $object = $data; - } else { - $object = clone $data; - } - - foreach ($vars as $key => $value) { - $object->$key = $value; - } - - return $object; - }; -} - -/** - * Validate at least one of the given _validators_ of throw an exception. - * - * @throws \plan\Invalid - * @return \Closure - */ -function any(...$validators) -{ - $count = \func_num_args(); - $schemas = []; - - for ($i = 0; $i < $count; $i++) { - $schemas[] = Schema::compile($validators[$i]); - } - - return function($data, $path=null) use($schemas, $count) - { - for ($i = 0; $i < $count; $i++) { - try { - return $schemas[$i]($data); - } catch (Invalid $e) { - // Ignore: We want to validate only one, if this is not, it was - // not meant to be. - } - } - - throw new Invalid('No valid value found', null, null, null, $path); - }; -} - -/** - * Validate all given _validators_ or throw an exception. - * - * @throws \plan\Invalid - * @return \Closure - */ -function all(...$validators) -{ - $count = \func_num_args(); - $schemas = []; - - for ($i = 0; $i < $count; $i++) { - $schemas[] = Schema::compile($validators[$i]); - } - - return function($data, $path=null) use($schemas, $count) - { - $return = $data; - - for ($i = 0; $i < $count; $i++) { - $return = $schemas[$i]($return, $path); - } - - return $return; - }; -} - -/** - * Check that the given _validator_ fail or throw an exception. - * - * @param mixed $validator to check - * - * @throws \plan\Invalid - * @return \Closure - */ -function not($validator) -{ - $compiled = Schema::compile($validator); - - return function($data, $path=null) use($compiled) - { - $pass = null; - - try { - $compiled($data, $path); - $pass = true; - } catch (Invalid $e) { - $pass = false; - } - - if ($pass) { - throw new Invalid('Validator passed', null, null, null, $path); - } - - return $data; - }; -} - -/** - * Simple condition validator. - * - * @param boolean $condition to check - * @param mixed $true validator if the condition is true - * @param mixed $false validator if the condition is false - * - * @return \Closure - */ -function iif($condition, $true=null, $false=null) -{ - $validator = function($data, $path=null) { return $data; }; - - if ($condition) { - if (null !== $true) { - $validator = Schema::compile($true); - } - } else { - if (null !== $false) { - $validator = Schema::compile($false); - } - } - - return function($data, $path=null) use($validator) - { - return $validator($data, $path); - }; -} - -/** - * The given $data length is between $min and $max value. - * - * @param integer|null $min the minimum value - * @param integer|null $max the maximum value - * - * @throws \plan\Invalid - * @return \Closure - */ -function length($min=null, $max=null) -{ - return function($data, $path=null) use($min, $max) - { - if (\gettype($data) === 'string') { - $count = function($data) { return \strlen($data); }; - } else { - $count = function($data) { return \count($data); }; - } - - if ($min !== null && $count($data) < $min) { - $tpl = 'Value must be at least {limit}'; - $var = array('{limit}' => $min); - - throw new Invalid($tpl, $var, null, null, $path); - } - - if ($max !== null && $count($data) > $max) { - $tpl = 'Value must be at most {limit}'; - $var = array('{limit}' => $max); - - throw new Invalid($tpl, $var, null, null, $path); - } - - return $data; - }; -} - -/** - * A wrapper for validate filters using `filter_var`. - * - * @param string $name of the the filter - * - * @throws \plan\Invalid - * @return \Closure - */ -function validate($name) -{ - $id = \filter_id($name); - - return function($data, $path=null) use($name, $id) - { - if (\filter_var($data, $id) === false) { - $tpl = 'Validation {name} for {value} failed'; - $var = array( - '{name}' => $name, - '{value}' => \json_encode($data), - ); - - throw new Invalid($tpl, $var, null, null, $path); - } - - return $data; - }; -} - -function url() -{ - return assert\validate('validate_url'); -} - -function email() -{ - return assert\validate('validate_email'); -} - -function ip() -{ - return assert\validate('validate_ip'); -} - -function boolval() -{ - return assert\validate('boolean'); -} - -function intval() -{ - return assert\validate('int'); -} - -function floatval() -{ - return assert\validate('float'); -} - -/** - * Will validate if $data can be parsed with given $format. - * - * @param string $format to parse the string with - * @param boolean $strict if true will throw Invalid on warnings too - * - * @throws \plan\Invalid - * @throws \plan\InvalidList - * @return \Closure - */ -function datetime($format, $strict=false) -{ - return function($data, $path=null) use($format, $strict) - { - // Silent the PHP Warning when a non-string is given. - $dt = @\date_parse_from_format($format, $data); - - if ($dt === false || !\is_array($dt)) { - $tpl = 'Datetime format {format} for {value} failed'; - $var = array( - '{format}' => $format, - '{value}' => \json_encode($data), - ); - - throw new Invalid($tpl, $var, null, null, $path); - } - - if ($dt['error_count'] + ($strict ? $dt['warning_count'] : 0) > 0) { - $problems = $dt['errors']; - if ($strict) { - $problems = \array_merge($problems, $dt['warnings']); - } - - $errors = array(); - foreach ($problems as $pos => $problem) { - $tpl = 'Datetime format {format} for {value} failed' - . ' on position {pos}: {problem}'; - $var = array( - '{format}' => $format, - '{value}' => \json_encode($data), - '{pos}' => $pos, - '{problem}' => $problem, - ); - - $errors[] = new Invalid($tpl, $var, null, null, $path); - } - - if (\count($errors) === 1) { - throw $errors[0]; - } - throw new InvalidList($errors); - } - - if ($dt['month'] !== false - && $dt['day'] !== false - && $dt['year'] !== false - && !\checkdate($dt['month'], $dt['day'], $dt['year']) - ) { - $tpl = 'Date in {value} is not valid'; - $var = array( - '{value}' => \json_encode($data), - ); - - throw new Invalid($tpl, $var, null, null, $path); - } - - return $data; - }; -} - -/** - * A wrapper around `preg_match` in a match/notmatch fashion. - * - * @param string $pattern regular expression to match - * - * @throws \plan\Invalid - * @return \Closure - */ -function match($pattern) -{ - return function($data, $path=null) use($pattern) - { - if (!\preg_match($pattern, $data)) { - $tpl = 'Value {value} doesn\'t follow {pattern}'; - $var = array( - '{pattern}' => $pattern, - '{value}' => \json_encode($data), - ); - - throw new Invalid($tpl, $var, null, null, $path); - } - - return $data; - }; -} - -namespace plan\filter; - -use plan\Invalid; -use plan\InvalidList; -use plan\assert; -use plan\filter; - -/** - * Cast data type into given $type. - * - * @param string $type given to `settype` - * - * @throws \plan\Invalid - * @return \Closure - */ -function type($type) -{ - return function($data, $path=null) use($type) - { - // We need to mute the warning here. The function will return false if - // it fails anyways and will throw our Invalid exception if that - // happend. Also, PHPUnit convert warnings into exceptions and make the - // test fail. - $ret = @\settype($data, $type); - - if ($ret === false) { - $tpl = 'Cannot cast {data} into {type}'; - $var = array( - '{data}' => \json_encode($data), - '{type}' => $type, - ); - - throw new Invalid($tpl, $var, null, null, $path); - } - - return $data; - }; -} - -/** - * Wrapper for `boolval`. - * - * @return \Closure - */ -function boolval() -{ - return function($data, $path=null) - { - return \boolval($data); - }; -} - -/** - * Wrapper for `intval`. - * - * @return \Closure - */ -function intval($base=10) -{ - return function($data, $path=null) use($base) - { - return \intval($data, $base); - }; -} - -/** - * Wrapper for `floatval`. - * - * @return \Closure - */ -function floatval() -{ - return function($data, $path=null) - { - return \floatval($data); - }; -} - -/** - * A wrapper for sanitize filters using `filter_var`. - * - * @param string $name of the filter - * - * @throws \plan\Invalid - * @return \Closure - */ -function sanitize($name) -{ - $id = \filter_id($name); - - return function($data, $path=null) use($name, $id) - { - $newdata = \filter_var($data, $id); - - if ($newdata === false) { - $tpl = 'Sanitization {name} for {value} failed'; - $var = array( - '{name}' => $name, - '{value}' => \json_encode($data), - ); - - throw new Invalid($tpl, $var, null, null, $path); - } - - return $newdata; - }; -} - -/** - * Alias of `plan\filter\sanitize('url')`. - */ -function url() -{ - return filter\sanitize('url'); -} - -/** - * Alias of `plan\filter\sanitize('email')`. - */ -function email() -{ - return filter\sanitize('email'); -} - -/** - * Will take an object and return an associative array from it's properties. - * - * @param boolean $recursive if true will process with recursion - * @param boolean $inscope if true will only return public properties - * - * @return \Closure - */ -function vars($recursive=false, $inscope=true) -{ - $closure = function($data, $path=null) use($recursive, $inscope, &$closure) - { - if (!\is_object($data)) { - return $data; - } - - if ($inscope) { - $vars = \get_object_vars($data); - } else { - $vars = (array) $data; - - $clkey = "\0" . \get_class($data) . "\0"; - $cllen = \strlen($clkey); - - $replace = array(); - - foreach ($vars as $key => $value) { - // XXX Why not this? - // $tmp = \explode("\0", $key); - // $key = $tmp[\count($tmp) - 1]; - if ($key[0] === "\0") { - unset($vars[$key]); - - if ($key[1] === '*') { - $key = \substr($key, 3); - } elseif (\substr($key, 0, $cllen) === $clkey) { - $key = \substr($key, $cllen); - } - - $replace[$key] = $value; - } - } - - if (!empty($replace)) { - $vars = \array_replace($vars, $replace); - } - } - - if ($recursive) { - // This is a ingenius way of doing recursion because we don't send - // the $path variable. If in the future this function throw an - // exception it should be doing manually: - // - // $root = $path === null ? [] : $path; - // foreach ($vars as $key => $value) { - // $path = $root; - // $path[] = $key; - // $vars[$key] = $closure($value, $path); - // } - $vars = \array_map($closure, $vars); - } - - return $vars; - }; - - return $closure; -} - -/** - * Will parse given $format into a \DateTime object. - * - * @param string $format to parse the string with - * @param boolean $strict if true will throw Invalid on warnings too - * - * @return \Closure - */ -function datetime($format, $strict=false) -{ - $type = assert\datetime($format, $strict); - - return function($data, $path=null) use($type, $format) - { - $data = $type($data, $path); - $date = \date_create_immutable_from_format($format, $data); - - return $date; - }; -} - -namespace plan\filter\intl; - -use plan\filter; -use plan\util; - -/** - * Keep only langauge chars. - * - * @param boolean $lower keep lower case letters - * @param boolean $upper keep upper case letters - * @param boolean $number keep numbers - * @param boolean $whitespace keep whitespace - * - * @return \Closure - */ -function chars($lower=true, $upper=true, $number=true, $whitespace=false) -{ - $patterns = array(); - - if ($whitespace) { - $patterns[] = '\s'; - } - - if (util\has_pcre_unicode_support()) { - if ($lower && $upper) { - $patterns[] = '\p{L}'; - } elseif ($lower) { - $patterns[] = '\p{Ll}'; - } elseif ($upper) { - $patterns[] = '\p{Lu}'; - } - if ($number) { - $patterns[] = '\p{N}'; - } - - $pattern = '/[^' . \implode('', $patterns) . ']/u'; - } else { - if ($lower) { - $patterns[] = 'a-z'; - } - if ($upper) { - $patterns[] = 'A-Z'; - } - if ($number) { - $patterns[] = '0-9'; - } - - $pattern = '/[^' . \implode('', $patterns) . ']/'; - } - - return function($data, $path=null) use($pattern) - { - return \preg_replace($pattern, '', $data); - }; -} - -/** - * Alias of `filter\intl\chars(true, true, false)`. - */ -function alpha($whitespace=false) -{ - return filter\intl\chars(true, true, false, $whitespace); -} - -/** - * Alias of `filter\intl\chars(true, true, true)`. - */ -function alnum($whitespace=false) -{ - return filter\intl\chars(true, true, true, $whitespace); -} - -namespace plan\util; - -/** - * Little hack to check if all indexes from an array are numerical and in - * sequence. Require that the given `$array` is not empty. - */ -function is_sequence(array $array) -{ - return !count(array_diff_key($array, array_fill(0, count($array), null))); -} - -/** - * Return true if `preg_*` functions support unicode character properties. - * False otherwise. - * - * @link http://php.net/manual/en/regexp.reference.unicode.php - */ -function has_pcre_unicode_support() -{ - static $cache; - - // Mute compilation warning "PCRE does not support \L" as it will return - // false on error anyway. - return isset($cache) ? $cache : $cache = @preg_match('/\pL/u', 'z') === 1; -} diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..6de935f --- /dev/null +++ b/psalm.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/Suite/assertSpec.php b/spec/Suite/assertSpec.php new file mode 100644 index 0000000..a407eb0 --- /dev/null +++ b/spec/Suite/assertSpec.php @@ -0,0 +1,343 @@ +toBe(true); + expect($schema(false))->toBe(false); + }); + it('validates integer with assert\\int', function() { + $schema = assert\int(); + + expect($schema(0))->toBe(0); + expect($schema(PHP_INT_MAX))->toBe(PHP_INT_MAX); + }); + it('validates float with assert\\float', function() { + $schema = assert\float(); + + expect($schema(0.0))->toBe(0.0); + expect($schema(2.33333333333333))->toBe(2.33333333333333); + }); + it('validates string with assert\\str', function() { + $schema = assert\str(); + + expect($schema('hello'))->toBe('hello'); + expect($schema('world'))->toBe('world'); + }); + it('throws Invalid on not boolean with assert\\bool', function() { + expect(function() { + $schema = assert\bool(); + $schema(123); + }) + ->toThrow(new Invalid('123 is not boolean')); + }); + it('throws Invalid on not integer with assert\\int', function() { + expect(function() { + $schema = assert\int(); + $schema(2.7); + }) + ->toThrow(new Invalid('2.7 is not integer')); + }); + it('throws Invalid on not float with assert\\float', function() { + expect(function() { + $schema = assert\float(); + $schema('hello'); + }) + ->toThrow(new Invalid('"hello" is not double')); + }); + it('throws Invalid on not string with assert\\str', function() { + expect(function() { + $schema = assert\str(); + $schema(true); + }) + ->toThrow(new Invalid('true is not string')); + }); + }); + + describe('scalar', function() { + it('validates scalar', function() { + $schema = assert\scalar(); + + expect($schema(true))->toBe(true); + expect($schema(123))->toBe(123); + expect($schema(2.7))->toBe(2.7); + expect($schema('hello'))->toBe('hello'); + }); + it('throws Invalid on not scalar data', function() { + $schema = assert\scalar(); + + expect(function() use($schema) { $schema(array()); })->toThrow(new Invalid('array is not scalar')); + expect(function() use($schema) { $schema(new stdClass()); })->toThrow(new Invalid('object is not scalar')); + }); + }); + + describe('instance', function() { + it('validates instances from interface', function() { + expect(assert\instance(Iterator::class)(new EmptyIterator))->toBeAnInstanceOf(EmptyIterator::class); + }); + it('validates instances from class', function() { + $asString = assert\instance(ArrayIterator::class); + $asInstance = assert\instance(new ArrayIterator); + + expect($asString(new RecursiveArrayIterator))->toBeAnInstanceOf(RecursiveArrayIterator::class); + expect($asInstance(new RecursiveArrayIterator))->toBeAnInstanceOf(RecursiveArrayIterator::class); + }); + it('throws Invalid on not instance of', function() { + expect(function() { + $schema = assert\instance(stdClass::class); + $schema(new Exception); + }) + ->toThrow(new Invalid('Expected stdClass (is Exception)')); + }); + }); + + describe('literal', function() { + it('validates equal value', function() { + foreach ([ + [123, 123], + ['hello', 'hello'], + [array(1, '1'), array(1, '1')], + ] as list($schema, $data)) { + expect(assert\literal($schema)($data))->toBe($data); + } + }); + it('throws Invalid on not being equal', function() { + expect(function() { + $schema = assert\literal(2); + $schema(3); + }) + ->toThrow(new Invalid('3 is not 2')); + }); + it('throws Invalid on types not being equal', function() { + expect(function() { + $schema = assert\literal('hello'); + $schema(42); + }) + ->toThrow(new Invalid('42 is not string')); + }); + }); + + describe('seq', function() { + it('does not validates if sequence validator is empty', function() { + $schema = assert\seq([]); + + expect($schema([]))->toBe([]); + expect($schema(['hello', 'world']))->toBe(['hello', 'world']); + expect($schema([true, 3.14]))->toBe([true, 3.14]); + }); + it('validates each value with the given validators', function() { + $schema = assert\seq([true, 3.14, 'hello']); + + expect($schema([true]))->toBe([true]); + expect($schema([true, true]))->toBe([true, true]); + expect($schema([3.14, 'hello', true]))->toBe([3.14, 'hello', true]); + expect($schema([3.14, true]))->toBe([3.14, true]); + }); + it('throws Invalid when no valid value found', function() { + expect(function() { + $schema = assert\seq([true, false]); + $schema(['hello']); + }) + ->toThrow(new Invalid('Invalid value at index 0 (value is "hello")')); + }); + it('throws Invalid from errors inside the sequence itself', function() { + expect(function() { + $schema = assert\seq([array('name' => assert\str())]); + $schema([['name' => 3.14]]); + })->toThrow(new Invalid('Invalid value at key name (value is 3.14)')); + }); + }); + + describe('dict', function() {}); + + describe('dictkeys', function() {}); + + describe('file', function() {}); + + describe('object', function() {}); + + describe('any', function() { + it('validates any of the given validators', function() { + $schema = assert\any('true', 'false', assert\bool()); + + expect($schema('true'))->toBe('true'); + expect($schema('false'))->toBe('false'); + expect($schema(true))->toBe(true); + expect($schema(false))->toBe(false); + }); + it('throws Invalid on no valid value found when validator throws Invalid', function() { + expect(function() { + $schema = assert\any('true', 'false', assert\bool()); + $schema(42); + }) + ->toThrow(new Invalid('No valid value found')); + }); + it('throws Invalid on no valid value found when validator throws InvalidList', function() { + expect(function() { + $schema = assert\any(function($data, $path=null) { + throw new InvalidList([ + new Invalid('Some error'), + new Invalid('Some more error') + ]); + }); + $schema(42); + }) + ->toThrow(new Invalid('No valid value found')); + }); + }); + + describe('all', function() { + it('validates using all validators', function() { + foreach ([ + [assert\all(assert\int(), 42), 42], + [assert\all(assert\str(), 'string'), 'string'], + [assert\all(assert\type('array'), assert\length(2, 4)), array('a', 'b', 'c')], + ] as list($schema, $data)) { + expect($schema($data))->toBe($data); + } + }); + }); + + describe('not', function() { + it('validates data that does not pass given validator', function() { + foreach ([ + [compile(123), '123'], + [compile(array(1, '1')), array(1, '1', 2, '2')], + [assert\str(), 123], + [assert\length(2, 4), array('a')], + [assert\any(assert\str(), assert\bool()), array()], + [assert\all(assert\str(), assert\length(2, 4)), array('a', 'b', 'c')], + ] as list($schema, $data)) { + expect(assert\not($schema)($data))->toBe($data); + } + }); + it('throws Invalid if validator passes', function() { + foreach ([ + [compile(123), 123], + [compile(array(1, '1')), array(1, '1', 1, '1')], + [assert\str(), 'string'], + [assert\length(2, 4), array('a', 'b', 'c')], + [assert\any(assert\str(), assert\bool()), true], + [assert\all(assert\str(), assert\length(2, 4)), 'abc'], + ] as list($schema, $data)) { + expect(function() use($schema, $data) { + $schema = assert\not($schema); + $schema($data); + }) + ->toThrow(new Invalid('Validator passed')); + } + }); + }); + + describe('iif', function() { + it('validates first validator in condition is true', function() { + $schema = assert\iif(true, assert\int(), assert\str()); + + expect($schema(42))->toBe(42); + expect(function() use($schema) { + $schema('hello'); + }) + ->toThrow(new Invalid('"hello" is not integer')); + }); + it('validates second validator in condition is false', function() { + $schema = assert\iif(false, assert\int(), assert\str()); + + expect($schema('hello'))->toBe('hello'); + expect(function() use($schema) { + $schema(42); + }) + ->toThrow(new Invalid('42 is not string')); + }); + it('does not validates if validator is not given', function() { + $called = false; + $schema = assert\iif(true, null, function() use(&$called) { + $called = true; + }); + + expect($schema('hello'))->toBe('hello'); + expect($called)->toBe(false); + }); + }); + + describe('length', function() { + it('validates strings and arrays', function() { + $schema = assert\length(2, 4); + foreach (['abc', ['a', 'b', 'c']] as $data) { + expect($schema($data))->toBe($data); + } + }); + it('throws Invalid when min is not reached', function() { + expect(function() { + $schema = assert\length(23); + $schema('hello'); + }) + ->toThrow(new Invalid('Value must be at least 23')); + }); + it('throws Invalid when max has been passed', function() { + expect(function() { + $schema = assert\length(0, 1); + $schema('hello'); + }) + ->toThrow(new Invalid('Value must be at most 1')); + }); + }); + + describe('validate', function() { + it('validates by filter name', function() { + foreach ([ + ['int', '1234567'], + ['boolean', 'true'], + ['float', '7.9999999999999991118'], + ['validate_url', 'http://www.example.org/'], + ['validate_email', 'john@example.org'], + ['validate_ip', '10.0.2.42'], + ] as list($filter, $data)) { + expect(assert\validate($filter)($data))->toBe($data); + } + }); + it('validates using alias validators', function() { + foreach ([ + [assert\intval(), '1234567'], + [assert\boolval(), 'true'], + [assert\floatval(), '7.9999999999999991118'], + [assert\url(), 'http://www.example.org/'], + [assert\email(), 'john@example.org'], + [assert\ip(), '10.0.2.42'], + ] as list($schema, $data)) { + expect($schema($data))->toBe($data); + } + }); + it('throws Invalid on invalid value', function() { + expect(function() { + $schema = assert\validate('int'); + $schema('hello'); + }) + ->toThrow(new Invalid('Validation int for "hello" failed')); + }); + }); + + describe('datetime', function() {}); + + describe('match', function() { + it('validates basic /[a-z]/ /[0-9]/ regular expressions', function() { + foreach ([ + ['/[a-z]/', 'a'], + ['/[0-9]/', '0'], + ] as list($pattern, $data)) { + expect(assert\match($pattern)($data))->toBe($data); + } + }); + it('throws Invalid when regex does not match', function() { + expect(function() { + $schema = assert\match('/[a-z]/'); + $schema(0); + }) + ->toThrow(new Invalid('Value 0 doesn\'t follow /[a-z]/')); + }); + }); +}); \ No newline at end of file From d231d6a174484b5db5c7c514952d0a9f7e8d6c5c Mon Sep 17 00:00:00 2001 From: Juan Martinez Date: Mon, 21 Jan 2019 13:53:01 +0100 Subject: [PATCH 2/6] WIP --- CHANGES.md | 17 +- README.md | 452 ++++++++++------- TODO.md | 2 + composer.json | 5 +- library/assert.php | 411 ++++++++------- library/exceptions.php | 261 +++++++--- library/filter.php | 239 ++++++--- library/filter_intl.php | 77 --- library/functions.php | 131 ----- library/plan.php | 261 ++++++++++ library/util.php | 66 ++- phpunit.xml.dist | 23 - plan_test.php | 1002 ------------------------------------- psalm.xml | 42 -- spec/Suite/assertSpec.php | 343 ------------- spec/assert.spec.php | 700 ++++++++++++++++++++++++++ spec/exceptions.spec.php | 161 ++++++ spec/filter.spec.php | 294 +++++++++++ spec/plan.spec.php | 191 +++++++ spec/util.spec.php | 39 ++ 20 files changed, 2589 insertions(+), 2128 deletions(-) delete mode 100644 library/filter_intl.php delete mode 100644 library/functions.php create mode 100644 library/plan.php delete mode 100644 phpunit.xml.dist delete mode 100644 plan_test.php delete mode 100644 psalm.xml delete mode 100644 spec/Suite/assertSpec.php create mode 100644 spec/assert.spec.php create mode 100644 spec/exceptions.spec.php create mode 100644 spec/filter.spec.php create mode 100644 spec/plan.spec.php create mode 100644 spec/util.spec.php diff --git a/CHANGES.md b/CHANGES.md index eeee5e8..66b04d3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,13 +1,16 @@ ### Last Version -### 3.0.0 (2017-07-XX) +### 3.0.0 (2019-01-21) - * Require PHP 7.1. - * Add `assert\datetime`. - * Add `validate` function. + * Require PHP 7.x. + * Use `kahlan` for testing. + * Add `validate` and `check` functions. + * Add `assert\datetime` and `assert\iterable`. + * Add `filter\template` and `util\repr`. * Re-arrange into `library/` directory. - * Remove `Schema`. - * Rename `Schema::compile` into `compile` function. + * Re-name exception `InvalidList` into `MultipleInvalid`. + * Extract `Schema::compile` into `compile` function. + * Fix `filter\vars` now returns in order. ### 2.1.0 (2017-04-03) @@ -17,7 +20,7 @@ ### 2.0.0 (2016-12-03) * Remove `assert\regexp`. - * Now `Invalid` accepts template and params. + * Now `Invalid` accepts template and parameters. * PHP >=5.6 required. ### 1.3.0 (2016-10-08) diff --git a/README.md b/README.md index 1622799..fdd0593 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,35 @@ Plan Plan is a data validation library for PHP. It's planed to be used for validating data from external sources. -It has three core design goals: +It has two core design goals: -1. Speed; -2. Simplicity and Lightweight; -3. Full validation features support; +1. Simple: use language own features: construct schema from literals and + function composition, errors are exceptions; +2. Lightweight: lots of validations included without 3rd party libraries; -It consist in just one file separated in three _namespaces_. To start, just -have to download it and require it in your code. +Usage +----- + +Simplest way would be requiring `guide42/plan` with composer. Then can be used +freely in PHP code: + +```php +use plan\{Schema, Invalid, assert, filter}; + +$userSchema = new Schema(array( + 'type' => assert\any('user', 'admin'), + 'name' => assert\all( + assert\length(4, 20), + filter\intl\alnum() + ), +)); + +try { + $user = $userSchema($_POST); +} catch (Invalid $invalid) { + $error = $invalid->getMessage(); +} +``` Concepts -------- @@ -25,40 +46,31 @@ through the `\plan\Schema` class. This object, when called like a function, will validate the data and return the modified (or not) data. If any error occurs and exception will be thrown. - getMessage()); - } - } - Schema information will be always be trusted, therefore will not be validate. Contrary input data will be never be trusted. ### Literals -Scalars are treated as literals that are matched using the identity operator: - - $plan = new plan('Hello World'); - $plan('Hello World'); // returns 'Hello World' - - $plan = new plan(42); - - try { - $plan(10); - } catch (InvalidList $e) { - // $e->getMessage() will be - // Multiple invalid: ["\"10\" is not \"42\""] - } +Scalars are treated as literals that are matched using the identity operator. + +```php +$plan = new Schema('Hello World'); + +assert('Hello World' === $plan('Hello World')); +``` + +As any plan validator it throws an `\plan\Invalid` when fails. + +```php +$plan = new Schema(42); +$plan(42); + +try { + $plan(10); +} catch (Invalid $invalid) { + assert('[ 10 is not 42 ]' === $invalid->getMessage()); +} +``` ### Arrays @@ -72,14 +84,18 @@ A sequence will be treated as a list of possible valid values. Will require that the input data is sequence that contains one or more elements of the schema. Elements can be repeated. - $plan = new plan([1, 'one']); - $plan([1]); - $plan([1, 'one', 1, 1, 'one', 1]); +```php +$plan = new Schema([1, 'one']); +$plan([1]); +$plan([1, 'one', 1, 1, 'one', 1]); +``` An empty array will be a sequence that accept any value. - $plan = new plan([]); - $plan(['anything', 123, true]); +```php +$plan = new Schema([]); +$plan(['anything', 123, true]); +``` #### Dictionaries @@ -87,14 +103,16 @@ A dictionary will be used to validate structures. Each key in data will be checked with the _validator_ of the same key in the schema. By default, keys are not required; but any additional key will throw an exception. - $plan = new plan(array('name' => 'John', 'age' => 42)); - $plan(array('age' => 42)); - - try { - $plan(array('age' => 42, 'sex' => 'male'); - } catch (InvalidList $e) { - // Multiple invalid: ["Extra key sex not allowed"] - } +```php +$plan = new Schema(array('name' => 'John', 'age' => 42)); +$plan(array('age' => 42)); + +try { + $plan(array('age' => 42, 'sex' => 'male')); +} catch (Invalid $invalid) { + assert('{ Extra key sex not allowed }' === $invalid->getMessage()); +} +``` Validators ---------- @@ -105,14 +123,16 @@ All core _validators_ live in `\plan\assert` _namespace_. Will validate the type of data. The data type will be not casted. - $plan = new plan(assert\type('int')); - $plan(123); - - try { - $plan('123'); - } catch (InvalidList $e) { - // Multiple invalid: ["123 not int"] - } +```php +$plan = new Schema(assert\type('integer')); +$plan(123); + +try { + $plan('123'); +} catch (Invalid $invalid) { + assert('[ "123" is not integer ]' === $invalid->getMessage()); +} +``` Aliases of this _validator_ are: `bool`, `int`, `float`, `str`. @@ -128,24 +148,30 @@ Wrapper around `instanceof` type operator. See [Literals](#literals). +### `iterable` + +Given data must be an array or implement `Iterable` interface. + ### `seq` See [Sequences](#sequences). This is normally accepted as "a list of something (or something else)". -* A list of email? `new plan([assert\email()])`. +* A list of email? `new Schema([assert\email()])`. * A list of people, but some of them are in text and some as a dictionary? - $plan = new plan([assert\str(), array( - 'name' => assert\str(), - 'email' => assert\email(), - )]); - $plan([ - array('name' => 'Kevin', 'email' => 'k@viewaskew.com'), - array('name' => 'Jane', 'email' => 'jane@example.org'), - 'John Doe ', - ]); + ```php + $plan = new Schema([assert\str(), array( + 'name' => assert\str(), + 'email' => assert\email(), + )]); + $plan([ + array('name' => 'Kevin', 'email' => 'k@viewaskew.com'), + array('name' => 'Jane', 'email' => 'jane@example.org'), + 'John Doe ', + ]); + ``` ### `dict` @@ -154,48 +180,50 @@ See [Dictionaries](#dictionaries). Because, by default keys are not required and extra keys throw exceptions, the _validator_ `dict` accept two more parameters to change this behavior. - $required = true; // Will require ALL keys - $extra = true; // Accept extra keys - - $dict = array('name' => 'John', 'age' => 42); - $plan = new plan(assert\dict($dict, $required, $extra)); - $plan(array( - 'name' => 'John', - 'age' => 42, - 'sex' => 'male', // This could be whatever - // as it would not be validated - )); +```php +$dict = array('name' => 'John', 'age' => 42); + +$required = true; // Will require ALL keys +$extra = true; // Accept extra keys + +$plan = new Schema(assert\dict($dict, $required, $extra)); +$plan(array( + 'name' => 'John', + 'age' => 42, + 'sex' => 'male', // This could be whatever + // as it would not be validated +)); +``` Both parameters (`required` and `extra`) could be arrays, so only the given keys will be taken in account. - $plan = new plan(assert\dict($dict, ['age'], ['sex'])); - $plan(array('name' => 'John', 'age' => 42, 'sex' => 'male')); - - try { - $plan(array('name' => 'John', 'hobby' => 'sailing')); - } catch (InvalidList $e) { - // Multiple invalid: [ - // "Extra key hobby not allowed", - // "Required key age not provided" - // ] - } +```php +$plan = new Schema(assert\dict($dict, ['age'], ['sex'])); +$plan(array('name' => 'John', 'age' => 42, 'sex' => 'male')); + +try { + $plan(array('name' => 'John', 'hobby' => 'sailing')); +} catch (Invalid $invalid) { + assert('{ Extra key hobby not allowed, Required key age not provided }' === $invalid->getMessage()); +} +``` If the `extra` parameter is a dictionary it will be compiled and treat it as a validator for each extra key. - $extra = array('dob' => assert\instance('\\DateTime')); - - $plan = new plan(assert\dict($dict, true, $extra)); - $plan(array('name' => 'John', 'age' => 42, 'dob' => new \DateTime)); - - try { - $plan(array('name' => 'John', 'age' => 42, 'dob' => '1970-01-01')); - } catch (InvalidList $e) { - // Multiple invalid: [ - // "Extra key dob is not valid" - // ] - } +```php +$extra = array('dob' => assert\instance('\\DateTime')); + +$plan = new Schema(assert\dict($dict, true, $extra)); +$plan(array('name' => 'John', 'age' => 42, 'dob' => new \DateTime)); + +try { + $plan(array('name' => 'John', 'age' => 42, 'dob' => '1970-01-01')); +} catch (Invalid $invalid) { + assert('{ Extra key dob is not valid: Expected \DateTime (is not an object) }' === $invalid->getMessage()); +} +``` There is no way of treat all items with the same validator. Nor having a default validator for extra keys. @@ -208,12 +236,20 @@ Is also possible to validate and/or filter the list of keys of a dictionary. The structure of an object can also be validated. - $structure = array('name' => assert\str()); - $class = 'stdClass'; - $byref = true; - - $plan = new plan(assert\object($structure, $class, $byref)); - $plan((object) array('name' => 'John')); +```php +$structure = array('name' => assert\str()); +$class = 'stdClass'; +$byref = true; + +$plan = new Schema(assert\object($structure, $class, $byref)); +$plan((object) array('name' => 'John')); + +try { + $plan((object) array('name' => false)); +} catch (Invalid $invalid) { + assert('{ [name]: false is not string }' === $invalid->getMessage()); +} +``` ### `any` @@ -221,59 +257,103 @@ Accept any of the given list of _validators_, as a valid value. This is useful when you only need one choice a of set of values. If you need any quantity of choices use a [sequence](#sequence) instead. - $plan = new plan(array( - 'Connection' => assert\any('ethernet', 'wireless'), - )); - $plan(array('Connection' => 'ethernet')); - $plan(array('Connection' => 'wireless')); +```php +$plan = new Schema(array( + 'Connection' => assert\any('ethernet', 'wireless'), +)); +$plan(array('Connection' => 'ethernet')); +$plan(array('Connection' => 'wireless')); + +try { + $plan(array('Connection' => 'any')); +} catch (Invalid $invalid) { + assert('{ [Connection]: No valid value found }' === $invalid->getMessage()); +} +``` ### `all` Require all _validators_ to be valid. - $plan = new plan(assert\all(assert\str(), assert\length(3, 17))); - $plan('Hello World'); +```php +$plan = new Schema(assert\all(assert\str(), assert\length(3, 17))); +$plan('Hello World'); + +try { + $plan('No'); +} catch (Invalid $invalid) { + assert('[ Value must be at least 3 ]' === $invalid->getMessage()); +} +``` ### `not` Negative the given _validator_. - $plan = new plan(assert\not(assert\str())); - $plan(true); - $plan(123); - - try { - $plan('fail'); - } catch (InvalidList $e) { - // Multiple invalid: ["Validator passed"] - } +```php +$plan = new Schema(assert\not(assert\str())); +$plan(true); +$plan(123); + +try { + $plan('fail'); +} catch (Invalid $invalid) { + assert('[ Validator passed ]' === $invalid->getMessage()); +} +``` ### `iif` Simple conditional. - $plan = new plan(assert\iif(null !== $class, - assert\instance($class), - assert\type('object') - )); - $plan($object); +```php +$class = 'stdClass'; +$plan = new Schema(assert\iif(null !== $class, + assert\instance($class), + assert\type('object') +)); + +$plan(new stdClass); + +try { + $plan(new Exception('Arr..')); +} catch (Invalid $invalid) { + assert('[ Expected stdClass (is Exception) ]' === $invalid->getMessage()); +} +``` ### `length` The given data length is between some minimum and maximum value. This works with strings using `strlen` or `count` for everything else. - $plan = new plan(assert\length(2, 4)); - $plan('abc'); - $plan(array('a', 'b', 'c')); +```php +$plan = new Schema(assert\length(2, 4)); +$plan('abc'); +$plan(['a', 'b', 'c']); + +try { + $plan('hello'); +} catch (Invalid $invalid) { + assert('[ Value must be at most 4 ]' === $errors->getMessage()); +} +``` ### `validate` A wrapper for validate filters using `filter_var`. It accepts the name of the filter as listed [here](http://php.net/manual/en/filter.filters.validate.php). - $plan = new plan(assert\validate('validate_email')); - $plan('john@example.org'); +```php +$plan = new Schema(assert\validate('email')); +$plan('john@example.org'); + +try { + $plan('john(@)example.org'); +} catch (Invalid $invalid) { + assert('[ Expected email for "john(@)example.org" ]' === $invalid->getMessage()); +} +``` Aliases are: `url`, `email`, `ip`. @@ -286,6 +366,10 @@ modify the input data. Validates if given datetime in string can be parsed by given format. +### `match` + +Value must be a string that matches the regular expression. + Filters ------- @@ -299,8 +383,12 @@ Core _filters_ will be found in the `\plan\filter` _namespace_. Will cast the data into the given type. - $plan = new plan(filter\type('int')); - $plan('123 users'); // returns 123 +```php +$plan = new Schema(filter\type('int')); +$data = $plan('123 users'); + +assert(123 === $data); +``` Note that `boolval`, `intval`, `floatval` are not aliases of this filter but wrappers of the homonymous functions. @@ -309,8 +397,12 @@ wrappers of the homonymous functions. Sanitization [filters](http://php.net/manual/en/filter.filters.sanitize.php). - $plan = new plan(filter\sanitize('email')); - $plan('(john)@example.org'); // returns 'john@example.org' +```php +$plan = new Schema(filter\sanitize('email')); +$data = $plan('(john)@example.org'); + +assert('john@example.org' === $data); +``` Aliases are: `url`, `email`. @@ -318,8 +410,16 @@ Aliases are: `url`, `email`. Will parse a datetime formated string into a `\DateTimeImmutable` object. - $plan = new plan(filter\datetime('Y-m-d H:i:s')); - $plan('2009-02-23 23:59:59')->format('m-d'); // returns '02-23' +```php +$plan = new Schema(filter\datetime('Y-m-d H:i:s')); +$data = $plan('2009-02-23 23:59:59')->format('m-d'); + +assert('02-23' === $data); +``` + +### `template` + +Simple string template. Internationalization -------------------- @@ -332,13 +432,17 @@ of them make sure that the correct locale is set (ex. by using `setlocale`). Will keep only characters in the current language and numbers. Optionally white-space could be keeped too. - $lower = true; // all lower-case characters - $upper = true; // all upper-case characters - $number = true; // all numbers - $whitespace = true; // the only one not language dependant +```php +$lower = true; // all lower-case characters +$upper = true; // all upper-case characters +$number = true; // all numbers +$whitespace = true; // the only one not language dependant - $plan = new plan(filter\intl\chars($lower, $upper, $number, $whitespace)); - $plan('Hello World ☃!!1'); // returns 'Hello World !!1' +$plan = new Schema(filter\intl\chars($lower, $upper, $number, $whitespace)); +$data = $plan('Hello World ☃!!1'); + +assert('Hello World !!1' === $data); +``` Aliases are: `alpha`, `alnum`. @@ -348,45 +452,47 @@ Writing Validators A simple _callable_ can be a _validator_. Any validation error is thrown with the `Invalid` exception. If several errors -must be reported, `InvalidList` is an exception that could contain several +must be reported, `MultipleInvalid` is an exception that could contain multiple exceptions. All other exceptions are considerer as errors in the _validator_. - $passwordStrength = function($data, $path=null) - { - $type = assert\str(); // Use another validator to check that $data is - $data = $type($data); // an string, if not will throw an exception. +```php +$passwordStrength = function($data, $path=null) +{ + $type = assert\str(); // Use another validator to check that `$data` is + $data = $type($data); // an string, if not will throw an exception. - // Because we are going to throw more than one error, we will - // accumulate in this variable. - $errors = array(); + // Because we are going to throw more than one error, we will + // accumulate in this variable. + $errors = []; - if (strlen($data) < 8) { - $errors[] = new Invalid('Must be at least 8 characters'); - } + if (strlen($data) < 8) { + $errors[] = new Invalid('Must be at least 8 characters'); + } - if (!preg_match('/[A-Z]/', $data)) { - $errors[] = new Invalid('Must have at least one uppercase letter'); - } + if (!preg_match('/[A-Z]/', $data)) { + $errors[] = new Invalid('Must have at least one uppercase letter'); + } - if (!preg_match('/[a-z]/', $data)) { - $errors[] = new Invalid('Must have at least one lowercase letter'); - } + if (!preg_match('/[a-z]/', $data)) { + $errors[] = new Invalid('Must have at least one lowercase letter'); + } - if (!preg_match('/\d/', $data)) { - $errors[] = new Invalid('Must have at least one digit'); - } + if (!preg_match('/\d/', $data)) { + $errors[] = new Invalid('Must have at least one digit'); + } - if (count($errors) > 0) { - throw new InvalidList($errors); - } + if (count($errors) > 0) { + throw new MultipleInvalid($errors); + } - // If everything went OK, we return the data so it can continue to be - // checked by the chain. - return $data; - }; + // If everything went OK, we return the data so it can continue to be + // checked by the chain. + return $data; +}; - $validator = new plan(assert\all(assert\str(), $passwordStrength, assert\not($oldPassword))); - $validated = $validator('heLloW0rld'); +$validator = new Schema(assert\all(assert\str(), $passwordStrength, assert\not('hunter2'))); +$validated = $validator('heLloW0rld'); +``` Acknowledgments --------------- diff --git a/TODO.md b/TODO.md index 97ed650..5398385 100644 --- a/TODO.md +++ b/TODO.md @@ -13,3 +13,5 @@ TODO - [ ] `asset\one` like `assert\any` but strict only one; or add a `$count` parameter to `assert\any` to count the exact times that a validator was success. + +- [ ] `assert\set` as an unordered collection of unique elements. diff --git a/composer.json b/composer.json index 60bb517..d792511 100644 --- a/composer.json +++ b/composer.json @@ -9,16 +9,15 @@ ], "autoload": { "files": [ - "library/functions.php", + "library/plan.php", "library/exceptions.php", "library/assert.php", "library/filter.php", - "library/filter_intl.php", "library/util.php" ] }, "require": { - "php": "~7.1" + "php": ">=7.2" }, "require-dev": { "kahlan/kahlan": "@stable" diff --git a/library/assert.php b/library/assert.php index fc81aa9..390554a 100644 --- a/library/assert.php +++ b/library/assert.php @@ -1,15 +1,15 @@ - json_encode($data), - '{type}' => $type, - ); + $ctx = [ + 'data' => util\repr($data), + 'type' => $type, + ]; - throw new Invalid($tpl, $var, null, null, $path); + throw new Invalid('{data} is not {type}', $ctx, $path); } return $data; @@ -44,19 +43,19 @@ function bool() } /** - * Alias of `plan\assert\type('integer')`. + * Alias of `plan\assert\type('double')`. */ -function int() +function float() { - return assert\type('integer'); + return assert\type('double'); } /** - * Alias of `plan\assert\type('double')`. + * Alias of `plan\assert\type('integer')`. */ -function float() +function int() { - return assert\type('double'); + return assert\type('integer'); } /** @@ -78,13 +77,12 @@ function scalar() return function($data, $path = null) { if (!is_scalar($data)) { - $tpl = '{type} is not scalar'; - $var = array( - '{type}' => gettype($data), - '{data}' => json_encode($data), - ); + $ctx = [ + 'type' => gettype($data), + 'data' => util\repr($data), + ]; - throw new Invalid($tpl, $var, null, null, $path); + throw new Invalid('{type} is not scalar', $ctx, $path); } return $data; @@ -104,14 +102,13 @@ function instance($class) return function($data, $path = null) use($class) { if (!$data instanceof $class) { - $tpl = 'Expected {class} (is {data_class})'; - $var = array( - '{class}' => $class, - '{data_class}' => is_object($data) ? get_class($data) - : 'not an object', - ); - - throw new Invalid($tpl, $var, null, null, $path); + $ctx = [ + 'class' => $class, + 'object' => is_object($data) + ? get_class($data) : 'not an object', + ]; + + throw new Invalid('Expected {class} (is {object})', $ctx, $path); } return $data; @@ -119,7 +116,7 @@ function instance($class) } /** - * Compare $data with $literal using the identity operator. + * Compare `$data` with `$literal` using the identity operator. * * @param mixed $literal something to compare to * @@ -135,13 +132,34 @@ function literal($literal) $data = $type($data, $path); if ($data !== $literal) { - $tpl = '{data} is not {literal}'; - $var = array( - '{data}' => json_encode($data), - '{literal}' => json_encode($literal), - ); + $ctx = [ + 'data' => util\repr($data), + 'literal' => $literal, + ]; - throw new Invalid($tpl, $var, null, null, $path); + throw new Invalid('{data} is not {literal}', $ctx, $path); + } + + return $data; + }; +} + +/** + * Validates that `$data` is iterable. + * + * @throws Invalid + * @return Closure + */ +function iterable() +{ + return function($data, $path = null) + { + if (!is_iterable($data)) { + $ctx = [ + 'data' => util\repr($data), + ]; + + throw new Invalid('{data} is not iterable', $ctx, $path); } return $data; @@ -182,6 +200,7 @@ function seq(array $values) for ($d = 0; $d < $dl; $d++) { $found = null; + $error = null; $path = $root; $path[] = $d; @@ -193,20 +212,21 @@ function seq(array $values) break; } catch (Invalid $e) { $found = false; - if (count($e->getPath()) > count($path)) { - throw $e; + if ($e->getDepth() > count($path)) { + $error = $e; + break; } } } if ($found !== true) { - $tpl = 'Invalid value at index {index} (value is {value})'; - $var = array( - '{index}' => $d, - '{value}' => json_encode($data[$d]), - ); + $tpl = $error ? '[{index}]' : '[{index}] Invalid value'; + $ctx = [ + 'index' => $d, + 'value' => util\repr($data[$d]), + ]; - throw new Invalid($tpl, $var, null, null, $path); + throw new Invalid($tpl, $ctx, $path, 0, $error); } } @@ -222,7 +242,7 @@ function seq(array $values) * @param boolean|array $extra if accept extra keys * * @throws Invalid - * @throws InvalidList + * @throws MultipleInvalid * @return Closure */ function dict(array $structure, $required = false, $extra = false) @@ -255,10 +275,7 @@ function dict(array $structure, $required = false, $extra = false) $cextra = $extra === true ?: array(); } - $type = assert\any( - assert\type('array'), - assert\instance(Traversable::class) - ); + $type = assert\iterable(); return function($data, $path = null) use($type, $compiled, $reqkeys, $cextra) { @@ -276,20 +293,12 @@ function dict(array $structure, $required = false, $extra = false) try { $return[$dkey] = $compiled[$dkey]($dvalue, $path); } catch (Invalid $e) { - if (count($e->getPath()) > count($path)) { - // Always grab deepest exception - // It will contain the path through here - $errors[] = $e; - continue; - } - - $tpl = 'Invalid value at key {key} (value is {value})'; - $var = array( - '{key}' => $dkey, - '{value}' => json_encode($dvalue) - ); + $ctx = [ + 'key' => $dkey, + 'value' => util\repr($dvalue), + ]; - $errors[] = new Invalid($tpl, $var, null, $e, $path); + $errors[$dkey] = new Invalid('[{key}]', $ctx, $path, 0, $e); } } elseif (in_array($dkey, $reqkeys)) { $return[$dkey] = $dvalue; // no validation done @@ -299,18 +308,22 @@ function dict(array $structure, $required = false, $extra = false) $return[$dkey] = $cextra[$dkey]($dvalue, $path); } catch (Invalid $e) { $tpl = 'Extra key {key} is not valid'; - $var = array('{key}' => $dkey); + $ctx = [ + 'key' => $dkey, + ]; - $errors[] = new Invalid($tpl, $var, null, $e, $path); + $errors[$dkey] = new Invalid($tpl, $ctx, $path, 0, $e); } } else { $return[$dkey] = $dvalue; } } else { $tpl = 'Extra key {key} not allowed'; - $var = array('{key}' => $dkey); + $ctx = [ + 'key' => $dkey, + ]; - $errors[] = new Invalid($tpl, $var, null, null, $path); + $errors[$dkey] = new Invalid($tpl, $ctx, $path); } $reqkeys = array_filter($reqkeys, function($rkey) use($dkey) { @@ -323,16 +336,15 @@ function dict(array $structure, $required = false, $extra = false) $path[] = $rvalue; $tpl = 'Required key {key} not provided'; - $var = array('{key}' => $rvalue); + $ctx = [ + 'key' => $rvalue, + ]; - $errors[] = new Invalid($tpl, $var, null, null, $path); + $errors[$rvalue] = new Invalid($tpl, $ctx, $path); } if (!empty($errors)) { - if (count($errors) === 1) { - throw $errors[0]; - } - throw new InvalidList($errors); + throw new MultipleInvalid($errors, $root); } return $return; @@ -342,38 +354,33 @@ function dict(array $structure, $required = false, $extra = false) /** * Runs a validator through a list of data keys. * - * @param mixed $validator to check + * @param mixed $schema to check * * @throws Invalid * @return Closure */ -function dictkeys($validator) +function dictkeys($schema) { - $schema = compile($validator); + $type = assert\iterable(); + $validator = compile($schema); - $type = assert\any( - assert\type('array'), - assert\instance(Traversable::class) - ); - - return function($data, $path = null) use($type, $schema) + return function($data, $path = null) use($type, $validator) { $data = $type($data, $path); $keys = array_keys($data); - $keys = $schema($keys, $path); + $keys = $validator($keys, $path); $return = array(); foreach ($keys as $key) { if (!array_key_exists($key, $data)) { - $tpl = 'Value for key {key} not found in {data}'; - $var = array( - '{key}' => json_encode($key), - '{data}' => json_encode($data), - ); + $ctx = [ + 'key' => $key, + 'data' => util\repr($data), + ]; - throw new Invalid($tpl, $var, null, null, $path); + throw new Invalid('Value for key {key} not found', $ctx, $path); } $return[$key] = $data[$key]; @@ -401,10 +408,9 @@ function file() UPLOAD_ERR_EXTENSION => 'File upload failed due to a PHP extension', ); - $type = assert\dict( - array(), - array('tmp_name', 'size', 'error', 'name', 'type'), - false + $type = assert\dict([], + /* required=*/['tmp_name', 'size', 'error', 'name', 'type'], + /* extra=*/false ); return function($data, $path = null) use($type, $errors) @@ -414,9 +420,12 @@ function file() if ($data['error'] !== UPLOAD_ERR_OK) { $tpl = isset($errors[$data['error']]) ? $errors[$data['error']] : 'File {name} was not uploaded due to an unknown error'; - $var = array('{name}' => $data['name']); + $ctx = [ + 'name' => util\repr($data['name']), + 'file' => util\repr($data), + ]; - throw new Invalid($tpl, $var, null, null, $path); + throw new Invalid($tpl, $ctx, $path); } return $data; @@ -460,49 +469,48 @@ function object(array $structure, string $class = null, bool $byref = true) } /** - * Validate at least one of the given _validators_ of throw an exception. + * Validate at least one of the given alternatives of throw an exception. + * + * @param array ...$alternatives schemas to match * * @throws Invalid * @return Closure */ -function any(...$validators) +function any(...$alternatives) { $count = func_num_args(); - $schemas = []; - - for ($i = 0; $i < $count; $i++) { - $schemas[] = compile($validators[$i]); - } + $schemas = array_map('plan\compile', $alternatives); return function($data, $path = null) use($schemas, $count) { + $error = null; + $depth = $path ? count($path) : 0; + for ($i = 0; $i < $count; $i++) { try { return $schemas[$i]($data, $path); - } catch (InvalidList $e) { - // ignore } catch (Invalid $e) { - // ignore + if ($error === null && $e->getDepth() > $depth) { + $error = $e; + } } } - throw new Invalid('No valid value found', null, null, null, $path); + throw new Invalid('No valid value found', null, $path, 0, $error); }; } /** - * Validate all given _validators_ or throw an exception. + * Validate all given alternatives or throw an exception. + * + * @param array ...$alternatives schemas to match * * @return Closure */ -function all(...$validators) +function all(...$alternatives) { $count = func_num_args(); - $schemas = []; - - for ($i = 0; $i < $count; $i++) { - $schemas[] = compile($validators[$i]); - } + $schemas = array_map('plan\compile', $alternatives); return function($data, $path = null) use($schemas, $count) { @@ -515,16 +523,16 @@ function all(...$validators) } /** - * Check that the given _validator_ fail or throw an exception. + * Check that the given schema fail or throw an exception. * - * @param mixed $validator to check + * @param mixed $schema to check * * @throws Invalid * @return Closure */ -function not($validator) +function not($schema) { - $schema = compile($validator); + $schema = compile($schema); return function($data, $path = null) use($schema) { @@ -536,7 +544,7 @@ function not($validator) } if ($pass) { - throw new Invalid('Validator passed', null, null, null, $path); + throw new Invalid('Validator passed', null, $path); } return $data; @@ -573,7 +581,7 @@ function iif(bool $condition, $true = null, $false = null) } /** - * The given $data length is between $min and $max value. + * The given `$data` length is between `$min` and `$max` value. * * @param integer|null $min the minimum value * @param integer|null $max the maximum value @@ -592,17 +600,21 @@ function length(int $min = null, int $max = null) } if (!is_null($min) && $count($data) < $min) { - $tpl = 'Value must be at least {limit}'; - $var = array('{limit}' => $min); + $ctx = [ + 'count' => $count($data), + 'limit' => $min, + ]; - throw new Invalid($tpl, $var, null, null, $path); + throw new Invalid('Value must be at least {limit}', $ctx, $path); } if (!is_null($max) && $count($data) > $max) { - $tpl = 'Value must be at most {limit}'; - $var = array('{limit}' => $max); + $ctx = [ + 'count' => $count($data), + 'limit' => $max, + ]; - throw new Invalid($tpl, $var, null, null, $path); + throw new Invalid('Value must be at most {limit}', $ctx, $path); } return $data; @@ -612,86 +624,127 @@ function length(int $min = null, int $max = null) /** * A wrapper for validate filters using `filter_var`. * - * @param string $name of the the filter + * @param string $name of the the filter + * @param integer $flags for filter * + * @throws LogicException * @throws Invalid * @return Closure */ -function validate(string $name) +function validate(string $name, int $flags = 0) { - $id = filter_id($name); + static $validate = ['domain', 'url', 'email', 'ip', 'mac_address']; + static $whitelist = [ + 'domain', 'url', 'email', 'ip', 'mac_address', + 'boolean', 'float', 'int', + ]; + + if (!in_array($name, $whitelist, true)) { + throw new LogicException('Filter "' . $name . '" not allowed'); + } + + if (in_array($name, $validate, true)) { + $id = filter_id('validate_' . $name); + } else { + $id = filter_id($name); + } - return function($data, $path = null) use($name, $id) + if ($name === 'email') { + $flags |= FILTER_FLAG_EMAIL_UNICODE; + } + + return function($data, $path = null) use($name, $id, $flags) { if (filter_var($data, $id) === false) { - $tpl = 'Validation {name} for {value} failed'; - $var = array( - '{name}' => $name, - '{value}' => json_encode($data), - ); + $ctx = [ + 'name' => $name, + 'data' => util\repr($data), + ]; - throw new Invalid($tpl, $var, null, null, $path); + throw new Invalid('Expected {name}', $ctx, $path); } return $data; }; } +/** + * Alias of `plan\assert\validate('url')`. + */ function url() { - return assert\validate('validate_url'); + return assert\validate('url'); } +/** + * Alias of `plan\assert\validate('email')`. + */ function email() { - return assert\validate('validate_email'); + return assert\validate('email'); } +/** + * Alias of `plan\assert\validate('ip')`. + */ function ip() { - return assert\validate('validate_ip'); + return assert\validate('ip'); } +/** + * Alias of `plan\assert\validate('boolean')`. + */ function boolval() { return assert\validate('boolean'); } -function intval() +/** + * Alias of `plan\assert\validate('float')`. + */ +function floatval() { - return assert\validate('int'); + return assert\validate('float'); } -function floatval() +/** + * Alias of `plan\assert\validate('int')`. + */ +function intval() { - return assert\validate('float'); + return assert\validate('int'); } /** - * Will validate if $data can be parsed with given $format. + * Will validate if `$data` can be parsed with given `$format`. * * @param string $format to parse the string with * @param boolean $strict if true will throw Invalid on warnings too * * @throws Invalid - * @throws InvalidList + * @throws MultipleInvalid * @return Closure */ function datetime(string $format, bool $strict = false) { return function($data, $path = null) use($format, $strict) { - // Silent the PHP Warning when a non-string is given. - $dt = @\date_parse_from_format($format, $data); + try { + // Silent the PHP Warning when a non-string is given. + $dt = @\date_parse_from_format($format, $data); + } catch (TypeError $e) { + $dt = false; + } if ($dt === false || !is_array($dt)) { - $tpl = 'Datetime format {format} for {value} failed'; - $var = array( - '{format}' => $format, - '{value}' => json_encode($data), - ); + $tpl = 'Datetime format {format} for {data} failed'; + $ctx = [ + 'format' => $format, + 'data' => util\repr($data), + ]; - throw new Invalid($tpl, $var, null, null, $path); + throw new Invalid($tpl, $ctx, $path); } if ($dt['error_count'] + ($strict ? $dt['warning_count'] : 0) > 0) { @@ -702,35 +755,21 @@ function datetime(string $format, bool $strict = false) $errors = array(); foreach ($problems as $pos => $problem) { - $tpl = 'Datetime format {format} for {value} failed' - . ' on position {pos}: {problem}'; - $var = array( - '{format}' => $format, - '{value}' => json_encode($data), - '{pos}' => $pos, - '{problem}' => $problem, - ); - - $errors[] = new Invalid($tpl, $var, null, null, $path); + $tpl = 'Datetime format {format} for {data} failed: {problem}'; + $ctx = [ + 'problem' => $problem, + 'format' => $format, + 'data' => util\repr($data), + 'pos' => $pos, + ]; + + $errors[] = new Invalid($tpl, $ctx, $path); } if (count($errors) === 1) { throw $errors[0]; } - throw new InvalidList($errors); - } - - if ($dt['month'] !== false - && $dt['day'] !== false - && $dt['year'] !== false - && !checkdate($dt['month'], $dt['day'], $dt['year']) - ) { - $tpl = 'Date in {value} is not valid'; - $var = array( - '{value}' => json_encode($data), - ); - - throw new Invalid($tpl, $var, null, null, $path); + throw new MultipleInvalid($errors, $path); } return $data; @@ -747,16 +786,20 @@ function datetime(string $format, bool $strict = false) */ function match(string $pattern) { - return function($data, $path = null) use($pattern) + $type = assert\type('string'); + + return function($data, $path = null) use($type, $pattern) { + $data = $type($data, $path); + if (!preg_match($pattern, $data)) { - $tpl = 'Value {value} doesn\'t follow {pattern}'; - $var = array( - '{pattern}' => $pattern, - '{value}' => json_encode($data), - ); + $tpl = 'Value {data} doesn\'t follow {pattern}'; + $ctx = [ + 'pattern' => $pattern, + 'data' => util\repr($data), + ]; - throw new Invalid($tpl, $var, null, null, $path); + throw new Invalid($tpl, $ctx, $path); } return $data; diff --git a/library/exceptions.php b/library/exceptions.php index 379eb52..3268447 100644 --- a/library/exceptions.php +++ b/library/exceptions.php @@ -1,20 +1,122 @@ - + */ + protected $context; + + /** + * Path from the root to the exception. + * + * @var array + */ + protected $path; + + /** + * @param string $template template for final message + * @param array $context parameters to the template + * @param array $path list of indexes/keys inside the tree + * @param string $code error identity code + * @param Throwable $previous previous exception + */ + public function __construct( + string $template, + array $context = null, + array $path = null, + int $code = 0, + Throwable $previous = null + ) { + if (empty($context)) { + $message = $template; + } else { + $message = filter\template($template)($context, $path); + } + + if ($previous) { + $message .= ': ' . $previous->getMessage(); + } + + parent::__construct($message, $code, $previous); + + $this->template = $template; + $this->context = $context; + $this->path = $path; + } + + /** + * Retrieve the depth of the exception in the schema tree. + * + * @return int + */ + public function getDepth(): int + { + if ($this->getPath()) { + return count($this->getPath()); + } + return 0; + } + + /** + * Retrieve template. + * + * @return string + */ + public function getTemplate(): string + { + return $this->template; + } + + /** + * Retrieve template parameters. + * + * @return array + */ + public function getContext(): ?array + { + return $this->context; + } + + /** + * Retrieve the path. + * + * @return array + */ + public function getPath(): ?array + { + return $this->path; + } +} + +/** + * Collection of `Invalid` exceptions. + */ +class MultipleInvalid extends Invalid implements IteratorAggregate { /** * List of exceptions. * - * @var array + * @var array */ protected $errors; @@ -26,11 +128,17 @@ class InvalidList extends Exception implements IteratorAggregate protected $messages; /** - * @param array $errors are a list of `\plan\Invalid` exceptions - * @param Exception $previous previous exception + * @param array $errors many `Invalid` exceptions + * @param array $path list of indexes/keys inside the tree + * @param string $code error identity code + * @param Throwable $previous previous exception */ - public function __construct(array $errors, Exception $previous = null) - { + public function __construct( + array $errors, + array $path = null, + int $code = 0, + Throwable $previous = null + ) { /** * Extracts error message. * @@ -38,24 +146,63 @@ public function __construct(array $errors, Exception $previous = null) * * @return string */ - $extract = function(Invalid $error) - { + $extract = function(Invalid $error) { return $error->getMessage(); }; $this->errors = $errors; $this->messages = array_map($extract, $this->errors); - parent::__construct(implode(', ', $this->messages), null, $previous); + $ctx = [ + 'length' => count($this->errors), + 'messages' => implode(', ', $this->messages), + ]; + + if (util\is_sequence($this->errors)) { + $template = '[ {messages} ]'; + } else { + $template = '{ {messages} }'; + } + + parent::__construct($template, $ctx, $path, $code, $previous); } /** - * Retrieve a list of Invalid errors. The returning array will have one - * level deep only. + * Calculate the maximum depth between its errors. + * + * @return int + */ + public function getDepth(): int + { + $depth = parent::getDepth(); + $paths = array_filter(array_map( + /** + * Extracts error path. + * + * @param Invalid $error the exception + * + * @return array|null + */ + function(Invalid $error) { + return $error->getPath(); + }, + $this->errors + )); + + if ($paths && ($max = max(array_map('count', $paths))) > $depth) { + $depth = $max; + } + + return $depth; + } + + /** + * Retrieve a list of `Invalid` errors. The returning array will + * have one level deep only. * * @return array */ - public function getFlatErrors() + public function getFlatErrors(): array { /** * Reducer that flat the errors. @@ -65,18 +212,30 @@ public function getFlatErrors() * * @return array */ - $reduce = function(array $carry, Invalid $item) - { - if ($item instanceof InvalidList) { + $reduce = function(array $carry, Invalid $item) use(&$reduce) { + if ($item instanceof self) { $carry = array_merge($carry, $item->getFlatErrors()); } else { $carry[] = $item; } + if ($item->getPrevious()) { + $carry = $reduce($carry, $item->getPrevious()); + } return $carry; }; - return iterator_to_array(array_reduce($this->errors, $reduce, [])); + return array_reduce($this->errors, $reduce, []); + } + + /** + * Retrieve the raw list of exceptions. + * + * @var array + */ + public function getErrors() + { + return $this->errors; } /** @@ -84,7 +243,7 @@ public function getFlatErrors() * * @return array */ - public function getMessages() + public function getMessages(): array { return $this->messages; } @@ -98,67 +257,3 @@ public function getIterator() return new ArrayIterator($this->errors); } } - -/** - * Base exception for errors thrown during assertion. - */ -class Invalid extends Exception -{ - /** - * Message template. - * - * @var string - */ - protected $template; - - /** - * Parameters to message template. - * - * @var array - */ - protected $params = []; - - /** - * Path from the root to the exception. - * - * @var array - */ - protected $path = []; - - /** - * @param string $template template for final message - * @param array $params parameters to the template - * @param string $code error identity code - * @param Exception $previous previous exception - * @param array $path list of indexes/keys inside the tree - */ - public function __construct( - string $template, - array $params = null, - string $code = null, - Exception $previous = null, - array $path = null - ) { - if (!empty($params) && !util\is_sequence($params)) { - $message = strtr($template, $params); - } else { - $message = $template; - } - - parent::__construct($message, $code, $previous); - - $this->template = $template; - $this->params = is_null($params) ? [] : $params; - $this->path = is_null($path) ? [] : $path; - } - - /** - * Retrieve the path. - * - * @return array - */ - public function getPath() - { - return array_values($this->path); - } -} diff --git a/library/filter.php b/library/filter.php index 5c102e8..b1ef1c7 100644 --- a/library/filter.php +++ b/library/filter.php @@ -1,9 +1,10 @@ - json_encode($data), - '{type}' => $type, - ); + $ctx = [ + 'data' => util\repr($data), + 'type' => $type, + ]; - throw new Invalid($tpl, $var, null, null, $path); + throw new Invalid('Cannot cast {data} into {type}', $ctx, $path); } return $data; @@ -42,7 +41,7 @@ function type(string $type) * * @return Closure */ -function boolval() +function boolval(): callable { return function($data, $path = null) { @@ -57,7 +56,7 @@ function boolval() * * @return Closure */ -function intval(int $base = 10) +function intval(int $base = 10): callable { return function($data, $path = null) use($base) { @@ -70,7 +69,7 @@ function intval(int $base = 10) * * @return Closure */ -function floatval() +function floatval(): callable { return function($data, $path = null) { @@ -81,37 +80,55 @@ function floatval() /** * A wrapper for sanitize filters using `filter_var`. * - * @param string $name of the filter + * @param string $name of the filter + * @param integer $flags for filter * + * @throws LogicException * @throws Invalid * @return Closure */ -function sanitize(string $name) +function sanitize(string $name, int $flags = 0): callable { - $id = filter_id($name); + static $whitelist = ['url', 'email', 'float', 'int', 'string']; - return function($data, $path = null) use($name, $id) + if (!in_array($name, $whitelist, true)) { + throw new LogicException('Filter "' . $name . '" not allowed'); + } + + if ($name === 'float' || $name === 'int') { + $id = filter_id('number_' . $name); + } else { + $id = filter_id($name); + } + + if ($name === 'float') { + $flags |= FILTER_FLAG_ALLOW_FRACTION; + } + if ($name === 'string') { + $flags |= FILTER_FLAG_NO_ENCODE_QUOTES; + } + + return function($data, $path = null) use($name, $id, $flags) { - $newdata = filter_var($data, $id); + $new = filter_var($data, $id, array('flags' => $flags)); - if ($newdata === false) { - $tpl = 'Sanitization {name} for {value} failed'; - $var = array( - '{name}' => $name, - '{value}' => json_encode($data), - ); + if ($new === false) { + $ctx = [ + 'name' => $name, + 'data' => util\repr($data), + ]; - throw new Invalid($tpl, $var, null, null, $path); + throw new Invalid('Sanitization {name} failed', $ctx, $path); } - return $newdata; + return $new; }; } /** * Alias of `plan\filter\sanitize('url')`. */ -function url() +function url(): callable { return filter\sanitize('url'); } @@ -119,11 +136,35 @@ function url() /** * Alias of `plan\filter\sanitize('email')`. */ -function email() +function email(): callable { return filter\sanitize('email'); } +/** + * Alias of `plan\filter\sanitize('float')`. + */ +function float(): callable +{ + return filter\sanitize('float'); +} + +/** + * Alias of `plan\filter\sanitize('int')`. + */ +function int(): callable +{ + return filter\sanitize('int'); +} + +/** + * Alias of `plan\filter\sanitize('string')`. + */ +function str(): callable +{ + return filter\sanitize('string'); +} + /** * Will take an object and return an associative array from it's properties. * @@ -132,7 +173,7 @@ function email() * * @return Closure */ -function vars(bool $recursive = false, bool $inscope = true) +function vars(bool $recursive = false, bool $inscope = true): callable { $closure = function($data, $path = null) use($recursive, $inscope, &$closure) { @@ -143,32 +184,14 @@ function vars(bool $recursive = false, bool $inscope = true) if ($inscope) { $vars = get_object_vars($data); } else { - $vars = (array) $data; - - $clkey = "\0" . get_class($data) . "\0"; - $cllen = strlen($clkey); - - $replace = array(); + $orig = (array) $data; + $vars = []; - foreach ($vars as $key => $value) { - // XXX Why not this? - // $tmp = explode("\0", $key); - // $key = $tmp[count($tmp) - 1]; - if ($key[0] === "\0") { - unset($vars[$key]); + foreach ($orig as $key => $value) { + $tmp = explode("\0", $key); + $key = $tmp[count($tmp) - 1]; - if ($key[1] === '*') { - $key = substr($key, 3); - } elseif (substr($key, 0, $cllen) === $clkey) { - $key = substr($key, $cllen); - } - - $replace[$key] = $value; - } - } - - if (!empty($replace)) { - $vars = array_replace($vars, $replace); + $vars[$key] = $value; } } @@ -193,14 +216,14 @@ function vars(bool $recursive = false, bool $inscope = true) } /** - * Will parse given $format into a \DateTime object. + * Will parse given `$format` into a \DateTime object. * * @param string $format to parse the string with * @param boolean $strict if true will throw Invalid on warnings too * * @return Closure */ -function datetime(string $format, bool $strict = false) +function datetime(string $format, bool $strict = false): callable { $type = assert\datetime($format, $strict); @@ -212,3 +235,103 @@ function datetime(string $format, bool $strict = false) return $date; }; } + +/** + * Replace placeholders in given `$template` with values extracted from data. + * + * @param string $template interpolated string with keys between values + * + * @throws Invalid + * @return Closure + */ +function template(string $template): callable +{ + $plan = assert\all(assert\iterable(), function($data, $path = null) { + return array_combine( + array_map(function($k) { return "{{$k}}"; }, array_keys($data)), + array_values($data) + ); + }); + + return function($data, $path = null) use($plan, $template) + { + return strtr($template, $plan($data, $path)); + }; +} + +namespace plan\filter\intl; + +use plan\{assert, util}; + +/** + * Keep only langauge chars. + * + * @param boolean $lower keep lower case letters + * @param boolean $upper keep upper case letters + * @param boolean $number keep numbers + * @param boolean $whitespace keep whitespace + * + * @return Closure + */ +function chars( + bool $lower = true, + bool $upper = true, + bool $number = true, + bool $whitespace = false +) { + $patterns = array(); + + if ($whitespace) { + $patterns[] = '\s'; + } + + if (util\has_pcre_unicode_support()) { + if ($lower && $upper) { + $patterns[] = '\p{L}'; + } elseif ($lower) { + $patterns[] = '\p{Ll}'; + } elseif ($upper) { + $patterns[] = '\p{Lu}'; + } + if ($number) { + $patterns[] = '\p{N}'; + } + + $pattern = '/[^' . implode('', $patterns) . ']/u'; + } else { + if ($lower) { + $patterns[] = 'a-z'; + } + if ($upper) { + $patterns[] = 'A-Z'; + } + if ($number) { + $patterns[] = '0-9'; + } + + $pattern = '/[^' . implode('', $patterns) . ']/'; + } + + $type = assert\str(); + + return function($data, $path = null) use($pattern, $type) + { + return preg_replace($pattern, '', $type($data)); + }; +} + +/** + * Alias of `filter\intl\chars(true, true, false)`. + */ +function alpha(bool $whitespace = false) +{ + return chars(true, true, false, $whitespace); +} + +/** + * Alias of `filter\intl\chars(true, true, true)`. + */ +function alnum(bool $whitespace = false) +{ + return chars(true, true, true, $whitespace); +} diff --git a/library/filter_intl.php b/library/filter_intl.php deleted file mode 100644 index 9738fcc..0000000 --- a/library/filter_intl.php +++ /dev/null @@ -1,77 +0,0 @@ -getFlatErrors(); - } catch (Invalid $e) { - $errors = [$e]; - } - - return new class($valid, $result, $errors) - { - /** - * @var boolean - */ - protected $valid; - - /** - * @var mixed - */ - protected $result; - - /** - * @var array - */ - protected $errors; - - public function __construct(bool $valid, $result, array $errors) - { - $this->valid = $valid; - $this->result = $result; - $this->errors = $errors; - } - - public function isValid() - { - return $this->valid; - } - - public function getResult() - { - if (!$this->valid) { - throw new InvalidList($this->errors); - } - - return $this->result; - } - - public function getErrors() - { - return $this->errors; - } - }; - }; -} diff --git a/library/plan.php b/library/plan.php new file mode 100644 index 0000000..1f28d4f --- /dev/null +++ b/library/plan.php @@ -0,0 +1,261 @@ +schema = $schema; + } + + /** + * Validate `$data` by feeding it to the root validator and let them know + * how to traverse the value, filter it or throw an `Invalid` if validation + * fails. + * + * @param mixed $data to validate + * + * @return mixed + */ + public function __invoke($data) + { + if ($this->dirty) { + if (is_callable($this->schema)) { + $this->compiled = $this->schema; + } else { + $this->compiled = compile($this->schema); + } + + $this->dirty = false; + } + + $compiled = $this->compiled; + + try { + return $compiled($data); + } catch (MultipleInvalid $errors) { + throw $errors; + } catch (Invalid $error) { + throw new MultipleInvalid([$error]); + } finally { + unset($compiled); + } + } + + /** + * Shows that the schema is or not compiled, and which type (if available) + * the schema is. + * + * @return string + */ + public function __toString(): string + { + if (is_callable($this->schema)) { + $type = 'compiled'; + } else { + $type = util\repr($this->schema); + } + return sprintf('', $type); + } +} + +/** + * Compile the schema depending on it's type. Will return always a callable + * or throw a LogicException otherwise. If `$schema` is already a callable will + * return it without modification. If not will wrap it around the proper + * validation function. + * + * @param mixed $schema the plan schema + * + * @throws LogicException + * @return Closure + */ +function compile($schema): callable +{ + if (is_scalar($schema)) { + return assert\literal($schema); + } elseif (is_array($schema)) { + if (empty($schema) || util\is_sequence($schema)) { + return assert\seq($schema); + } else { + return assert\dict($schema); + } + } elseif (is_callable($schema)) { + return Closure::fromCallable($schema); + } + + throw new LogicException( + sprintf('Unsupported type %s', gettype($schema)) + ); +} + +/** + * Returns a validator for the schema that when use will not thrown any invalid + * exception, nor filter the value but return true in case of passed successfuly + * or false otherwise. + * + * @param mixed $schema to validate + * + * @return Closure + */ +function validate($schema): callable +{ + /** @var Closure $schema */ + $schema = compile($schema); + + return function($data) use($schema) + { + try { + $result = $schema($data); + $valid = true; + } catch (MultipleInvalid $e) { + $valid = false; + } catch (Invalid $e) { + $valid = false; + } + + return $valid; + }; +} + +/** + * Wraps a schema into a validator that will return an object instead of the + * resulting value or throw any exception. The returning object will have the + * following methods: + * + * isValid() // will return true if validation passed, false otherwise + * getResult() // will return the result or throw an MultipleInvalid if none + * getErrors() // will return a flat list of Invalid errors, or empty array + * + * @param mixed $schema to validate + * + * @return Closure + */ +function check($schema): callable +{ + /** @var Closure $schema */ + $schema = compile($schema); + + /** + * Creates an return an object that will contain the result and a list of + * errors thrown. + * + * @param mixed $data to validate + * + * @return object + */ + return function($data) use($schema) + { + $valid = false; + $result = null; + $errors = array(); + + try { + $result = $schema($data); + $valid = true; + } catch (MultipleInvalid $e) { + $errors = $e->getFlatErrors(); + } catch (Invalid $e) { + $errors = [$e]; + } + + return new class($valid, $result, $errors) + { + /** + * @var boolean + */ + protected $valid; + + /** + * @var mixed + */ + protected $result; + + /** + * @var array + */ + protected $errors; + + public function __construct(bool $valid, $result, array $errors) + { + $this->valid = $valid; + $this->result = $result; + $this->errors = $errors; + } + + /** + * Return true if the validation pass. False otherwise. + * + * @return boolean + */ + public function isValid(): bool + { + return $this->valid; + } + + /** + * Retrieve the filtered result if valid. Given default otherwise. + * + * @param mixed $default to return if is not valid + * + * @return mixed + */ + public function getResult($default = null) + { + if ($this->valid) { + return $this->result; + } + + return $default; + } + + /** + * Retrieve the list of `Invalid` exceptions. + * + * @return array + */ + public function getErrors() + { + return $this->errors; + } + }; + }; +} diff --git a/library/util.php b/library/util.php index 58dd59c..655e7c3 100644 --- a/library/util.php +++ b/library/util.php @@ -10,7 +10,7 @@ * * @return boolean */ -function is_sequence(array $array) +function is_sequence(array $array): bool { return !count(array_diff_key($array, array_fill(0, count($array), null))); } @@ -23,7 +23,7 @@ function is_sequence(array $array) * * @return boolean */ -function has_pcre_unicode_support() +function has_pcre_unicode_support(): bool { static $cache; @@ -31,3 +31,65 @@ function has_pcre_unicode_support() // false on error anyway. return isset($cache) ? $cache : $cache = @preg_match('/\pL/u', 'z') === 1; } + +/** + * Return a representation of the given value. + * + * @param mixed $value to represent + * + * @return string + */ +function repr($value): string +{ + static $limits = [ + 'length' => 47, + 'size' => 3, + ]; + + if (is_string($value)) { + $length = strlen($value); + $open = $close = '"'; + + if ($length > $limits['length']) { + $value = substr($value, 0, $limits['length']); + $close = '...'; + } + + return $open . $value . $close; + } + + if (is_array($value)) { + $size = count($value); + $more = ''; + + if ($size === 0) { + return '[]'; + } + + if ($size > $limits['size']) { + $value = array_slice($value, 0, $limits['size']); + $more = ', ...'; + } + + if (is_sequence($value)) { + $elements = array_map('plan\util\repr', $value); + } else { + $elements = []; + foreach ($value as $key => $value) { + $elements[] = repr($key) . ' => ' . repr($value); + } + } + + return '[' . implode(', ', $elements) . $more . ']'; + } + + if (is_object($value)) { + return sprintf('<%s>', get_class($value)); + } + + if (is_resource($value)) { + return sprintf('', get_resource_type($value)); + } + + return strtolower(var_export($value, true)); +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist deleted file mode 100644 index 18559f4..0000000 --- a/phpunit.xml.dist +++ /dev/null @@ -1,23 +0,0 @@ - - - - - ./plan_test.php - - - - - - ./plan.php - - - \ No newline at end of file diff --git a/plan_test.php b/plan_test.php deleted file mode 100644 index 185ce69..0000000 --- a/plan_test.php +++ /dev/null @@ -1,1002 +0,0 @@ -assertEquals($test1, $validator($test1)); - $this->assertEquals($test2, $validator($test2)); - } - - public function getTypeProvider() - { - return array( - array(assert\bool(), true, false), - array(assert\int(), 0, PHP_INT_MAX), - array(assert\float(), 0.0, 7.9999999999999991118), - array(assert\str(), 'hello', 'world'), - - // XXX Disabled for the moment - //array(new ArrayType(), array(), array_fill(0, 666, '666')), - //array(new ObjectType(), new stdClass(), new SplStack()), - ); - } - - /** - * @expectedException \plan\InvalidList - * @expectedExceptionMessage Multiple invalid: ["\"123\" is not integer"] - */ - public function testTypeInvalid() - { - $validator = new plan(assert\int()); - $validator('123'); - } - - /** - * @covers ::plan\assert\scalar - * @dataProvider getScalarProvider - */ - public function testScalar($test1, $test2) - { - $validator = new plan(assert\scalar()); - - $this->assertEquals($test1, $validator($test1)); - $this->assertEquals($test2, $validator($test2)); - } - - public function getScalarProvider() - { - return array( - array(true, false), - array(0, PHP_INT_MAX), - array(0.0, 7.9999999999999991118), - array('hello', 'world'), - ); - } - - /** - * @expectedException \plan\InvalidList - * @expectedExceptionMessage Multiple invalid: ["[] is not scalar"] - */ - public function testScalarInvalid() - { - $validator = new plan(assert\scalar()); - $validator(array()); - } - - /** - * @covers ::plan\assert\literal - */ - public function testLiteral() - { - $str = new plan('hello'); - $int = new plan(1234567); - - $this->assertEquals('hello', $str('hello')); - $this->assertEquals(1234567, $int(1234567)); - } - - /** - * @expectedException \plan\InvalidList - * @expectedExceptionMessage Multiple invalid: ["\"world\" is not \"hello\""] - */ - public function testLiteralInvalid() - { - $validator = new plan('hello'); - $validator('world'); - } - - /** - * @covers ::plan\assert\instance - */ - public function testInstance() - { - $object = new \stdClass(); - - $validator = new plan(assert\instance('\stdClass')); - $validated = $validator($object); - - $this->assertEquals($object, $validated); - } - - /** - * @expectedException \plan\InvalidList - * @expectedExceptionMessage Multiple invalid: ["Expected \\stdClass (is ArrayObject)"] - */ - public function testInstanceInvalid() - { - $validator = new plan(assert\instance('\stdClass')); - $validator(new \ArrayObject()); - } - - /** - * @covers ::plan\assert\seq - * @dataProvider getSequenceProvider - */ - public function testSequence($schema, $input) - { - $validator = new plan($schema); - $validated = $validator($input); - - $this->assertEquals($input, $validated); - } - - public function getSequenceProvider() - { - return array( - # Test 1: Values can be repeated by default - array(array('foo', array('a' => 'b'), assert\int()), - array('foo', 'foo', array('a' => 'b'), 123, 321)), - - # Test 2: Empty schema, allow any data - array(array(), - array('123', 123, 'abc' => 'def')), - ); - } - - /** - * @covers ::plan\assert\seq - * @expectedException \plan\InvalidList - * @expectedExceptionMessage Multiple invalid: ["Invalid value at index 0 (value is \"foobar\")"] - */ - public function testSequenceInvalid() - { - $validator = new plan(array('foo', 'bar')); - $validator(array('foobar')); - } - - public function testSequenceDeepException() - { - $validator = assert\seq(array( - assert\any(assert\validate('validate_email'), assert\str()), - array('name' => assert\str(), 'email' => assert\validate('validate_email')), - )); - - try { - $validator(array( - array('name' => 'John', 'email' => 'john@example.org'), - array('name' => 'Jane', 'email' => 'ERROR'), - 'Other Doe ', - )); - } catch (\plan\Invalid $e) { - $this->assertEquals('Invalid value at key email (value is "ERROR")', $e->getMessage()); - $this->assertEquals(array(1, 'email'), $e->getPath()); - - return; - } - - $this->fail('Exception Invalid not thrown'); - } - - /** - * @covers ::plan\assert\dict - * @dataProvider getDictionaryProvider - */ - public function testDictionary($schema, $input) - { - $validator = new plan($schema); - $validated = $validator($input); - - $this->assertEquals($input, $validated); - } - - public function getDictionaryProvider() - { - return array( - # Test 1: Keys are not required by default - array(array('key' => 'value'), - array()), - - # Test 2: Literal value - array(array('key' => 'value'), - array('key' => 'value')), - - # Test 3: Type value - array(array('key' => assert\str()), - array('key' => 'string')), - - # Test 4: Multidimensional array - array(array('key' => array('foo' => 'bar')), - array('key' => array('foo' => 'bar'))), - ); - } - - /** - * @expectedException \plan\Invalid - * @expectedExceptionMessage Required key c not provided - */ - public function testDictionaryRequired() - { - $validator = assert\dict(array('a' => 'b', 'c' => 'd'), true); - $validator(array('a' => 'b', )); - } - - /** - * @expectedException \plan\Invalid - * @expectedExceptionMessage Required key two not provided - */ - public function testDictionaryRequiredArray() - { - $dict = array('one' => '1', 'two' => '2'); - - $validator = assert\dict($dict, array('two')); - $validator(array()); - } - - public function testDictionaryRequiredNoExtra() - { - $expected = array('one' => '1', 'two' => '2'); - - $validator = assert\dict(array('one' => '1'), array('one', 'two'), false); - $validated = $validator($expected); - - $this->assertEquals($expected, $validated); - } - - /** - * @covers ::plan\assert\dict - */ - public function testDictionaryExtra() - { - $dict = array('foo' => 'foo', 'bar' => 'bar'); - - $validator = assert\dict(array('foo' => 'foo'), false, true); - $validated = $validator($dict); - - $this->assertEquals($dict, $validated); - } - - /** - * @expectedException \plan\Invalid - * @expectedExceptionMessage Extra key bar not allowed - */ - public function testDictionaryExtraInvalid() - { - $validator = assert\dict(array('foo' => 'foo'), false, false); - $validator(array('foo' => 'foo', 'bar' => 'bar')); - } - - /** - * @covers ::plan\assert\dict - */ - public function testDictionaryExtraArray() - { - $dict = array('foo' => 'foo', 'bar' => 'bar'); - - $validator = assert\dict(array('foo' => 'foo'), false, array('bar')); - $validated = $validator($dict); - - $this->assertEquals($dict, $validated); - } - - /** - * @expectedException \plan\Invalid - * @expectedExceptionMessage Extra key bar not allowed - */ - public function testDictionaryExtraArrayInvalid() - { - $validator = assert\dict(array(), false, array('foo')); - $validator(array('foo' => 'foo', 'bar' => 'bar')); - } - - /** - * @covers ::plan\assert\dict - */ - public function testDictionaryExtraSchema() - { - $dict = array('two' => '2'); - - $validator = assert\dict(array(), false, array('two' => '2')); - $validated = $validator($dict); - - $this->assertEquals($dict, $validated); - } - - /** - * @expectedException \plan\Invalid - * @expectedExceptionMessage Extra key two is not valid - */ - public function testDictionaryExtraSchemaInvalid() - { - $validator = assert\dict(array(), false, array('two' => '2')); - $validator(array('two' => '3')); - } - - public function testDictionaryDeepException() - { - $validator = assert\dict(array( - 'email' => assert\validate('validate_email'), - 'extra' => array( - 'emails' => array(assert\validate('validate_email')), - ), - )); - - try { - $validator(array( - 'email' => 'john@example.org', - 'extra' => array( - 'emails' => array('ERROR', 'mysecondemail@ymail.com') - ) - )); - } catch (\plan\Invalid $e) { - $this->assertEquals('Invalid value at index 0 (value is "ERROR")', $e->getMessage()); - $this->assertEquals(array('extra', 'emails', 0), $e->getPath()); - - return; - } - - $this->fail('Exception Invalid not thrown'); - } - - /** - * @covers ::plan\assert\file - */ - public function testFile() - { - $file = array( - 'tmp_name' => '/tmp/phpFzv1ru', - 'name' => 'avatar.png', - 'type' => 'image/png', - 'size' => 73096, - 'error' => 0, - ); - - $validator = assert\file(); - $validated = $validator($file); - - $this->assertEquals($file, $validated); - } - - /** - * @covers ::plan\assert\dictkeys - * @dataProvider getDictkeysProvider - */ - public function testDictkeys($schema, $input) - { - $validator = new plan(assert\dictkeys($schema)); - $validated = $validator($input); - - $this->assertEquals($input, $validated); - } - - public function getDictkeysProvider() - { - return array( - # Keys should always be an array - array(assert\type('array'), array()), - - # Length is the same as the dict - array(assert\length(1, 1), array('key' => 'value')), - ); - } - - /** - * @covers ::plan\assert\dictkeys - */ - public function testDictkeysFiltered() - { - $validator = new plan(assert\dictkeys(function($data, $root=null) - { - \PHPUnit_Framework_Assert::assertEquals(array('name', 'age'), $data); - \PHPUnit_Framework_Assert::assertNull($root); - - return array('name'); - })); - - $expected = array('name' => 'John'); - $validated = $validator(array('name' => 'John', 'age' => 42)); - - $this->assertEquals($expected, $validated); - } - - /** - * @expectedException \plan\Invalid - * @expectedExceptionMessage Value for key "age" not found in {"name":"John"} - */ - public function testDictkeysInvalid() - { - $validator = assert\dictkeys(function($data, $root=null) - { - return array('name', 'age'); - }); - - $validator(array('name' => 'John')); - } - - /** - * @covers ::plan\assert\object - */ - public function testObject() - { - $structure = array('name' => 'John', 'age' => assert\int(), 'email' => filter\sanitize('email')); - $validator = new plan(assert\object($structure, 'stdClass', true)); - - $expect = (object) array('name' => 'John', 'age' => 42, 'email' => 'john@example.org'); - $object = (object) array('name' => 'John', 'age' => 42, 'email' => '(john)@example¶.org'); - $result = $validator($object); - - $this->assertSame($object, $result); - $this->assertEquals($expect, $result); - $this->assertEquals($expect, $object); - } - - /** - * @covers ::plan\assert\object - */ - public function testObjectNew() - { - $validator = new plan(assert\object(array('email' => filter\sanitize('email')), 'stdClass', false)); - - $expect = (object) array('email' => 'john@example.org'); - $object = (object) array('email' => '(john)@example¶.org'); - $result = $validator($object); - - $this->assertEquals($expect, $result); - $this->assertNotEquals($expect, $object); - $this->assertNotSame($object, $result); - } - - /** - * @expectedException \plan\InvalidList - * @expectedExceptionMessage Multiple invalid: ["Invalid value at key age (value is \"21 years old\")"] - */ - public function testObjectInvalid() - { - $validator = new plan(assert\object(array('age' => 42))); - $validator((object) array('name' => 'John', 'age' => '21 years old')); - } - - /** - * @expectedException \plan\InvalidList - * @expectedExceptionMessage Multiple invalid: ["{\"age\":42} is not object"] - */ - public function testObjectInvalidTypeCloned() - { - $validator = new plan(assert\object(array('age' => 42), null, false)); - $validator(array('age' => 42)); - } - - /** - * @covers ::plan\assert\any - */ - public function testAny() - { - $validator = new plan(assert\any('true', 'false', assert\bool())); - - $this->assertEquals('true', $validator('true')); - $this->assertEquals('false', $validator('false')); - $this->assertEquals(true, $validator(true)); - $this->assertEquals(false, $validator(false)); - } - - /** - * @expectedException \plan\InvalidList - * @expectedExceptionMessage Multiple invalid: ["No valid value found"] - */ - public function testAnyInvalid() - { - $validator = new plan(assert\any('true', 'false', assert\bool())); - $validator(array('true')); - } - - /** - * @covers ::plan\assert\all - */ - public function testAll() - { - $validator = new plan(assert\all(assert\str(), 'string')); - $validated = $validator('string'); - - $this->assertEquals('string', $validated); - } - - /** - * @covers ::plan\assert\all - */ - public function testAllShouldPassPath() - { - $validator = new plan(assert\dict(array( - 'foo' => assert\all(function($data, $path=null) - { - \PHPUnit_Framework_Assert::assertEquals('bar', $data); - \PHPUnit_Framework_Assert::assertEquals(array('foo'), $path); - - return $data; - }), - ))); - - $validator(array('foo' => 'bar')); - } - - /** - * @covers ::plan\assert\not - * @dataProvider getNotProvider - */ - public function testNot($schema, $input) - { - $validator = new plan(assert\not($schema)); - $validated = $validator($input); - - $this->assertEquals($input, $validated); - } - - public function getNotProvider() - { - return array( - array(123, '123'), - array(assert\str(), 123), - array(assert\length(2, 4), array('a')), - array(assert\any(assert\str(), assert\bool()), array()), - array(assert\all(assert\str(), assert\length(2, 4)), array('a', 'b', 'c')), - array(array(1, '1'), array(1, '1', 2, '2')), - ); - } - - /** - * @covers ::plan\assert\not - * @dataProvider getNotInvalidProvider - * @expectedException \plan\InvalidList - * @expectedExceptionMessage Multiple invalid: ["Validator passed"] - */ - public function testNotInvalid($schema, $input) - { - $validator = new plan(assert\not($schema)); - $validator($input); - } - - public function getNotInvalidProvider() - { - return array( - array(123, 123), - array(assert\str(), 'string'), - array(assert\length(2, 4), array('a', 'b', 'c')), - array(assert\any(assert\str(), assert\bool()), true), - array(assert\all(assert\str(), assert\length(2, 4)), 'abc'), - array(array(1, '1'), array(1, '1', 1, '1')), - ); - } - - /** - * @covers ::plan\assert\iif - * @dataProvider getIifProvider - */ - public function testIif($input, $condition, $true, $false) - { - $validator = new plan(assert\iif($condition, $true, $false)); - $validated = $validator($input); - - $this->assertEquals($input, $validated); - } - - public function getIifProvider() - { - return array( - array(12345, true, assert\int(), assert\str()), - array('HI', false, assert\int(), assert\str()), - ); - } - - /** - * @covers ::plan\assert\length - * @dataProvider getLengthProvider - */ - public function testLength($input) - { - $validator = new plan(assert\length(2, 4)); - $validated = $validator($input); - - $this->assertEquals($input, $validated); - } - - public function getLengthProvider() - { - return array( - array('abc'), - array(array('a', 'b', 'c')), - ); - } - - /** - * @expectedException \plan\InvalidList - * @expectedExceptionMessage Multiple invalid: ["Value must be at least 40"] - */ - public function testLengthInvalidMin() - { - $validator = new plan(assert\length(40)); - $validator('Hello World'); - } - - /** - * @expectedException \plan\InvalidList - * @expectedExceptionMessage Multiple invalid: ["Value must be at most 4"] - */ - public function testLengthInvalidMax() - { - $validator = new plan(assert\length(2, 4)); - $validator('Hello World'); - } - - /** - * @covers ::plan\assert\validate - * @dataProvider getValidateProvider - */ - public function testValidate($filter, $test) - { - $validator = new plan(assert\validate($filter)); - $validated = $validator($test); - - $this->assertEquals($test, $validated); - } - - public function getValidateProvider() - { - return array( - array('int', '1234567'), - array('boolean', 'true'), - array('float', '7.9999999999999991118'), - array('validate_url', 'http://www.example.org/'), - array('validate_email', 'john@example.org'), - array('validate_ip', '10.0.2.42'), - ); - } - - /** - * @expectedException \plan\InvalidList - * @expectedExceptionMessage Multiple invalid: ["Validation validate_email for 123 failed"] - */ - public function testValidateInvalid() - { - $validator = new plan(assert\validate('validate_email')); - $validator(123); - } - - /** - * @covers ::plan\assert\match - * @dataProvider getMatchProvider - */ - public function getMatch($pattern, $test) - { - $validator = new plan(assert\match($pattern)); - $validated = $validator($test); - - $this->assertEquals($test, $validated); - } - - public function getMatchProvider() - { - return array( - array('/[a-z]/', 'a'), - array('/[0-9]/', '0'), - ); - } - - /** - * @expectedException \plan\InvalidList - * @expectedExceptionMessage Multiple invalid: ["Value 0 doesn't follow \/[a-z]\/"] - */ - public function testMatchInvalid() - { - $validator = new plan(assert\match('/[a-z]/')); - $validator(0); - } - - /** - * @covers ::plan\filter\type - * @dataProvider getTypeFilterProvider - */ - public function testTypeFilter($type, $expected, $test) - { - $validator = new plan(filter\type($type)); - $result = $validator($test); - - $this->assertEquals($expected, $result); - } - - public function getTypeFilterProvider() - { - return array( - array('bool', true, 'something true'), - array('integer', 678, '0678 people are wrong'), - array('float', 3.14, '3.14 < pi'), - array('string', '3.1415926535898', pi()), - ); - } - - /** - * @expectedException \plan\InvalidList - * @expectedExceptionMessage Multiple invalid: ["Cannot cast \"123\" into unknown type"] - */ - public function testTypeFilterInvalid() - { - $validator = new plan(filter\type('unknown type')); - $validator('123'); - } - - /** - * @covers ::plan\filter\boolval - * @dataProvider getBooleanProvider - */ - public function testBoolean($expected, $test1, $test2, $test3) - { - $validator = new plan(filter\boolval()); - - $this->assertEquals($expected, $validator($test1)); - $this->assertEquals($expected, $validator($test2)); - $this->assertEquals($expected, $validator($test3)); - } - - public function getBooleanProvider() - { - return array( - array(true, array(1), 'true', new \stdClass()), - array(false, array(), '', '0'), - ); - } - - /** - * @covers ::plan\filter\intval - * @dataProvider getIntegerProvider - */ - public function testInteger($expected, $test1, $test2, $test3) - { - $validator = new plan(filter\intval()); - - $this->assertEquals($expected, $validator($test1)); - $this->assertEquals($expected, $validator($test2)); - $this->assertEquals($expected, $validator($test3)); - } - - public function getIntegerProvider() - { - return array( - array(42, '42', '042', '42i10'), - array(34, '+34', 042, 0x22), - ); - } - - /** - * @covers ::plan\filter\floatval - * @dataProvider getFloatProvider - */ - public function testFloat($expected, $test1, $test2, $test3) - { - $validator = new plan(filter\floatval()); - - $this->assertEquals($expected, $validator($test1)); - $this->assertEquals($expected, $validator($test2)); - $this->assertEquals($expected, $validator($test3)); - } - - public function getFloatProvider() - { - return array( - array(0, 'PI = 3.14', '$ 19.332,35-', '0,76'), - array(1.999, '1.999,369', '0001.999', '1.99900000000000000000009'), - ); - } - - /** - * @covers ::plan\filter\sanitize - * @dataProvider getSanitizeProvider - */ - public function testSanitize($filter, $expected, $invalid) - { - $validator = new plan(filter\sanitize($filter)); - $validated = $validator($invalid); - - $this->assertEquals($expected, $validated); - } - - public function getSanitizeProvider() - { - return array( - array('url', 'example.org', 'example¶.org'), - array('email', 'john@example.org', '(john)@example¶.org'), - ); - } - - /** - * @expectedException \plan\InvalidList - * @expectedExceptionMessage Multiple invalid: ["Sanitization asd for \"asd\" failed"] - */ - public function testSanitizeInvalid() - { - $validator = new plan(filter\sanitize('asd')); - $validator('asd'); - } - - /** - * @covers ::plan\filter\vars - * @dataProvider getVarsProvider - */ - public function testVarsObject($recursive, $inscope, $expected, $object, $fix=null) - { - $validator = new plan(filter\vars($recursive, $inscope)); - $validated = $validator($object); - - if (\is_callable($fix)) { - $fix($validated); - } - - $this->assertEquals($expected, $validated); - } - - public function getVarsProvider() - { - $tests = array(); - - $arr = array( - 'name' => 'John', - 'age' => null, - 'dog' => array( - 'name' => 'Einstein', - ), - ); - - $obj = new \stdClass(); - $obj->name = 'John'; - $obj->age = null; - $obj->dog = new \stdClass(); - $obj->dog->name = 'Einstein'; - - $tests[] = array(true, true, $arr, $obj); - - $arr = array('message' => 'ok', 'code' => 42, 'string' => '', 'file' => __FILE__, 'line' => __LINE__ + 1, 'previous' => null); - $obj = new \Exception('ok', 42); - - $tests[] = array(false, false, $arr, $obj, function(&$array) { - // Because we cannot preview the stacktrace, - // is "fix" it by removing it from the - // result array. - unset($array['trace']); - }); - - return $tests; - } - - /** - * @covers ::plan\filter\datetime - * @dataProvider getDatetimeProvider - */ - public function testDatetime($format, $input) - { - $validator = new plan(filter\datetime($format, true)); - $validated = $validator($input); - - $this->assertInstanceof(\DateTimeImmutable::class, $validated); - $this->assertEquals($input, $validated->format($format)); - } - - public function getDatetimeProvider() - { - return array( - array('Y', '2009'), - array('Y-m', '2009-02'), - array('m/Y', '02/2009'), - array('d/m/y', '23/02/09'), - array('H', '23'), - array('H:i', '23:59'), - array('H:i:s', '23:59:59'), - array('P', '+03:00'), - array('T', 'UTC'), - ); - } - - /** - * @covers ::plan\filter\intl\chars - * @dataProvider getCharsProvider - */ - public function testChars($lower, $upper, $number, $whitespace, $input, $expected) - { - \setlocale(LC_ALL, 'en'); - - $validator = new plan(filter\intl\chars($lower, $upper, $number, $whitespace)); - $validated = $validator($input); - - $this->assertEquals($expected, $validated); - } - - public function getCharsProvider() - { - $input = 'hEl1o ☃W0rld'; - - return array( - array(true, true, true, true, $input, 'hEl1o W0rld'), - array(true, false, false, false, $input, 'hlorld'), - array(true, true, false, false, $input, 'hEloWrld'), - array(true, false, true, false, $input, 'hl1o0rld'), - array(true, false, false, true, $input, 'hlo rld'), - array(false, true, false, false, $input, 'EW'), - array(false, true, true, false, $input, 'E1W0'), - array(false, true, false, true, $input, 'E W'), - array(false, false, true, false, $input, '10'), - array(false, false, true, true, $input, '1 0'), - array(false, false, false, true, $input, ' '), - ); - } - - /** - * @expectedException \plan\InvalidList - * @expectedExceptionMessage Multiple invalid: ["Invalid value at key age (value is \"18 years old\")","Extra key sex not allowed","Required key name not provided"] - */ - public function testMultipleInvalid() - { - $validator = assert\dict(array( - 'name' => assert\str(), - 'age' => assert\int()), true); - $validator(array( - 'age' => '18 years old', - 'sex' => 'female', - )); - } - - public function testCustomValidator() - { - $passwordStrength = function($data, $path=null) - { - $type = assert\str(); - $data = $type($data); - - $errors = array(); - - if (strlen($data) < 8) { - $errors[] = new Invalid( - 'Password must be at least 8 characters' - ); - } - - if (!preg_match('/[A-Z]/', $data)) { - $errors[] = new Invalid( - 'Password must contain at least one uppercase letter' - ); - } - - if (!preg_match('/[a-z]/', $data)) { - $errors[] = new Invalid( - 'Password must contain at least one lowercase letter' - ); - } - - if (!preg_match('/\d/', $data)) { - $errors[] = new Invalid( - 'Password must contain at least one digit' - ); - } - - if (count($errors) > 0) { - throw new InvalidList($errors); - } - - return $data; - }; - - $validator = new plan($passwordStrength); - $validated = $validator('heLloW0rld'); - - try { - $validator('badpwd'); - } catch (InvalidList $e) { - $this->assertEquals( - 'Multiple invalid: ["Password must be at least 8 characters",' . - '"Password must contain at least one uppercase letter",' . - '"Password must contain at least one digit"]' - , $e->getMessage() - ); - } - } -} diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index 6de935f..0000000 --- a/psalm.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/spec/Suite/assertSpec.php b/spec/Suite/assertSpec.php deleted file mode 100644 index a407eb0..0000000 --- a/spec/Suite/assertSpec.php +++ /dev/null @@ -1,343 +0,0 @@ -toBe(true); - expect($schema(false))->toBe(false); - }); - it('validates integer with assert\\int', function() { - $schema = assert\int(); - - expect($schema(0))->toBe(0); - expect($schema(PHP_INT_MAX))->toBe(PHP_INT_MAX); - }); - it('validates float with assert\\float', function() { - $schema = assert\float(); - - expect($schema(0.0))->toBe(0.0); - expect($schema(2.33333333333333))->toBe(2.33333333333333); - }); - it('validates string with assert\\str', function() { - $schema = assert\str(); - - expect($schema('hello'))->toBe('hello'); - expect($schema('world'))->toBe('world'); - }); - it('throws Invalid on not boolean with assert\\bool', function() { - expect(function() { - $schema = assert\bool(); - $schema(123); - }) - ->toThrow(new Invalid('123 is not boolean')); - }); - it('throws Invalid on not integer with assert\\int', function() { - expect(function() { - $schema = assert\int(); - $schema(2.7); - }) - ->toThrow(new Invalid('2.7 is not integer')); - }); - it('throws Invalid on not float with assert\\float', function() { - expect(function() { - $schema = assert\float(); - $schema('hello'); - }) - ->toThrow(new Invalid('"hello" is not double')); - }); - it('throws Invalid on not string with assert\\str', function() { - expect(function() { - $schema = assert\str(); - $schema(true); - }) - ->toThrow(new Invalid('true is not string')); - }); - }); - - describe('scalar', function() { - it('validates scalar', function() { - $schema = assert\scalar(); - - expect($schema(true))->toBe(true); - expect($schema(123))->toBe(123); - expect($schema(2.7))->toBe(2.7); - expect($schema('hello'))->toBe('hello'); - }); - it('throws Invalid on not scalar data', function() { - $schema = assert\scalar(); - - expect(function() use($schema) { $schema(array()); })->toThrow(new Invalid('array is not scalar')); - expect(function() use($schema) { $schema(new stdClass()); })->toThrow(new Invalid('object is not scalar')); - }); - }); - - describe('instance', function() { - it('validates instances from interface', function() { - expect(assert\instance(Iterator::class)(new EmptyIterator))->toBeAnInstanceOf(EmptyIterator::class); - }); - it('validates instances from class', function() { - $asString = assert\instance(ArrayIterator::class); - $asInstance = assert\instance(new ArrayIterator); - - expect($asString(new RecursiveArrayIterator))->toBeAnInstanceOf(RecursiveArrayIterator::class); - expect($asInstance(new RecursiveArrayIterator))->toBeAnInstanceOf(RecursiveArrayIterator::class); - }); - it('throws Invalid on not instance of', function() { - expect(function() { - $schema = assert\instance(stdClass::class); - $schema(new Exception); - }) - ->toThrow(new Invalid('Expected stdClass (is Exception)')); - }); - }); - - describe('literal', function() { - it('validates equal value', function() { - foreach ([ - [123, 123], - ['hello', 'hello'], - [array(1, '1'), array(1, '1')], - ] as list($schema, $data)) { - expect(assert\literal($schema)($data))->toBe($data); - } - }); - it('throws Invalid on not being equal', function() { - expect(function() { - $schema = assert\literal(2); - $schema(3); - }) - ->toThrow(new Invalid('3 is not 2')); - }); - it('throws Invalid on types not being equal', function() { - expect(function() { - $schema = assert\literal('hello'); - $schema(42); - }) - ->toThrow(new Invalid('42 is not string')); - }); - }); - - describe('seq', function() { - it('does not validates if sequence validator is empty', function() { - $schema = assert\seq([]); - - expect($schema([]))->toBe([]); - expect($schema(['hello', 'world']))->toBe(['hello', 'world']); - expect($schema([true, 3.14]))->toBe([true, 3.14]); - }); - it('validates each value with the given validators', function() { - $schema = assert\seq([true, 3.14, 'hello']); - - expect($schema([true]))->toBe([true]); - expect($schema([true, true]))->toBe([true, true]); - expect($schema([3.14, 'hello', true]))->toBe([3.14, 'hello', true]); - expect($schema([3.14, true]))->toBe([3.14, true]); - }); - it('throws Invalid when no valid value found', function() { - expect(function() { - $schema = assert\seq([true, false]); - $schema(['hello']); - }) - ->toThrow(new Invalid('Invalid value at index 0 (value is "hello")')); - }); - it('throws Invalid from errors inside the sequence itself', function() { - expect(function() { - $schema = assert\seq([array('name' => assert\str())]); - $schema([['name' => 3.14]]); - })->toThrow(new Invalid('Invalid value at key name (value is 3.14)')); - }); - }); - - describe('dict', function() {}); - - describe('dictkeys', function() {}); - - describe('file', function() {}); - - describe('object', function() {}); - - describe('any', function() { - it('validates any of the given validators', function() { - $schema = assert\any('true', 'false', assert\bool()); - - expect($schema('true'))->toBe('true'); - expect($schema('false'))->toBe('false'); - expect($schema(true))->toBe(true); - expect($schema(false))->toBe(false); - }); - it('throws Invalid on no valid value found when validator throws Invalid', function() { - expect(function() { - $schema = assert\any('true', 'false', assert\bool()); - $schema(42); - }) - ->toThrow(new Invalid('No valid value found')); - }); - it('throws Invalid on no valid value found when validator throws InvalidList', function() { - expect(function() { - $schema = assert\any(function($data, $path=null) { - throw new InvalidList([ - new Invalid('Some error'), - new Invalid('Some more error') - ]); - }); - $schema(42); - }) - ->toThrow(new Invalid('No valid value found')); - }); - }); - - describe('all', function() { - it('validates using all validators', function() { - foreach ([ - [assert\all(assert\int(), 42), 42], - [assert\all(assert\str(), 'string'), 'string'], - [assert\all(assert\type('array'), assert\length(2, 4)), array('a', 'b', 'c')], - ] as list($schema, $data)) { - expect($schema($data))->toBe($data); - } - }); - }); - - describe('not', function() { - it('validates data that does not pass given validator', function() { - foreach ([ - [compile(123), '123'], - [compile(array(1, '1')), array(1, '1', 2, '2')], - [assert\str(), 123], - [assert\length(2, 4), array('a')], - [assert\any(assert\str(), assert\bool()), array()], - [assert\all(assert\str(), assert\length(2, 4)), array('a', 'b', 'c')], - ] as list($schema, $data)) { - expect(assert\not($schema)($data))->toBe($data); - } - }); - it('throws Invalid if validator passes', function() { - foreach ([ - [compile(123), 123], - [compile(array(1, '1')), array(1, '1', 1, '1')], - [assert\str(), 'string'], - [assert\length(2, 4), array('a', 'b', 'c')], - [assert\any(assert\str(), assert\bool()), true], - [assert\all(assert\str(), assert\length(2, 4)), 'abc'], - ] as list($schema, $data)) { - expect(function() use($schema, $data) { - $schema = assert\not($schema); - $schema($data); - }) - ->toThrow(new Invalid('Validator passed')); - } - }); - }); - - describe('iif', function() { - it('validates first validator in condition is true', function() { - $schema = assert\iif(true, assert\int(), assert\str()); - - expect($schema(42))->toBe(42); - expect(function() use($schema) { - $schema('hello'); - }) - ->toThrow(new Invalid('"hello" is not integer')); - }); - it('validates second validator in condition is false', function() { - $schema = assert\iif(false, assert\int(), assert\str()); - - expect($schema('hello'))->toBe('hello'); - expect(function() use($schema) { - $schema(42); - }) - ->toThrow(new Invalid('42 is not string')); - }); - it('does not validates if validator is not given', function() { - $called = false; - $schema = assert\iif(true, null, function() use(&$called) { - $called = true; - }); - - expect($schema('hello'))->toBe('hello'); - expect($called)->toBe(false); - }); - }); - - describe('length', function() { - it('validates strings and arrays', function() { - $schema = assert\length(2, 4); - foreach (['abc', ['a', 'b', 'c']] as $data) { - expect($schema($data))->toBe($data); - } - }); - it('throws Invalid when min is not reached', function() { - expect(function() { - $schema = assert\length(23); - $schema('hello'); - }) - ->toThrow(new Invalid('Value must be at least 23')); - }); - it('throws Invalid when max has been passed', function() { - expect(function() { - $schema = assert\length(0, 1); - $schema('hello'); - }) - ->toThrow(new Invalid('Value must be at most 1')); - }); - }); - - describe('validate', function() { - it('validates by filter name', function() { - foreach ([ - ['int', '1234567'], - ['boolean', 'true'], - ['float', '7.9999999999999991118'], - ['validate_url', 'http://www.example.org/'], - ['validate_email', 'john@example.org'], - ['validate_ip', '10.0.2.42'], - ] as list($filter, $data)) { - expect(assert\validate($filter)($data))->toBe($data); - } - }); - it('validates using alias validators', function() { - foreach ([ - [assert\intval(), '1234567'], - [assert\boolval(), 'true'], - [assert\floatval(), '7.9999999999999991118'], - [assert\url(), 'http://www.example.org/'], - [assert\email(), 'john@example.org'], - [assert\ip(), '10.0.2.42'], - ] as list($schema, $data)) { - expect($schema($data))->toBe($data); - } - }); - it('throws Invalid on invalid value', function() { - expect(function() { - $schema = assert\validate('int'); - $schema('hello'); - }) - ->toThrow(new Invalid('Validation int for "hello" failed')); - }); - }); - - describe('datetime', function() {}); - - describe('match', function() { - it('validates basic /[a-z]/ /[0-9]/ regular expressions', function() { - foreach ([ - ['/[a-z]/', 'a'], - ['/[0-9]/', '0'], - ] as list($pattern, $data)) { - expect(assert\match($pattern)($data))->toBe($data); - } - }); - it('throws Invalid when regex does not match', function() { - expect(function() { - $schema = assert\match('/[a-z]/'); - $schema(0); - }) - ->toThrow(new Invalid('Value 0 doesn\'t follow /[a-z]/')); - }); - }); -}); \ No newline at end of file diff --git a/spec/assert.spec.php b/spec/assert.spec.php new file mode 100644 index 0000000..2f42fa9 --- /dev/null +++ b/spec/assert.spec.php @@ -0,0 +1,700 @@ +toBe(true); + expect($schema(false))->toBe(false); + }); + it('validates integer with assert\\int', function() { + $schema = assert\int(); + + expect($schema(0))->toBe(0); + expect($schema(PHP_INT_MAX))->toBe(PHP_INT_MAX); + }); + it('validates float with assert\\float', function() { + $schema = assert\float(); + + expect($schema(0.0))->toBe(0.0); + expect($schema(2.33333333333333))->toBe(2.33333333333333); + }); + it('validates string with assert\\str', function() { + $schema = assert\str(); + + expect($schema('hello'))->toBe('hello'); + expect($schema('world'))->toBe('world'); + }); + it('throws Invalid on not boolean with assert\\bool', function() { + expect(function() { + $schema = assert\bool(); + $schema(123); + }) + ->toThrow(new Invalid('123 is not boolean')); + }); + it('throws Invalid on not integer with assert\\int', function() { + expect(function() { + $schema = assert\int(); + $schema(2.7); + }) + ->toThrow(new Invalid('2.7 is not integer')); + }); + it('throws Invalid on not float with assert\\float', function() { + expect(function() { + $schema = assert\float(); + $schema('hello'); + }) + ->toThrow(new Invalid('"hello" is not double')); + }); + it('throws Invalid on not string with assert\\str', function() { + expect(function() { + $schema = assert\str(); + $schema(true); + }) + ->toThrow(new Invalid('true is not string')); + }); + }); + + describe('scalar', function() { + it('validates scalar', function() { + $schema = assert\scalar(); + + expect($schema(true))->toBe(true); + expect($schema(123))->toBe(123); + expect($schema(2.7))->toBe(2.7); + expect($schema('hello'))->toBe('hello'); + }); + it('throws Invalid on not scalar data', function() { + $schema = assert\scalar(); + + expect(function() use($schema) { $schema(array()); })->toThrow(new Invalid('array is not scalar')); + expect(function() use($schema) { $schema(new stdClass()); })->toThrow(new Invalid('object is not scalar')); + }); + }); + + describe('instance', function() { + it('validates instances from interface', function() { + expect(assert\instance(Iterator::class)(new EmptyIterator))->toBeAnInstanceOf(EmptyIterator::class); + }); + it('validates instances from class', function() { + $asString = assert\instance(ArrayIterator::class); + $asInstance = assert\instance(new ArrayIterator); + + expect($asString(new RecursiveArrayIterator))->toBeAnInstanceOf(RecursiveArrayIterator::class); + expect($asInstance(new RecursiveArrayIterator))->toBeAnInstanceOf(RecursiveArrayIterator::class); + }); + it('throws Invalid on not instance of', function() { + expect(function() { + $schema = assert\instance(stdClass::class); + $schema(new Exception); + }) + ->toThrow(new Invalid('Expected stdClass (is Exception)')); + }); + }); + + describe('literal', function() { + it('validates equal value', function() { + foreach ([ + [123, 123], + ['hello', 'hello'], + [array(1, '1'), array(1, '1')], + ] as list($schema, $data)) { + expect(assert\literal($schema)($data))->toBe($data); + } + }); + it('throws Invalid on not being equal', function() { + expect(function() { + $schema = assert\literal(2); + $schema(3); + }) + ->toThrow(new Invalid('3 is not 2')); + }); + it('throws Invalid on types not being equal', function() { + expect(function() { + $schema = assert\literal('hello'); + $schema(42); + }) + ->toThrow(new Invalid('42 is not string')); + }); + }); + + describe('iterable', function() { + it('validates arrays or objects implementing Traversable', function() { + foreach ([ + array(), + array(1, 2, 3), + array(['one' => 1]), + new ArrayObject, + new ArrayIterator, + ] as $data) { + expect(assert\iterable()($data))->toBe($data); + } + }); + it('throws Invalid on non-arrays types', function() { + foreach ([ + [true, 'true is not iterable'], + ['a', '"a" is not iterable'], + [123, '123 is not iterable'], + ] as list($data, $msg)) { + expect(function() use($data, $msg) { + $schema = assert\iterable(); + $schema($data); + }) + ->toThrow(new Invalid($msg)); + } + }); + it('throws Invalid on objects not implementing Traversable', function() { + foreach ([ + new stdClass, + new LogicException, + ] as $data) { + expect(function() use($data) { + $schema = assert\iterable(); + $schema($data); + }) + ->toThrow(new Invalid('<' . get_class($data) . '> is not iterable')); + } + }); + }); + + describe('seq', function() { + it('does not validates if sequence validator is empty', function() { + $schema = assert\seq([]); + + expect($schema([]))->toBe([]); + expect($schema(['hello', 'world']))->toBe(['hello', 'world']); + expect($schema([true, 3.14]))->toBe([true, 3.14]); + }); + it('validates each value with the given validators', function() { + $schema = assert\seq([true, 3.14, 'hello']); + + expect($schema([true]))->toBe([true]); + expect($schema([true, true]))->toBe([true, true]); + expect($schema([3.14, 'hello', true]))->toBe([3.14, 'hello', true]); + expect($schema([3.14, true]))->toBe([3.14, true]); + }); + it('throws Invalid when no valid value found', function() { + expect(function() { + $schema = assert\seq([true, false]); + $schema(['hello']); + }) + ->toThrow(new Invalid('[0] Invalid value')); + }); + it('throws Invalid from deeper errors', function() { + expect(function() { + $schema = assert\seq([array('name' => assert\str())]); + $schema([array('name' => 3.14)]); + }) + ->toThrow(new Invalid('[0]: { [name]: 3.14 is not string }')); + }); + it('throws Invalid from deeper errors before when encounter (fast)', function() { + expect(function() { + $schema = assert\seq([ + ['foo', 'bar'], + // The actual validation which can possible pass is not + // being run because a deeper error has been thrown before. + // This means depth-first and fail-fast. + 'foobar', + ]); + $schema([['foobar']]); + }) + ->toThrow(new Invalid('[0]: [0] Invalid value')); + }); + }); + + describe('dict', function() { + it('validates an associative array', function() { + $schema = assert\dict([ 'key' => assert\str() ]); + $data = [ 'key' => 'foobar' ]; + + expect($schema($data))->toBe($data); + }); + it('validates all keys in structure to be required', function() { + $schema = assert\dict([ 'key' => assert\str() ], true); + $data = [ 'key' => 'foobar' ]; + + expect($schema($data))->toBe($data); + }); + it('validates required key from associative array', function() { + $schema = assert\dict([], ['key']); + $data = [ 'key' => 'foobar' ]; + + expect($schema($data))->toBe($data); + }); + it('validates allowing extra keys', function() { + $schema = assert\dict([], false, true); + $data = [ 'key' => 'foobar' ]; + + expect($schema($data))->toBe($data); + }); + it('validates allowing extra keys by name', function() { + $schema = assert\dict([], false, ['key']); + $data = [ 'key' => 'foobar' ]; + + expect($schema($data))->toBe($data); + }); + it('validates extra keys', function() { + $schema = assert\dict([], false, [ 'key' => assert\str() ]); + $data = [ 'key' => 'foobar' ]; + + expect($schema($data))->toBe($data); + }); + it('throws MultipleInvalid when key in structure is not present', function() { + expect(function() { + $schema = assert\dict([ 'key' => assert\str() ], true); + $schema([]); + }) + ->toThrow(new MultipleInvalid([ + 'key' => new Invalid('Required key key not provided'), + ])); + }); + it('throws MultipleInvalid when given required key is not present', function() { + expect(function() { + $schema = assert\dict([], ['key']); + $schema([]); + }) + ->toThrow(new MultipleInvalid([ + 'key' => new Invalid('Required key key not provided'), + ])); + }); + it('throws MultipleInvalid when extra validator fails', function() { + expect(function() { + $schema = assert\dict([], [], ['yek' => assert\str()]); + $schema(['yek' => 123]); + }) + ->toThrow(new MultipleInvalid([ + 'yek' => new Invalid('Extra key yek is not valid: 123 is not string'), + ])); + }); + it('throws MultipleInvalid when extra key is not allowed', function() { + expect(function() { + $schema = assert\dict([], [], []); + $schema(['key' => 'foobar']); + }) + ->toThrow(new MultipleInvalid([ + 'key' => new Invalid('Extra key key not allowed'), + ])); + }); + it('throws MultipleInvalid when Invalid is thrown from deeper', function() { + expect(function() { + $schema = assert\dict([ + 'foo' => ['bar', 'baz'], + ]); + $schema([ + 'foo' => ['foo'], + ]); + }) + ->toThrow(new MultipleInvalid([ + 'foo' => new Invalid('[foo]', null, null, 0, new Invalid('[0] Invalid value')), + ])); + }); + it('throws MultipleInvalid when MultipleInvalid is thrown from deeper', function() { + expect(function() { + $schema = assert\dict([ + 'foo' => assert\dict([ + 'bar' => 'foobar', + ]), + ]); + $schema([ + 'foo' => [ + 'bar' => 'foobaz', + ], + ]); + }) + ->toThrow(new MultipleInvalid([ + 'foo' => new Invalid('[foo]', null, null, 0, new MultipleInvalid([ + 'bar' => new Invalid('[bar]', null, null, 0, new Invalid('"foobaz" is not foobar')), + ])), + ])); + }); + it('throws MultipleInvalid when many errors are thrown', function() { + expect(function() { + $schema = assert\dict([ + 'bar' => 'foobar', + 'baz' => 'foobaz', + ]); + $schema([ + 'bar' => 'foobaz', + 'baz' => 'foobar', + ]); + }) + ->toThrow(new MultipleInvalid([ + 'bar' => new Invalid('[bar]', null, null, 0, new Invalid('"foobaz" is not foobar')), + 'baz' => new Invalid('[baz]', null, null, 0, new Invalid('"foobar" is not foobaz')), + ])); + }); + }); + + describe('dictkeys', function() { + it('validates keys of an associative array', function() { + $expected = ['zero', 'one', 'two']; + + $schema = assert\dictkeys(function(array $keys) use($expected) { + expect($keys)->toBe($expected); + return $keys; + }); + + $expected = array_combine($expected, [0, 1, 2]); + $result = $schema($expected); + + expect($result)->toBe($expected); + }); + it('returns new associative array with returned keys', function() { + $schema = assert\dictkeys(function(array $keys) { + return ['two']; + }); + + $result = $schema([ + 'one' => 1, + 'two' => 2, + ]); + + expect($result)->toBe([ 'two' => 2 ]); + }); + it('throws Invalid when new keys are returned', function() { + expect(function() { + $schema = assert\dictkeys(function(array $keys) { + $keys[] = 'new'; + return $keys; + }); + + $schema(['old' => 'foo']); + }) + ->toThrow(new Invalid('Value for key new not found')); + }); + }); + + describe('file', function() { + it('validates file structure', function() { + $file = [ + 'tmp_name' => '/tmp/phpUxcOty', + 'name' => 'avatar.png', + 'type' => 'image/png', + 'size' => 73096, + 'error' => 0, + ]; + + $schema = assert\file(); + $result = $schema($file); + + expect($result)->toBe($file); + }); + it('throws Invalid on error', function() { + foreach([ + [UPLOAD_ERR_INI_SIZE, 'File "avatar.png" exceeds upload limit'], + [UPLOAD_ERR_FORM_SIZE, 'File "avatar.png" exceeds upload limit in form'], + [UPLOAD_ERR_PARTIAL, 'File "avatar.png" was only partially uploaded'], + [UPLOAD_ERR_NO_FILE, 'No file was uploaded'], + [UPLOAD_ERR_CANT_WRITE, 'File "avatar.png" could not be written on disk'], + [UPLOAD_ERR_NO_TMP_DIR, 'Missing temporary directory'], + [UPLOAD_ERR_EXTENSION, 'File upload failed due to a PHP extension'], + ] as list($err, $msg)) { + expect(function() use($err) { + $schema = assert\file(); + $schema([ + 'tmp_name' => '/tmp/phpUxcOty', + 'name' => 'avatar.png', + 'type' => 'image/png', + 'size' => 0, + 'error' => $err, + ]); + }) + ->toThrow(new Invalid($msg)); + } + }); + }); + + describe('object', function() { + it('validates public properties of an object', function() { + $object = new \stdClass; + $object->name = 'John'; + + $schema = assert\object([ 'name' => assert\str() ]); + $result = $schema($object); + + expect($result)->toBe($object); + }); + it('validates class type', function() { + $object = new \stdClass; + $schema = assert\object([], 'stdClass'); + $result = $schema($object); + + expect($result)->toBe($object); + }); + it('assigns new values of public properties to original object if filtered', function() { + $object = new \stdClass; + $object->name = 'J0hn'; + + $expected = new \stdClass; + $expected->name = 'John'; + + $schema = assert\object([ 'name' => function($data, $path=null) { return 'John'; } ]); + $result = $schema($object); + + expect($result)->toBeAnInstanceOf(stdClass::class); + expect($result)->toEqual($expected); + + expect($object->name)->toBe('John'); + }); + it('returns same object without modifications made by filters when $byref=false', function() { + $object = new \stdClass; + $object->name = 'J0hn'; + + $structure = [ + 'name' => function($data, $path=null) { + return 'John'; + }, + ]; + + $schema = assert\object($structure, null, false); + $result = $schema($object); + + expect($object->name)->toBe('J0hn'); + }); + }); + + describe('any', function() { + it('validates any of the given validators', function() { + $schema = assert\any('true', 'false', assert\bool()); + + expect($schema('true'))->toBe('true'); + expect($schema('false'))->toBe('false'); + expect($schema(true))->toBe(true); + expect($schema(false))->toBe(false); + }); + it('throws Invalid on no valid value found when validator throws Invalid', function() { + expect(function() { + $schema = assert\any('true', 'false', assert\bool()); + $schema(42); + }) + ->toThrow(new Invalid('No valid value found')); + }); + it('throws Invalid on no valid value found when validator throws MultipleInvalid', function() { + expect(function() { + $schema = assert\any(function($data, $path=null) { + throw new MultipleInvalid([ + new Invalid('Some error'), + new Invalid('Some more error') + ]); + }); + $schema(42); + }) + ->toThrow(new Invalid('No valid value found')); + }); + it('throws Invalid with the first deep exception as previous', function() { + expect(function() { + $schema = assert\any( + array('type' => 'A', 'a-value' => assert\str()), + array('type' => 'B', 'b-value' => assert\int()), + array('type' => 'C', 'c-value' => assert\bool()), + ); + $schema(array('type' => 'C', 'c-value' => null)); + }) + ->toThrow(new Invalid('No valid value found', null, null, 0, + new Invalid('{ [type]: "C" is not A, Extra key c-value not allowed }') + )); + }); + }); + + describe('all', function() { + it('validates using all validators', function() { + foreach ([ + [assert\all(assert\int(), 42), 42], + [assert\all(assert\str(), 'string'), 'string'], + [assert\all(assert\type('array'), assert\length(2, 4)), array('a', 'b', 'c')], + ] as list($schema, $data)) { + expect($schema($data))->toBe($data); + } + }); + }); + + describe('not', function() { + it('validates data that does not pass given validator', function() { + foreach ([ + [compile(123), '123'], + [compile(array(1, '1')), array(1, '1', 2, '2')], + [assert\str(), 123], + [assert\length(2, 4), array('a')], + [assert\any(assert\str(), assert\bool()), array()], + [assert\all(assert\str(), assert\length(2, 4)), array('a', 'b', 'c')], + ] as list($schema, $data)) { + expect(assert\not($schema)($data))->toBe($data); + } + }); + it('throws Invalid if validator passes', function() { + foreach ([ + [compile(123), 123], + [compile(array(1, '1')), array(1, '1', 1, '1')], + [assert\str(), 'string'], + [assert\length(2, 4), array('a', 'b', 'c')], + [assert\any(assert\str(), assert\bool()), true], + [assert\all(assert\str(), assert\length(2, 4)), 'abc'], + ] as list($schema, $data)) { + expect(function() use($schema, $data) { + $schema = assert\not($schema); + $schema($data); + }) + ->toThrow(new Invalid('Validator passed')); + } + }); + }); + + describe('iif', function() { + it('validates first validator in condition is true', function() { + $schema = assert\iif(true, assert\int(), assert\str()); + + expect($schema(42))->toBe(42); + expect(function() use($schema) { + $schema('hello'); + }) + ->toThrow(new Invalid('"hello" is not integer')); + }); + it('validates second validator in condition is false', function() { + $schema = assert\iif(false, assert\int(), assert\str()); + + expect($schema('hello'))->toBe('hello'); + expect(function() use($schema) { + $schema(42); + }) + ->toThrow(new Invalid('42 is not string')); + }); + it('does not validates if validator is not given', function() { + $called = false; + $schema = assert\iif(true, null, function() use(&$called) { + $called = true; + }); + + expect($schema('hello'))->toBe('hello'); + expect($called)->toBe(false); + }); + }); + + describe('length', function() { + it('validates strings and arrays', function() { + $schema = assert\length(2, 4); + foreach (['abc', ['a', 'b', 'c']] as $data) { + expect($schema($data))->toBe($data); + } + }); + it('throws Invalid when min is not reached', function() { + expect(function() { + $schema = assert\length(23); + $schema('hello'); + }) + ->toThrow(new Invalid('Value must be at least 23')); + }); + it('throws Invalid when max has been passed', function() { + expect(function() { + $schema = assert\length(0, 1); + $schema('hello'); + }) + ->toThrow(new Invalid('Value must be at most 1')); + }); + }); + + describe('validate', function() { + it('validates by filter name', function() { + foreach ([ + ['int', '1234567'], + ['boolean', 'true'], + ['float', '7.9999999999999991118'], + ['url', 'http://www.example.org/'], + ['email', 'john@example.org'], + ['ip', '10.0.2.42'], + ] as list($filter, $data)) { + expect(assert\validate($filter)($data))->toBe($data); + } + }); + it('validates using alias validators', function() { + foreach ([ + [assert\intval(), '1234567'], + [assert\boolval(), 'true'], + [assert\floatval(), '7.9999999999999991118'], + [assert\url(), 'http://www.example.org/'], + [assert\email(), 'john@example.org'], + [assert\ip(), '10.0.2.42'], + ] as list($schema, $data)) { + expect($schema($data))->toBe($data); + } + }); + it('throws LogicException when filter is not allowed', function() { + expect(function() { + $schema = assert\validate('callback'); + }) + ->toThrow(new LogicException('Filter "callback" not allowed')); + }); + it('throws Invalid on invalid value', function() { + expect(function() { + $schema = assert\validate('int'); + $schema('hello'); + }) + ->toThrow(new Invalid('Expected int')); + }); + }); + + describe('datetime', function() { + it('validates date-like input with a format', function() { + foreach ([ + ['U', '1292177455'], + ['Y-m-d H:i:s', '1987-11-10 06:42:02'], + ] as list($format, $data)) { + expect(assert\datetime($format)($data))->toBe($data); + } + }); + it('throws Invalid on non-string data', function() { + expect(function() { + $schema = assert\datetime('U'); + $schema(new \stdClass); + }) + ->toThrow(new Invalid('Datetime format U for failed')); + }); + it('throws Invalid on error', function() { + expect(function() { + $schema = assert\datetime('Y-m-d', true); + var_dump($schema('1970-13-32')); + }) + ->toThrow(new Invalid('Datetime format Y-m-d for "1970-13-32" failed: The parsed date was invalid')); + }); + it('throws Invalid on warning when in strict-mode', function() { + expect(function() { + $schema = assert\datetime('j F Y G:i a', true); + var_dump($schema('10 October 2018 19:30 pm')); + }) + ->toThrow(new Invalid('Datetime format j F Y G:i a for "10 October 2018 19:30 pm" failed: The parsed time was invalid')); + }); + it('throws MultipleInvalid on multiple errors/warnings', function() { + expect(function() { + $schema = assert\datetime('Y-m-d H:i:s', true); + $schema('23:61:61'); + }) + ->toThrow(new MultipleInvalid([ + new Invalid('Datetime format Y-m-d H:i:s for "23:61:61" failed: Unexpected data found.'), + new Invalid('Datetime format Y-m-d H:i:s for "23:61:61" failed: Unexpected data found.'), + new Invalid('Datetime format Y-m-d H:i:s for "23:61:61" failed: Data missing'), + new Invalid('Datetime format Y-m-d H:i:s for "23:61:61" failed: The parsed date was invalid'), + ])); + }); + }); + + describe('match', function() { + it('validates basic /[a-z]/ /[0-9]/ regular expressions', function() { + foreach ([ + ['/[a-z]/', 'a'], + ['/[0-9]/', '0'], + ] as list($pattern, $data)) { + expect(assert\match($pattern)($data))->toBe($data); + } + }); + it('throws Invalid when regex does not match', function() { + expect(function() { + $schema = assert\match('/[a-z]/'); + $schema('765'); + }) + ->toThrow(new Invalid('Value "765" doesn\'t follow /[a-z]/')); + }); + }); +}); \ No newline at end of file diff --git a/spec/exceptions.spec.php b/spec/exceptions.spec.php new file mode 100644 index 0000000..590c346 --- /dev/null +++ b/spec/exceptions.spec.php @@ -0,0 +1,161 @@ +getMessage())->toBe($message); + }); + it('accepts template and context', function() { + $template = 'This is {subject}'; + $context = array( + 'subject' => 'the message', + ); + + $invalid = new Invalid($template, $context); + + expect($invalid->getTemplate())->toBe($template); + expect($invalid->getContext())->toBe($context); + expect($invalid->getMessage())->toBe('This is the message'); + }); + it('accepts path', function() { + $path = [0, 'hello']; + $invalid = new Invalid('Hello', null, $path); + + expect($invalid->getPath())->toBe($path); + }); + it('accepts code', function() { + $code = 220; + $invalid = new Invalid('Not valid', null, null, $code); + + expect($invalid->getCode())->toBe(220); + }); + it('accepts previous', function() { + $previous = new Invalid('Previous'); + $invalid = new Invalid('Not valid', null, null, 0, $previous); + + expect($invalid->getPrevious())->toBe($previous); + }); + it('appends previous message to owns message', function() { + $previous = new Invalid('Previous'); + $invalid = new Invalid('Not valid', null, null, 0, $previous); + + expect($invalid->getMessage())->toBe('Not valid: Previous'); + }); + }); + + describe('getDepth', function() { + it('returns zero if there is no path', function() { + $invalid = new Invalid('Not valid'); + $depth = $invalid->getDepth(); + + expect($depth)->toBe(0); + }); + it('returns the count of path', function() { + $invalid = new Invalid('Not valid', null, [2, 'h']); + $depth = $invalid->getDepth(); + + expect($depth)->toBe(2); + }); + }); +}); + +describe('MultipleInvalid', function() { + it('is an iterator', function() { + $errors = [new Invalid('Not valid')]; + $iterator = new MultipleInvalid($errors); + + expect(iterator_to_array($iterator))->toBe($errors); + }); + + describe('__construct', function() { + it('accepts a list of errors', function() { + $errors = [new Invalid('Not valid')]; + $error = new MultipleInvalid($errors); + + expect($error->getErrors())->toBe($errors); + }); + it('assigns a list of messages', function() { + $error = new MultipleInvalid([ + new Invalid('Not valid'), + ]); + + expect($error->getMessages())->toBe(['Not valid']); + }); + it('assigns message for sequence', function() { + $error = new MultipleInvalid([ + new Invalid('Not valid'), + ]); + + expect($error->getMessage())->toBe('[ Not valid ]'); + }); + it('assigns message for dictionary', function() { + $error = new MultipleInvalid(array( + 'foobar' => new Invalid('Not valid'), + )); + + expect($error->getMessage())->toBe('{ Not valid }'); + }); + }); + + describe('getDepth', function() { + it('returns invalid path depth if children has none', function() { + $error = new MultipleInvalid( + [new Invalid('Not valid')], + [2, 'h'] + ); + + expect($error->getDepth())->toBe(2); + }); + it('returns highest invalid path depth between children', function() { + $error = new MultipleInvalid( + [ + new Invalid('Not valid', null, ['a', 0]), + new Invalid('Not valid', null, ['a', 0, 'c']), + new Invalid('Not valid', null, ['a', 'b']), + ], + [2] + ); + + expect($error->getDepth())->toBe(3); + }); + }); + + describe('getFlatErrors', function() { + it('returns a list of errors', function() { + $error = new MultipleInvalid($errors = [ + new Invalid('Not valid'), + ]); + + expect($error->getFlatErrors())->toBe($errors); + }); + it('returns a list of errors with errors inside MultipleInvalid', function() { + $error = new MultipleInvalid([ + $invalid0 = new Invalid('Not valid 0'), + new MultipleInvalid([ + $invalid1 = new Invalid('Not valid 1'), + new MultipleInvalid([ + $invalid2 = new Invalid('Not valid 2'), + ]), + ]), + ]); + + expect($error->getFlatErrors())->toBe([$invalid0, $invalid1, $invalid2]); + }); + it('returns a list of errors with errors inside previous', function() { + $error = new MultipleInvalid([ + $invalid0 = new Invalid('Not valid 0', null, null, 0, + $invalid1 = new Invalid('Not valid 1', null, null, 0, + $invalid2 = new Invalid('Not valid 2') + ) + ), + ]); + + expect($error->getFlatErrors())->toBe([$invalid0, $invalid1, $invalid2]); + }); + }); +}); diff --git a/spec/filter.spec.php b/spec/filter.spec.php new file mode 100644 index 0000000..cdc0b73 --- /dev/null +++ b/spec/filter.spec.php @@ -0,0 +1,294 @@ +toBe($expected); + } + }); + it('casts to erronous integer when over PHP_INT_MAX', function() { + $schema = filter\type('integer'); + $result = $schema(PHP_INT_MAX + 1); + + expect($result)->not->toBe(PHP_INT_MAX + 1); + }); + it('throws Invalid when cannot be transformed to type', function() { + expect(function() { + $schema = filter\type('unknown type'); + $schema(123); + }) + ->toThrow(new Invalid('Cannot cast 123 into unknown type')); + }); + }); + + describe('boolval', function() { + it('casts to true', function() { + $schema = filter\boolval(); + foreach ([ + [1], + 'true', + new \stdClass(), + ] as $data) { + expect($schema($data))->toBe(true); + } + }); + it('casts to false', function() { + $schema = filter\boolval(); + foreach ([ + [], + '', + 0, + ] as $data) { + expect($schema($data))->toBe(false); + } + }); + }); + + describe('intval', function() { + it('casts to integer', function() { + foreach ([ + [42, '42', '042', '42i10'], + [34, '+34', 042, 0x22], + ] as list($expected, $data0, $data1, $data2)) { + $schema = filter\intval(); + + expect($schema($data0))->toBe($expected); + expect($schema($data1))->toBe($expected); + expect($schema($data2))->toBe($expected); + } + }); + it('casts to integer in other than ten base', function() { + $schema = filter\intval(2); + $result = $schema('00101010'); + + expect($result)->toBe(42); + }); + }); + + describe('floatval', function() { + it('casts to float', function() { + foreach ([ + [0.0, 'PI = 3.14', '$ 19.332,35-', '0,76'], + [1.999, '1.999,369', '0001.999', '1.99900000000000000000009'], + ] as list($expected, $data0, $data1, $data2)) { + $schema = filter\floatval(); + + expect($schema($data0))->toBe($expected); + expect($schema($data1))->toBe($expected); + expect($schema($data2))->toBe($expected); + } + }); + }); + + describe('sanitize', function() { + it('returns sanitized url using filter\\url', function() { + $schema = filter\url(); + $result = $schema('example¶.org'); + + expect($result)->toBe('example.org'); + }); + it('returns sanitized email using filter\\email', function() { + $schema = filter\email(); + $result = $schema('(john)@example¶.org'); + + expect($result)->toBe('john@example.org'); + }); + it('returns sanitized float allowing dot (.) as fraction separator using filter\\float', function() { + $schema = filter\float(); + $result = $schema('$2.2'); + + expect($result)->toBe('2.2'); + }); + it('returns sanitized int using filter\\int', function() { + $schema = filter\int(); + $result = $schema('99.9'); + + expect($result)->toBe('999'); + }); + it('returns sanitized string without encoding quotes using filter\\str', function() { + $schema = filter\str(); + $result = $schema("\n1 and 'two'"); + + expect($result)->toBe("\n1 and 'two'"); + }); + it('throws LogicException when filter is not allowed', function() { + expect(function() { + $schema = filter\sanitize('callback'); + }) + ->toThrow(new LogicException('Filter "callback" not allowed')); + }); + it('throws Invalid when sanitization fails', function() { + expect(function() { + $schema = filter\sanitize('string'); + $a = $schema(new \stdClass); + }) + ->toThrow(new Invalid('Sanitization string failed')); + }); + }); + + describe('vars', function() { + it('returns associative array composed of object\'s public keys and values', function() { + $obj = new \stdClass(); + $obj->name = 'John'; + $obj->age = null; + + $dog = new \stdClass(); + $dog->name = 'Einstein'; + + $obj->dog = $dog; + + $schema = filter\vars(); + $result = $schema($obj); + + $arr = array( + 'name' => 'John', + 'age' => null, + 'dog' => $dog, + ); + + expect($result)->toBe($arr); + }); + it('returns associative array recursivly composed of object\'s public keys and values', function() { + $obj = new \stdClass(); + $obj->name = 'John'; + $obj->age = null; + $obj->dog = new \stdClass(); + $obj->dog->name = 'Einstein'; + + $schema = filter\vars(true); + $result = $schema($obj); + + $arr = array( + 'name' => 'John', + 'age' => null, + 'dog' => array( + 'name' => 'Einstein', + ), + ); + + expect($result)->toBe($arr); + }); + it('returns associative array composed of all object\'s keys and values', function() { + $obj = new class { + private $secret = 'hunter2'; + protected $seed = 1234; + public $yes = true; + }; + + $schema = filter\vars(false, false); + $result = $schema($obj); + + $arr = array( + 'secret' => 'hunter2', + 'seed' => 1234, + 'yes' => true, + ); + + expect($result)->toBe($arr); + }); + }); + + describe('datetime', function() { + it('cast to \DateTime from data matching format', function() { + foreach ([ + ['Y', '2009'], + ['Y-m', '2009-02'], + ['m/Y', '02/2009'], + ['d/m/y', '23/02/09'], + ['H', '23'], + ['H:i', '23:59'], + ['H:i:s', '23:59:59'], + ['P', '+03:00'], + ['T', 'UTC'], + ] as list($format, $data)) { + $schema = filter\datetime($format); + $result = $schema($data); + + expect($result)->toBeAnInstanceOf(DateTimeImmutable::class); + expect($result->format($format))->toBe($data); + } + }); + }); + + describe('template', function() { + it('returns string with interpolated vars by key', function() { + $schema = filter\template('foo={foo}'); + $result = $schema([ 'foo' => 'bar' ]); + + expect($result)->toBe('foo=bar'); + }); + it('throws Invalid on non-iterable data', function() { + expect(function() { + $schema = filter\template('foo'); + $result = $schema('bar'); + }) + ->toThrow(new Invalid('"bar" is not iterable')); + }); + }); + + describe('intl', function() { + describe('chars', function() { + $data = [ + [true, true, true, true, 'hEl1o W0rld'], // filter\alnum(true) + [true, false, false, false, 'hlorld'], + [true, true, false, false, 'hEloWrld'], // filter\alpha(false) + [true, false, true, false, 'hl1o0rld'], + [true, false, false, true, 'hlo rld'], + [false, true, false, false, 'EW'], + [false, true, true, false, 'E1W0'], + [false, true, false, true, 'E W'], + [false, false, true, false, '10'], + [false, false, true, true, '1 0'], + [false, false, false, true, ' '], + ]; + + it('returns a copy of the string input keeping only the wanted chars', function() use($data) { + foreach ($data as list($lower, $upper, $number, $whitespace, $expected)) { + $schema = filter\intl\chars($lower, $upper, $number, $whitespace); + $result = $schema('hEl1o ☃W0rld'); + + expect($result)->toBe($expected); + } + }); + it('returns a copy of the string input keeping only the wanted chars when unicode support is not available', function() use($data) { + allow('plan\util\has_pcre_unicode_support')->toBeCalled()->andReturn(false); + + foreach ($data as list($lower, $upper, $number, $whitespace, $expected)) { + $schema = filter\intl\chars($lower, $upper, $number, $whitespace); + $result = $schema('hEl1o ☃W0rld'); + + expect($result)->toBe($expected); + } + }); + }); + + describe('alpha', function() { + it('returns alphabetic characters', function() { + $schema = filter\intl\alpha(); + $result = $schema('☃W0rLd'); + + expect($result)->toBe('WrLd'); + }); + }); + + describe('alnum', function() { + it('returns alphanumeric and numbers only', function() { + $schema = filter\intl\alnum(); + $result = $schema('☃W0rLd'); + + expect($result)->toBe('W0rLd'); + }); + }); + }); +}); \ No newline at end of file diff --git a/spec/plan.spec.php b/spec/plan.spec.php new file mode 100644 index 0000000..f7dc1ff --- /dev/null +++ b/spec/plan.spec.php @@ -0,0 +1,191 @@ +toThrow(new LogicException('Invalid schema type')); + }); + }); + + describe('__invoke', function() { + it('returns validated data using compiled schema', function() { + $schema = new Schema(42); + $result = $schema(42); + + expect($result)->toBe(42); + }); + it('returns validated data using raw schema', function() { + $schema = new Schema(function($data, $path = null) { + if ($data !== 42) { + throw new Invalid('Not 42'); + } + return $data; + }); + + $result = $schema(42); + + expect($result)->toBe(42); + }); + it('throws MultipleInvalid as catched', function() { + expect(function() { + $schema = new Schema(function($data, $path = null) { + throw new MultipleInvalid([ + new Invalid('Not valid'), + ]); + }); + $schema(24); + }) + ->toThrow(new MultipleInvalid([ + new Invalid('Not valid'), + ])); + }); + it('throws MultipleInvalid with only when Invalid when chatched', function() { + expect(function() { + $schema = new Schema(function($data, $path = null) { + throw new Invalid('Not valid'); + }); + $schema(24); + }) + ->toThrow(new MultipleInvalid([ + new Invalid('Not valid'), + ])); + }); + }); + + describe('__toString', function() { + it('returns compiled when a callable is given', function() { + $schema = new Schema(function($data, $path = null) { + return 42; + }); + + expect($schema->__toString())->toBe(''); + }); + it('returns representation when a literal is given', function() { + expect((new Schema(42))->__toString())->toBe(''); + expect((new Schema('foobar'))->__toString())->toBe(''); + }); + }); +}); + +describe('compile', function() { + it('returns schema for literal', function() { + expect(compile(213))->toBeAnInstanceOf(Closure::class); + }); + it('returns schema for sequence', function() { + expect(compile(['foo', 123]))->toBeAnInstanceOf(Closure::class); + }); + it('returns schema for dictionary', function() { + expect(compile(array('foo' => 'bar')))->toBeAnInstanceOf(Closure::class); + }); + it('returns schema for validator', function() { + expect(compile(function($data, $path = null) { return $data; }))->toBeAnInstanceOf(Closure::class); + }); + it('throws LogicException for invalid schema', function() { + expect(function() { + compile(STDIN); + }) + ->toThrow(new LogicException('Unsupported type resource')); + }); +}); + +describe('validate', function() { + it('returns true when schema succeed', function() { + $schema = validate(123); + $result = $schema(123); + + expect($result)->toBe(true); + }); + it('returns false when schema throws MultipleInvalid', function() { + $schema = validate(function($data, $path = null) { + throw new MultipleInvalid([ + new Invalid('Not valid'), + ]); + }); + + expect($schema(123))->toBe(false); + }); + it('returns false when schema throws Invalid', function() { + $schema = validate(function($data, $path = null) { + throw new Invalid('Not valid'); + }); + + expect($schema(123))->toBe(false); + }); +}); + +describe('check', function() { + it('returns an object with isValid method that returns true when schema succeed', function() { + $schema = check(123); + $result = $schema(123); + + expect($result->isValid())->toBe(true); + }); + it('returns an object with isValid method that returns false when schema throws MultipleInvalid', function() { + $schema = check(function($data, $path = null) { + throw new MultipleInvalid([ + new Invalid('Not valid'), + ]); + }); + + $result = $schema(123); + + expect($result->isValid())->toBe(false); + }); + it('returns an object with isValid method that returns false when schema throws Invalid', function() { + $schema = check(function($data, $path = null) { + throw new Invalid('Not valid'); + }); + + $result = $schema(123); + + expect($result->isValid())->toBe(false); + }); + it('returns an object with getResult method that returns the filtered data when is valid', function() { + $schema = check(function($data, $path = null) { + return strval($data); + }); + + $result = $schema(123); + + expect($result->getResult())->toBe('123'); + }); + it('returns an object with getResult method that returns the default value when is not valid', function() { + $schema = check(function($data, $path = null) { + throw new Invalid('Not valid'); + }); + + $result = $schema(123); + + expect($result->getResult())->toBe(null); + expect($result->getResult('321'))->toBe('321'); + }); + it('returns an object with getErrors method that returns an empty list when is valid', function() { + $schema = check(function($data, $path = null) { + return strval($data); + }); + + $result = $schema(123); + + expect($result->getErrors())->toBe([]); + }); + it('returns an object with getErrors method that returns a flat list of Invalid or MultipleInvalid exceptions when is not valid', function() { + $previous = new Invalid('Not valid pre'); + $invalid0 = new Invalid('Not valid 0'); + $invalid1 = new Invalid('Not valid 1', null, [0], 0, $previous); + $invalidIterator1 = new MultipleInvalid([$invalid1]); + + $schema = check(function() use($invalid0, $invalidIterator1) { + throw new MultipleInvalid([$invalid0, $invalidIterator1]); + }); + + $result = $schema(123); + + expect($result->getErrors())->toBe([$invalid0, $invalid1, $previous]); + }); +}); diff --git a/spec/util.spec.php b/spec/util.spec.php new file mode 100644 index 0000000..6dd945d --- /dev/null +++ b/spec/util.spec.php @@ -0,0 +1,39 @@ +toBe('"foo"'); + }); + it('returns string up to 47 chars', function() { + expect( + plan\util\repr('Lorem ipsum dolor sit amet, consectetur adipiscing elit.') + ) + ->toBe('"Lorem ipsum dolor sit amet, consectetur adipisc...'); + }); + it('returns array represented with square brackets', function() { + expect(plan\util\repr([1, 2, 3]))->toBe('[1, 2, 3]'); + }); + it('returns array up to 3 elements', function() { + expect(plan\util\repr([1, 2, 3, 4, 5, 6]))->toBe('[1, 2, 3, ...]'); + }); + it('returns object class name tag', function() { + expect(plan\util\repr(new \stdClass))->toBe(''); + }); + it('returns resource type tag', function() { + expect(plan\util\repr(STDIN))->toBe(''); + }); + it('returns boolean as lower case export', function() { + expect(plan\util\repr(true))->toBe('true'); + expect(plan\util\repr(false))->toBe('false'); + }); + it('returns integer as export', function() { + expect(plan\util\repr(0))->toBe('0'); + expect(plan\util\repr(1))->toBe('1'); + }); + it('returns float as export', function() { + expect(plan\util\repr(0.0))->toBe('0.0'); + expect(plan\util\repr(1.0))->toBe('1.0'); + }); + }); +}); From c6166e1a5817fd24e1f3b66b264d4765da50a5af Mon Sep 17 00:00:00 2001 From: Juan Martinez Date: Mon, 21 Jan 2019 14:01:25 +0100 Subject: [PATCH 3/6] PHP 7.2 --- .travis.yml | 2 +- CHANGES.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 71e94f6..7e9075f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ language: php php: - - 7.1 + - 7.2 before_script: - composer install --no-interaction --prefer-dist diff --git a/CHANGES.md b/CHANGES.md index 66b04d3..f5115e6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,7 +2,7 @@ ### 3.0.0 (2019-01-21) - * Require PHP 7.x. + * Require PHP 7.2. * Use `kahlan` for testing. * Add `validate` and `check` functions. * Add `assert\datetime` and `assert\iterable`. From ded9d981626bed515cc98792a9707d2774b6724c Mon Sep 17 00:00:00 2001 From: Juan Martinez Date: Mon, 21 Jan 2019 14:05:45 +0100 Subject: [PATCH 4/6] Typo --- spec/assert.spec.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/assert.spec.php b/spec/assert.spec.php index 2f42fa9..92201f8 100644 --- a/spec/assert.spec.php +++ b/spec/assert.spec.php @@ -490,7 +490,7 @@ $schema = assert\any( array('type' => 'A', 'a-value' => assert\str()), array('type' => 'B', 'b-value' => assert\int()), - array('type' => 'C', 'c-value' => assert\bool()), + array('type' => 'C', 'c-value' => assert\bool()) ); $schema(array('type' => 'C', 'c-value' => null)); }) From 251881bb2d3e7318cce140d5e84cc4f780c82593 Mon Sep 17 00:00:00 2001 From: Juan Martinez Date: Mon, 21 Jan 2019 14:10:00 +0100 Subject: [PATCH 5/6] Coverage Bagde --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index fdd0593..fa5f3be 100644 --- a/README.md +++ b/README.md @@ -506,3 +506,4 @@ Badges [![Build Status](https://travis-ci.org/guide42/plan.svg)](https://travis-ci.org/guide42/plan) [![Total Downloads](https://poser.pugx.org/guide42/plan/downloads.svg)](https://packagist.org/packages/guide42/plan) +[![Coverage Status](https://coveralls.io/repos/github/guide42/plan/badge.svg)](https://coveralls.io/github/guide42/plan) From 33a5c3289317bd8af3945b902e3c2d9d27955b44 Mon Sep 17 00:00:00 2001 From: Juan Martinez Date: Mon, 21 Jan 2019 14:15:56 +0100 Subject: [PATCH 6/6] Use php-coveralls --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7e9075f..618cc8c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,12 +4,12 @@ php: - 7.2 before_script: + - composer require --no-interaction php-coveralls/php-coveralls - composer install --no-interaction --prefer-dist - - composer require --no-interaction satooshi/php-coveralls - mkdir -p build/logs/ script: - php vendor/bin/kahlan --cc=true --coverage=4 --clover=build/logs/clover.xml after_success: - - php vendor/bin/coveralls -v + - php vendor/bin/php-coveralls -v