forked from traderinteractive/filter-php
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathFilterer.php
More file actions
453 lines (399 loc) · 16.3 KB
/
Filterer.php
File metadata and controls
453 lines (399 loc) · 16.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
<?php
namespace TraderInteractive;
use Exception;
use Throwable;
use TraderInteractive\Exceptions\FilterException;
/**
* Class to filter an array of input.
*/
final class Filterer
{
/**
* @var array
*/
const DEFAULT_FILTER_ALIASES = [
'array' => '\\TraderInteractive\\Filter\\Arrays::filter',
'arrayize' => '\\TraderInteractive\\Filter\\Arrays::arrayize',
'bool' => '\\TraderInteractive\\Filter\\Booleans::filter',
'bool-convert' => '\\TraderInteractive\\Filter\\Booleans::convert',
'concat' => '\\TraderInteractive\\Filter\\Strings::concat',
'date' => '\\TraderInteractive\\Filter\\DateTime::filter',
'date-format' => '\\TraderInteractive\\Filter\\DateTime::format',
'email' => '\\TraderInteractive\\Filter\\Email::filter',
'explode' => '\\TraderInteractive\\Filter\\Strings::explode',
'flatten' => '\\TraderInteractive\\Filter\\Arrays::flatten',
'float' => '\\TraderInteractive\\Filter\\Floats::filter',
'in' => '\\TraderInteractive\\Filter\\Arrays::in',
'int' => '\\TraderInteractive\\Filter\\Ints::filter',
'ofArray' => '\\TraderInteractive\\Filterer::ofArray',
'ofArrays' => '\\TraderInteractive\\Filterer::ofArrays',
'ofScalars' => '\\TraderInteractive\\Filterer::ofScalars',
'string' => '\\TraderInteractive\\Filter\\Strings::filter',
'strip-tags' => '\\TraderInteractive\\Filter\\Strings::stripTags',
'timezone' => '\\TraderInteractive\\Filter\\DateTimeZone::filter',
'uint' => '\\TraderInteractive\\Filter\\UnsignedInt::filter',
'url' => '\\TraderInteractive\\Filter\\Url::filter',
];
/**
* @var array
*/
private static $filterAliases = self::DEFAULT_FILTER_ALIASES;
/**
* Example:
* <pre>
* <?php
* class AppendFilter
* {
* public function filter($value, $extraArg)
* {
* return $value . $extraArg;
* }
* }
* $appendFilter = new AppendFilter();
*
* $trimFunc = function($val) { return trim($val); };
*
* list($status, $result, $error, $unknowns) = TraderInteractive\Filterer::filter(
* [
* 'field one' => [[$trimFunc], ['substr', 0, 3], [[$appendFilter, 'filter'], 'boo']],
* 'field two' => ['required' => true, ['floatval']],
* 'field three' => ['required' => false, ['float']],
* 'field four' => ['required' => true, 'default' => 1, ['uint']],
* ],
* ['field one' => ' abcd', 'field two' => '3.14']
* );
*
* var_dump($status);
* var_dump($result);
* var_dump($error);
* var_dump($unknowns);
* </pre>
* prints:
* <pre>
* bool(true)
* array(3) {
* 'field one' =>
* string(6) "abcboo"
* 'field two' =>
* double(3.14)
* 'field four' =>
* int(1)
* }
* NULL
* array(0) {
* }
* </pre>
*
* @param array $spec the specification to apply to the $input. An array where each key is a known input field and
* each value is an array of filters. Each filter should be an array with the first member being
* anything that can pass is_callable() as well as accepting the value to filter as its first
* argument. Two examples would be the string 'trim' or an object function specified like [$obj,
* 'filter'], see is_callable() documentation. The rest of the members are extra arguments to the
* callable. The result of one filter will be the first argument to the next filter. In addition
* to the filters, the specification values may contain a 'required' key (default false) that
* controls the same behavior as the 'defaultRequired' option below but on a per field basis. A
* 'default' specification value may be used to substitute in a default to the $input when the
* key is not present (whether 'required' is specified or not).
* @param array $input the input the apply the $spec on.
* @param array $options 'allowUnknowns' (default false) true to allow unknowns or false to treat as error,
* 'defaultRequired' (default false) true to make fields required by default and treat as
* error on absence and false to allow their absence by default
*
* @return array on success [true, $input filtered, null, array of unknown fields]
* on error [false, null, 'error message', array of unknown fields]
*
* @throws Exception
* @throws \InvalidArgumentException if 'allowUnknowns' option was not a bool
* @throws \InvalidArgumentException if 'defaultRequired' option was not a bool
* @throws \InvalidArgumentException if filters for a field was not a array
* @throws \InvalidArgumentException if a filter for a field was not a array
* @throws \InvalidArgumentException if 'required' for a field was not a bool
*/
public static function filter(array $spec, array $input, array $options = []) : array
{
$options += ['allowUnknowns' => false, 'defaultRequired' => false];
$allowUnknowns = self::getAllowUnknowns($options);
$defaultRequired = self::getDefaultRequired($options);
$inputToFilter = array_intersect_key($input, $spec);
$leftOverSpec = array_diff_key($spec, $input);
$leftOverInput = array_diff_key($input, $spec);
$errors = [];
foreach ($inputToFilter as $field => $value) {
$filters = $spec[$field];
self::assertFiltersIsAnArray($filters, $field);
$customError = self::validateCustomError($filters, $field);
unset($filters['required']);//doesn't matter if required since we have this one
unset($filters['default']);//doesn't matter if there is a default since we have a value
foreach ($filters as $filter) {
self::assertFilterIsNotArray($filter, $field);
if (empty($filter)) {
continue;
}
$function = array_shift($filter);
$function = self::handleFilterAliases($function);
self::assertFunctionIsCallable($function, $field);
array_unshift($filter, $value);
try {
$value = call_user_func_array($function, $filter);
} catch (Exception $e) {
$errors = self::handleCustomError($field, $value, $e, $errors, $customError);
continue 2;//next field
}
}
$inputToFilter[$field] = $value;
}
foreach ($leftOverSpec as $field => $filters) {
self::assertFiltersIsAnArray($filters, $field);
$required = self::getRequired($filters, $defaultRequired, $field);
if (array_key_exists('default', $filters)) {
$inputToFilter[$field] = $filters['default'];
continue;
}
$errors = self::handleRequiredFields($required, $field, $errors);
}
$errors = self::handleAllowUnknowns($allowUnknowns, $leftOverInput, $errors);
if (empty($errors)) {
return [true, $inputToFilter, null, $leftOverInput];
}
return [false, null, implode("\n", $errors), $leftOverInput];
}
/**
* Return the filter aliases.
*
* @return array array where keys are aliases and values pass is_callable().
*/
public static function getFilterAliases() : array
{
return self::$filterAliases;
}
/**
* Set the filter aliases.
*
* @param array $aliases array where keys are aliases and values pass is_callable().
* @return void
*
* @throws Exception Thrown if any of the given $aliases is not valid. @see registerAlias()
*/
public static function setFilterAliases(array $aliases)
{
$originalAliases = self::$filterAliases;
self::$filterAliases = [];
try {
foreach ($aliases as $alias => $callback) {
self::registerAlias($alias, $callback);
}
} catch (Exception $e) {
self::$filterAliases = $originalAliases;
throw $e;
}
}
/**
* Register a new alias with the Filterer
*
* @param string|int $alias the alias to register
* @param callable $filter the aliased callable filter
* @param bool $overwrite Flag to overwrite existing alias if it exists
*
* @return void
*
* @throws \InvalidArgumentException if $alias was not a string or int
* @throws Exception if $overwrite is false and $alias exists
*/
public static function registerAlias($alias, callable $filter, bool $overwrite = false)
{
self::assertIfStringOrInt($alias);
self::assertIfAliasExists($alias, $overwrite);
self::$filterAliases[$alias] = $filter;
}
/**
* Filter an array by applying filters to each member
*
* @param array $values an array to be filtered. Use the Arrays::filter() before this method to ensure counts when
* you pass into Filterer
* @param array $filters filters with each specified the same as in @see self::filter.
* Eg [['string', false, 2], ['uint']]
*
* @return array the filtered $values
*
* @throws FilterException if any member of $values fails filtering
*/
public static function ofScalars(array $values, array $filters) : array
{
$wrappedFilters = [];
foreach ($values as $key => $item) {
$wrappedFilters[$key] = $filters;
}
list($status, $result, $error) = self::filter($wrappedFilters, $values);
if (!$status) {
throw new FilterException($error);
}
return $result;
}
/**
* Filter an array by applying filters to each member
*
* @param array $values as array to be filtered. Use the Arrays::filter() before this method to ensure counts when
* you pass into Filterer
* @param array $spec spec to apply to each $values member, specified the same as in @see self::filter.
* Eg ['key' => ['required' => true, ['string', false], ['unit']], 'key2' => ...]
*
* @return array the filtered $values
*
* @throws Exception if any member of $values fails filtering
*/
public static function ofArrays(array $values, array $spec) : array
{
$results = [];
$errors = [];
foreach ($values as $key => $item) {
if (!is_array($item)) {
$errors[] = "Value at position '{$key}' was not an array";
continue;
}
list($status, $result, $error) = self::filter($spec, $item);
if (!$status) {
$errors[] = $error;
continue;
}
$results[$key] = $result;
}
if (!empty($errors)) {
throw new FilterException(implode("\n", $errors));
}
return $results;
}
/**
* Filter $value by using a Filterer $spec and Filterer's default options.
*
* @param array $value array to be filtered. Use the Arrays::filter() before this method to ensure counts when you
* pass into Filterer
* @param array $spec spec to apply to $value, specified the same as in @see self::filter.
* Eg ['key' => ['required' => true, ['string', false], ['unit']], 'key2' => ...]
*
* @return array the filtered $value
*
* @throws FilterException if $value fails filtering
*/
public static function ofArray(array $value, array $spec) : array
{
list($status, $result, $error) = self::filter($spec, $value);
if (!$status) {
throw new FilterException($error);
}
return $result;
}
private static function assertIfStringOrInt($alias)
{
if (!is_string($alias) && !is_int($alias)) {
throw new \InvalidArgumentException('$alias was not a string or int');
}
}
private static function assertIfAliasExists($alias, bool $overwrite)
{
if (array_key_exists($alias, self::$filterAliases) && !$overwrite) {
throw new Exception("Alias '{$alias}' exists");
}
}
private static function checkForUnknowns(array $leftOverInput, array $errors) : array
{
foreach ($leftOverInput as $field => $value) {
$errors[] = "Field '{$field}' with value '" . trim(var_export($value, true), "'") . "' is unknown";
}
return $errors;
}
private static function handleAllowUnknowns(bool $allowUnknowns, array $leftOverInput, array $errors) : array
{
if (!$allowUnknowns) {
$errors = self::checkForUnknowns($leftOverInput, $errors);
}
return $errors;
}
private static function handleRequiredFields(bool $required, string $field, array $errors) : array
{
if ($required) {
$errors[] = "Field '{$field}' was required and not present";
}
return $errors;
}
private static function getRequired($filters, $defaultRequired, $field) : bool
{
$required = isset($filters['required']) ? $filters['required'] : $defaultRequired;
if ($required !== false && $required !== true) {
throw new \InvalidArgumentException("'required' for field '{$field}' was not a bool");
}
return $required;
}
private static function assertFiltersIsAnArray($filters, string $field)
{
if (!is_array($filters)) {
throw new \InvalidArgumentException("filters for field '{$field}' was not a array");
}
}
private static function handleCustomError(
string $field,
$value,
Throwable $e,
array $errors,
string $customError = null
) : array {
$error = $customError;
if ($error === null) {
$error = sprintf(
"Field '%s' with value '{value}' failed filtering, message '%s'",
$field,
$e->getMessage()
);
}
$errors[] = str_replace('{value}', trim(var_export($value, true), "'"), $error);
return $errors;
}
private static function assertFunctionIsCallable($function, string $field)
{
if (!is_callable($function)) {
throw new Exception(
"Function '" . trim(var_export($function, true), "'") . "' for field '{$field}' is not callable"
);
}
}
private static function handleFilterAliases($function)
{
if ((is_string($function) || is_int($function)) && array_key_exists($function, self::$filterAliases)) {
$function = self::$filterAliases[$function];
}
return $function;
}
private static function assertFilterIsNotArray($filter, string $field)
{
if (!is_array($filter)) {
throw new \InvalidArgumentException("filter for field '{$field}' was not a array");
}
}
private static function validateCustomError(array &$filters, string $field)
{
$customError = null;
if (array_key_exists('error', $filters)) {
$customError = $filters['error'];
if (!is_string($customError) || trim($customError) === '') {
throw new \InvalidArgumentException("error for field '{$field}' was not a non-empty string");
}
unset($filters['error']);//unset so its not used as a filter
}
return $customError;
}
private static function getAllowUnknowns(array $options) : bool
{
$allowUnknowns = $options['allowUnknowns'];
if ($allowUnknowns !== false && $allowUnknowns !== true) {
throw new \InvalidArgumentException("'allowUnknowns' option was not a bool");
}
return $allowUnknowns;
}
private static function getDefaultRequired(array $options) : bool
{
$defaultRequired = $options['defaultRequired'];
if ($defaultRequired !== false && $defaultRequired !== true) {
throw new \InvalidArgumentException("'defaultRequired' option was not a bool");
}
return $defaultRequired;
}
}