vertilia / valid-array
Object with automatic items validation based on standard filtering mechanism of php
Installs: 95
Dependents: 2
Suggesters: 0
Security: 0
Stars: 0
Watchers: 1
Forks: 0
Open Issues: 0
pkg:composer/vertilia/valid-array
Requires
- php: >=7.4
- ext-filter: *
Requires (Dev)
- phpunit/phpunit: ^9
README
Data filtering mechanism based on native php-filter extension with additional capacities.
A ValidArray object receives on instantiation an associative array with elements names as keys and elements filters as
values. These filters guarantee that when corresponding elements in this object are set, they will automatically get
validated and their valid value will be stored. If an element does not pass validation, a default value may be used
basing on filter parameters. Object may be used as a normal array to set elements and get elements values, validation is
done on element modification.
ValidArray extends SPL ArrayObject and wraps the functionality of php-filter extension.
Introduction
PHP native data validation mechanism using standard php-filter extension is a great way of working with data, to
ensure user input correctness and to protect against several attack vectors. This extension is bundled with PHP and is
available by default in most configurations, which makes it a natural choice for any project requiring data validation.
As a bundled extension, php-filter benefits from documentation coverage in PHP manual,
performance gain of compiled-in module and quasi-absolute availability.
While being a de-facto standard for data validation in PHP, it still suffers from several questionable architectural decisions that were made at the early days and that may slow down the learning curve and development pace when implementing validation strategy in user land.
Some of these dubious decisions:
- ๐คจ
defaultvalue is implemented for limited number of filters, only to replace improperly formatted arguments that otherwise match defined filter flags, but not for cases when argument is not supplied or when flags mismatch is detected, - ๐ค limited
FILTER_CALLBACKfunctionality, when flags are ignored and validation callback cannot distinguish scalar data from array, - ๐ง objects may be filtered, but only if they have a magic method
__toString()defined (implementStringablesince v8.0), and only this value is kept as element value after validation.
Based on numerous strengths of php-filter extension, ValidArray class corrects (where possible) beforementioned
flaws and implements php-filter functionality into a useful and predictable data structure with the following
features:
- associative arrays with validation mechanism,
- validation is provided on array instantiation and item modification,
- filters are set at data object instantiation,
- useful for input / output parameters validation,
- minimal footprint,
- extending
ArrayObjectallows for natural array handling in user code, - use of standard
php-filterextension with enhanced handling of default values, callbacks and objects filtering.
Example
Enough talks, let's see how it works.
We shall handle a login POST request with email, password and uri parameters that need validation.
The route POST /api/login will call LoginController and pass request parameters in an array which should have 3
fields: email, password and uri. All fields should have valid data, which is defined as follows:
emailis a string with valid email address orfalseif not valid,passwordis a string with hashed version of sent password (calculated during validation) orfalseif not valid; valid password is a string of at least 8 characters,uriis a string with a path for next page to redirect after login or#memberby default.
We shall identify request parameters inside the corresponding controller, like in the following simplified code, where
our goal will be to implement withRequestVars() method:
<?php // router.php // create controller $controller = new ApiLoginController(); // validate request vars $controller->withRequestVars(); // run controller with valid parameters $controller->run();
<?php // ApiLoginController.php class ApiLoginController implements Runnable { protected array $vars = []; public function withRequestVars(): self { // TODO validate request variables $this->vars = []; // <- we need to implement this return $this; } public function run() { // TODO redirect to $uri or output error... } }
Implementing required validation with php-filter (1st attempt):
<?php // validate request variables $this->vars = filter_input_array( INPUT_POST, [ 'email' => FILTER_VALIDATE_EMAIL, 'password' => [ 'filter' => FILTER_CALLBACK, 'options' => function ($pwd) { return (strlen($pwd) >= 8) ? password_hash($pwd, PASSWORD_BCRYPT) : false; }, ], 'uri' => [ 'filter' => FILTER_DEFAULT, 'options' => ['default' => '#member'], // <- will not work ], ] ) ?: [];
If you need help with using
php-filterextension have a look at official documentation.
Now, this will partly work. Until someone discovers out login api and decides to bring our system down with several absolutely legitimate requests of the following form (newlines added for readability):
email=abc@def.com
&uri=%23member
&password[]=12345678
&password[]=12345678
&password[]=12345678
... repeat 100 times
&password[]=12345678
When php-filter extension sees an array sent, its default behavior is to validate every item in this array via the
provided filter and keep this array structure in place replacing array values with filtered values. In most cases this
may be a correct behavior (anyway that's how filter extension works), but in our case this will start calculating
password hashes for all the provided passwords, and this is a very CPU intensive task. A couple of dozens of such
requests sent in parallel is enough to put our infra in trouble.
php-filter has a very elegant solution for this situation, which is called filter flags, and our first intention will
be to set the FILTER_REQUIRE_SCALAR.
Set FILTER_REQUIRE_SCALAR flag (2nd attempt):
<?php // validate request variables $this->vars = filter_input_array( INPUT_POST, [ 'email' => [ 'filter' => FILTER_VALIDATE_EMAIL, 'flags' => FILTER_REQUIRE_SCALAR, ], 'password' => [ 'filter' => FILTER_CALLBACK, 'flags' => FILTER_REQUIRE_SCALAR, // <- will not work 'options' => function ($pwd) { return (strlen($pwd) >= 8) ? password_hash($pwd, PASSWORD_BCRYPT) : false; }, ], 'uri' => [ 'filter' => FILTER_DEFAULT, 'flags' => FILTER_REQUIRE_SCALAR, 'options' => ['default' => '#member'], // <- will not work ], ] ) ?: [];
Nothing wrong with this approach, and scalar flag will work fine for email and uri fields, but not for
the password field. Yes, FILTER_CALLBACK simply
ignores flags by design. You cannot do anything with it, so
before the validation step you'll likely want to add a verification to catch the possibility of array passed
as password argument.
Other thing, and also up to you to handle correctly, is the fact that default value provided via the default flag, is
only used when the parameter is provided but is not valid.
It is only available for FILTER_VALIDATE_* filters (not for FILTER_DEFAULT by the way), it will not be used if any
sanitize-filter shrinks parameter value down to an empty string, if parameter value mismatches defined flags, and
finally, it will not be used if parameter was not set in request.
So our final version should handle these additional conditions.
Handle array type and default value manually (3rd attempt):
<?php // validate request variables $post = $_POST; if (is_array($post['password'] ?? null)) { $post['password'] = false; } if (empty($post['uri'])) { $post['uri'] = '#member'; } $vars = filter_var_array( $post, [ 'email' => [ 'filter' => FILTER_VALIDATE_EMAIL, 'flags' => FILTER_REQUIRE_SCALAR, ], 'password' => [ 'filter' => FILTER_CALLBACK, 'options' => function ($pwd) { return (strlen($pwd) >= 8) ? password_hash($pwd, PASSWORD_BCRYPT) : false; }, ], 'uri' => [ 'filter' => FILTER_DEFAULT, 'flags' => FILTER_REQUIRE_SCALAR, ], ] ) ?: [];
This is definitely not the most beautiful piece of code and that is where ValidArray is improving the situation.
Consider the same example, but implemented using ValidArray functionality:
Implementing required validation with ValidArray:
<?php use Vertilia\ValidArray; // validate request variables $this->vars = new ValidArray( [ 'email' => [ 'filter' => FILTER_VALIDATE_EMAIL, 'flags' => FILTER_REQUIRE_SCALAR, ], 'password' => [ 'filter' => ValidArray::FILTER_EXTENDED_CALLBACK, 'flags' => FILTER_REQUIRE_SCALAR, 'options' => [ 'callback' => function ($pwd) { return (strlen($pwd) >= 8) ? password_hash($pwd, PASSWORD_BCRYPT) : false; } ], ], 'uri' => [ 'filter' => FILTER_SANITIZE_STRING, 'flags' => FILTER_REQUIRE_SCALAR, 'options' => ['default' => '#member'], ], ], $_POST );
ValidArray specificity
Notable distinctions between ValidArray and filter_*() functions:
ValidArrayis an object but allows array-style access to elements since extendingArrayObjectSPL class;- it sets
defaultvalues ornullfor missing elements for all filters; - it provides
ValidArray::FILTER_EXTENDED_CALLBACKfilter allowing to define callbacks that unlock bothflagsanddefaultfunctionality; - it provides
ValidArray::FILTER_INSTANCE_OFfilter allowing to validate values as objects of specific type and use bothflagsanddefaultfunctionality; ValidArrayalways uses$add_emptymode offilter_var_array()function and does not allow to unset elements that have filters defined;count()onValidArrayobject will always return the number of defined filters;- setting value for undefined key will be ignored;
- setting value for existing key will trigger a
filter_var()call, which may modify the value in the following ways:- change value (ex:
FILTER_SANITIZE_NUMBER_INT), - change the type of the value (ex:
FILTER_VALIDATE_INT), - convert scalar value to array (with
FILTER_FORCE_ARRAYflag), - set value to its
defaultvalue,falseornullin case of failure (attention, not all standard filters usedefaultvalue even when one is provided), - if value is an array (or becomes an array via
FILTER_FORCE_ARRAYflag), these modifications will be applied to all array elements, recursively;
- change value (ex:
- unsetting value will set it to either
defaultvalue (if defined) ornullfor all filters.
More use cases
In a specific controller we handle the following request parameters:
{
"id": {"type": "integer"},
"name": {"type": "string"},
"email": {"type": "string"},
"tel": {"type": "string"}
}
So we define the following filters:
$filters = [ 'id' => [ 'filter' => FILTER_VALIDATE_INT, 'flags' => FILTER_REQUIRE_SCALAR, ], 'name' => [ 'filter' => FILTER_SANITIZE_STRING, 'flags' => FILTER_REQUIRE_SCALAR, ], 'email' => [ 'filter' => FILTER_VALIDATE_EMAIL, 'flags' => FILTER_REQUIRE_SCALAR, ], 'tel' => [ 'filter' => FILTER_VALIDATE_REGEXP, 'flags' => FILTER_REQUIRE_SCALAR, 'options' => [ 'regexp' => '/^\+?\d+(?:[. ()-]{1,2}\d+)*$/', 'default' => '+00 (0)0 00 00 00 00', ], ], ];
We then create a ValidArray instance passing there predefined $filters and the value of the $_REQUEST (which
normally accumulates values of GET, POST and COOKIE parameters):
$va = new ValidArray($filters, $_REQUEST);
Now we can be sure that the count of $va array is exactly 4 (id, name, email, tel keys), that each of them is
of corresponding type or null if not provided in request or false if incoming value does not correspond to
definition in filter. The default value for tel element will be used if not provided or invalid.
Since ValidArray extends ArrayObject, its elements may be accessed (added, iterated etc.) via normal array notation:
$va['name'] = 'John Snow'; echo "{$va['name']}\n"; // prints: John Snow foreach ($va as $name => $value) { printf("'%s' => %s,\n", $name, var_export($value, true)); } // prints (if $_REQUEST is empty): // 'id' => null, // 'name' => 'John Snow', // 'email' => null, // 'tel' => '+00 (0)0 00 00 00 00',
When setting ValidArray values, they are automatically validated using the predefined filters. Here again, if
provided value does not pass validation and no default value is defined, false is set instead (or null if
FILTER_NULL_ON_FAILURE flag is set).
$va['email'] = 'unknown'; echo "{$va['email']}\n"; // prints empty line since $va['email'] is false
More examples
For the following request parameters:
{
"id": 175,
"name": "John Snow",
"email": "john.snow@winterfell.com",
"tel": "322-223"
}
$va contents will be:
[
'id' => 175,
'name' => 'John Snow',
'email' => 'john.snow@winterfell.com',
'tel' => '322-223',
]
For incorrect request parameters:
{
"id": [1, "' OR 1 -- "],
"name": "X",
"another": true,
"admin": 1
}
$va contents will be:
[
'id' => false,
'name' => 'X',
'email' => null,
'tel' => "+00 (0)0 00 00 00 00",
]
Here, correct parameters will be present, all unknown parameters will be ignored, missing parameters will be set to
null or provided default value, and incorrect parameters will be set to false.
Additional filters
These additional filters are provided to enhance standard php-filter capacities:
ValidArray::FILTER_EXTENDED_CALLBACKValidArray::FILTER_INSTANCE_OF
| ID | Value | Options | Flags | Description |
|---|---|---|---|---|
FILTER_EXTENDED_CALLBACK |
-1 | callback, default |
FILTER_NULL_ON_FAILURE, FILTER_REQUIRE_SCALAR, FILTER_REQUIRE_ARRAY, FILTER_FORCE_ARRAY |
Executes user-defined callback passing initial value as argument. Returned value is used as valid. If initial value mismatches flags, sets value to default (if provided) or null (if FILTER_NULL_ON_FAILURE) or false without executing the callback. |
FILTER_INSTANCE_OF |
-2 | class_name, default |
FILTER_NULL_ON_FAILURE, FILTER_REQUIRE_SCALAR, FILTER_REQUIRE_ARRAY, FILTER_FORCE_ARRAY |
Validates initial value is an instance of class_name class. If initial value is not of class_name family or not an object or mismatches flags, sets value to default (if provided) or null (if FILTER_NULL_ON_FAILURE) or false. |
IMPORTANT
Filters in
ValidArrayobject are read-only and cannot change during object lifespan. This is by design. If you need object with updatable filters, use providedMutableValidArrayobject instead.