Completed
Pull Request — master (#85)
by
unknown
01:18
created

Filterer::ofArrays()   A

Complexity

Conditions 5
Paths 8

Size

Total Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 25
rs 9.2088
c 0
b 0
f 0
cc 5
nc 8
nop 2
1
<?php
2
3
namespace TraderInteractive;
4
5
use Exception;
6
use InvalidArgumentException;
7
use Throwable;
8
use TraderInteractive\Exceptions\FilterException;
9
10
/**
11
 * Class to filter an array of input.
12
 */
13
final class Filterer
14
{
15
    /**
16
     * @var array
17
     */
18
    const DEFAULT_FILTER_ALIASES = [
19
        'array' => '\\TraderInteractive\\Filter\\Arrays::filter',
20
        'arrayize' => '\\TraderInteractive\\Filter\\Arrays::arrayize',
21
        'bool' => '\\TraderInteractive\\Filter\\Booleans::filter',
22
        'bool-convert' => '\\TraderInteractive\\Filter\\Booleans::convert',
23
        'concat' => '\\TraderInteractive\\Filter\\Strings::concat',
24
        'date' => '\\TraderInteractive\\Filter\\DateTime::filter',
25
        'date-format' => '\\TraderInteractive\\Filter\\DateTime::format',
26
        'email' => '\\TraderInteractive\\Filter\\Email::filter',
27
        'explode' => '\\TraderInteractive\\Filter\\Strings::explode',
28
        'flatten' => '\\TraderInteractive\\Filter\\Arrays::flatten',
29
        'float' => '\\TraderInteractive\\Filter\\Floats::filter',
30
        'in' => '\\TraderInteractive\\Filter\\Arrays::in',
31
        'int' => '\\TraderInteractive\\Filter\\Ints::filter',
32
        'ofArray' => '\\TraderInteractive\\Filterer::ofArray',
33
        'ofArrays' => '\\TraderInteractive\\Filterer::ofArrays',
34
        'ofScalars' => '\\TraderInteractive\\Filterer::ofScalars',
35
        'redact' => '\\TraderInteractive\\Filter\\Strings::redact',
36
        'string' => '\\TraderInteractive\\Filter\\Strings::filter',
37
        'strip-tags' => '\\TraderInteractive\\Filter\\Strings::stripTags',
38
        'timezone' => '\\TraderInteractive\\Filter\\DateTimeZone::filter',
39
        'translate' => '\\TraderInteractive\\Filter\\Strings::translate',
40
        'uint' => '\\TraderInteractive\\Filter\\UnsignedInt::filter',
41
        'url' => '\\TraderInteractive\\Filter\\Url::filter',
42
    ];
43
44
    /**
45
     * @var array
46
     */
47
    const DEFAULT_OPTIONS = [
48
        'allowUnknowns' => false,
49
        'defaultRequired' => false,
50
        'responseType' => self::RESPONSE_TYPE_ARRAY,
51
    ];
52
53
    /**
54
     * @var string
55
     */
56
    const RESPONSE_TYPE_ARRAY = 'array';
57
58
    /**
59
     * @var string
60
     */
61
    const RESPONSE_TYPE_FILTER = FilterResponse::class;
62
63
    /**
64
     * @var array
65
     */
66
    private static $registeredFilterAliases = self::DEFAULT_FILTER_ALIASES;
67
68
    /**
69
     * @var array
70
     */
71
    private $filterAliases;
72
73
    /**
74
     * @var array
75
     */
76
    private $specification;
77
78
    /**
79
     * @var array
80
     */
81
    private $options;
82
83
    /**
84
     * @param array      $specification The specification to apply to the value.
85
     * @param array      $options       The options apply during filtering.
86
     * @param array|null $filterAliases The filter aliases to accept.
87
     *
88
     * @see filter For more detailed information on the parameters.
89
     */
90
    public function __construct(array $specification, array $options = [], array $filterAliases = null)
91
    {
92
        $this->specification = $specification;
93
        $this->options = $options + self::DEFAULT_OPTIONS;
94
        $this->filterAliases = $filterAliases ?? self::$registeredFilterAliases;
95
    }
96
97
    /**
98
     * @param array $value The value to filter.
99
     *
100
     * @return array
101
     */
102
    public function __invoke(array $value) : array
103
    {
104
        $filterResponse = $this->execute($value, [], ['responseType' => self::RESPONSE_TYPE_FILTER]);
105
        if ($filterResponse->success === false) {
106
            throw new FilterException($filterResponse->errorMessage);
107
        }
108
109
        return $filterResponse->filteredValue;
110
    }
111
112
    /**
113
     * @param mixed $value         The value to filter.
114
     * @param array $specification Specification to merge with the instance specification.
115
     * @param array $options       Options to merge with the instance options.
116
     *
117
     * @return array|FilterResponse
118
     *
119
     * @throws Exception
120
     * @see filter For more information on the return types and structure.
121
     */
122
    public function execute(array $value, array $specification = [], array $options = [])
123
    {
124
        $specification += $this->specification;
125
        $options += $this->options;
126
127
        $allowUnknowns = self::getAllowUnknowns($options);
128
        $defaultRequired = self::getDefaultRequired($options);
129
        $responseType = $options['responseType'];
130
131
        $inputToFilter = array_intersect_key($value, $specification);
132
        $leftOverSpec = array_diff_key($specification, $value);
133
        $leftOverInput = array_diff_key($value, $specification);
134
135
        $errors = [];
136
        foreach ($inputToFilter as $field => $value) {
137
            $filters = $specification[$field];
138
            self::assertFiltersIsAnArray($filters, $field);
139
            $customError = self::validateCustomError($filters, $field);
140
            unset($filters['required']);//doesn't matter if required since we have this one
141
            unset($filters['default']);//doesn't matter if there is a default since we have a value
142
            foreach ($filters as $filter) {
143
                self::assertFilterIsNotArray($filter, $field);
144
145
                if (empty($filter)) {
146
                    continue;
147
                }
148
149
                $function = array_shift($filter);
150
                $function = self::handleFilterAliases($function, $this->filterAliases);
151
152
                self::assertFunctionIsCallable($function, $field);
153
154
                array_unshift($filter, $value);
155
                try {
156
                    $value = call_user_func_array($function, $filter);
157
                } catch (Exception $exception) {
158
                    $errors = self::handleCustomError($field, $value, $exception, $errors, $customError);
159
                    continue 2;//next field
160
                }
161
            }
162
163
            $inputToFilter[$field] = $value;
164
        }
165
166
        foreach ($leftOverSpec as $field => $filters) {
167
            self::assertFiltersIsAnArray($filters, $field);
168
            $required = self::getRequired($filters, $defaultRequired, $field);
169
            if (array_key_exists('default', $filters)) {
170
                $inputToFilter[$field] = $filters['default'];
171
                continue;
172
            }
173
174
            $errors = self::handleRequiredFields($required, $field, $errors);
175
        }
176
177
        $errors = self::handleAllowUnknowns($allowUnknowns, $leftOverInput, $errors);
178
179
        return self::generateFilterResponse($responseType, $inputToFilter, $errors, $leftOverInput);
180
    }
181
182
    /**
183
     * Example:
184
     * <pre>
185
     * <?php
186
     * class AppendFilter
187
     * {
188
     *     public function filter($value, $extraArg)
189
     *     {
190
     *         return $value . $extraArg;
191
     *     }
192
     * }
193
     * $appendFilter = new AppendFilter();
194
     *
195
     * $trimFunc = function($val) { return trim($val); };
196
     *
197
     * list($status, $result, $error, $unknowns) = TraderInteractive\Filterer::filter(
198
     *     [
199
     *         'field one' => [[$trimFunc], ['substr', 0, 3], [[$appendFilter, 'filter'], 'boo']],
200
     *         'field two' => ['required' => true, ['floatval']],
201
     *         'field three' => ['required' => false, ['float']],
202
     *         'field four' => ['required' => true, 'default' => 1, ['uint']],
203
     *     ],
204
     *     ['field one' => ' abcd', 'field two' => '3.14']
205
     * );
206
     *
207
     * var_dump($status);
208
     * var_dump($result);
209
     * var_dump($error);
210
     * var_dump($unknowns);
211
     * </pre>
212
     * prints:
213
     * <pre>
214
     * bool(true)
215
     * array(3) {
216
     *   'field one' =>
217
     *   string(6) "abcboo"
218
     *   'field two' =>
219
     *   double(3.14)
220
     *   'field four' =>
221
     *   int(1)
222
     * }
223
     * NULL
224
     * array(0) {
225
     * }
226
     * </pre>
227
     *
228
     * @param array $spec the specification to apply to the $input. An array where each key is a known input field and
229
     *                    each value is an array of filters. Each filter should be an array with the first member being
230
     *                    anything that can pass is_callable() as well as accepting the value to filter as its first
231
     *                    argument. Two examples would be the string 'trim' or an object function specified like [$obj,
232
     *                    'filter'], see is_callable() documentation. The rest of the members are extra arguments to the
233
     *                    callable. The result of one filter will be the first argument to the next filter. In addition
234
     *                    to the filters, the specification values may contain a 'required' key (default false) that
235
     *                    controls the same behavior as the 'defaultRequired' option below but on a per field basis. A
236
     *                    'default' specification value may be used to substitute in a default to the $input when the
237
     *                    key is not present (whether 'required' is specified or not).
238
     * @param array $input the input the apply the $spec on.
239
     * @param array $options 'allowUnknowns' (default false) true to allow unknowns or false to treat as error,
240
     *                       'defaultRequired' (default false) true to make fields required by default and treat as
241
     *                       error on absence and false to allow their absence by default
242
     *                       'responseType' (default RESPONSE_TYPE_ARRAY) Determines the return type, as described
243
     *                       in the return section.
244
     *
245
     * @return array|FilterResponse If 'responseType' option is RESPONSE_TYPE_ARRAY:
246
     *                                  on success [true, $input filtered, null, array of unknown fields]
247
     *                                  on error [false, null, 'error message', array of unknown fields]
248
     *                              If 'responseType' option is RESPONSE_TYPE_FILTER: a FilterResponse instance.
249
     *
250
     * @throws Exception
251
     * @throws InvalidArgumentException if 'allowUnknowns' option was not a bool
252
     * @throws InvalidArgumentException if 'defaultRequired' option was not a bool
253
     * @throws InvalidArgumentException if 'responseType' option was not a recognized type
254
     * @throws InvalidArgumentException if filters for a field was not an array
255
     * @throws InvalidArgumentException if a filter for a field was not an array
256
     * @throws InvalidArgumentException if 'required' for a field was not a bool
257
     */
258
    public static function filter(array $spec, array $input, array $options = [])
259
    {
260
        $filterer = new Filterer($spec, $options);
261
        return $filterer->execute($input);
262
    }
263
264
    /**
265
     * Return the filter aliases.
266
     *
267
     * @return array array where keys are aliases and values pass is_callable().
268
     */
269
    public static function getFilterAliases() : array
270
    {
271
        return self::$registeredFilterAliases;
272
    }
273
274
    /**
275
     * Set the filter aliases.
276
     *
277
     * @param array $aliases array where keys are aliases and values pass is_callable().
278
     * @return void
279
     *
280
     * @throws Exception Thrown if any of the given $aliases is not valid. @see registerAlias()
281
     */
282
    public static function setFilterAliases(array $aliases)
283
    {
284
        $originalAliases = self::$registeredFilterAliases;
285
        self::$registeredFilterAliases = [];
286
        try {
287
            foreach ($aliases as $alias => $callback) {
288
                self::registerAlias($alias, $callback);
289
            }
290
        } catch (Throwable $throwable) {
291
            self::$registeredFilterAliases = $originalAliases;
292
            throw $throwable;
293
        }
294
    }
295
296
    /**
297
     * Register a new alias with the Filterer
298
     *
299
     * @param string|int $alias the alias to register
300
     * @param callable $filter the aliased callable filter
301
     * @param bool $overwrite Flag to overwrite existing alias if it exists
302
     *
303
     * @return void
304
     *
305
     * @throws \InvalidArgumentException if $alias was not a string or int
306
     * @throws Exception if $overwrite is false and $alias exists
307
     */
308
    public static function registerAlias($alias, callable $filter, bool $overwrite = false)
309
    {
310
        self::assertIfStringOrInt($alias);
311
        self::assertIfAliasExists($alias, $overwrite);
312
        self::$registeredFilterAliases[$alias] = $filter;
313
    }
314
315
    /**
316
     * Filter an array by applying filters to each member
317
     *
318
     * @param array $values an array to be filtered. Use the Arrays::filter() before this method to ensure counts when
319
     *                      you pass into Filterer
320
     * @param array $filters filters with each specified the same as in @see self::filter.
321
     *                       Eg [['string', false, 2], ['uint']]
322
     *
323
     * @return array the filtered $values
324
     *
325
     * @throws FilterException if any member of $values fails filtering
326
     */
327
    public static function ofScalars(array $values, array $filters) : array
328
    {
329
        $wrappedFilters = [];
330
        foreach ($values as $key => $item) {
331
            $wrappedFilters[$key] = $filters;
332
        }
333
334
        list($status, $result, $error) = self::filter($wrappedFilters, $values);
335
        if (!$status) {
336
            throw new FilterException($error);
337
        }
338
339
        return $result;
340
    }
341
342
    /**
343
     * Filter an array by applying filters to each member
344
     *
345
     * @param array $values as array to be filtered. Use the Arrays::filter() before this method to ensure counts when
346
     *                      you pass into Filterer
347
     * @param array $spec spec to apply to each $values member, specified the same as in @see self::filter.
348
     *     Eg ['key' => ['required' => true, ['string', false], ['unit']], 'key2' => ...]
349
     *
350
     * @return array the filtered $values
351
     *
352
     * @throws Exception if any member of $values fails filtering
353
     */
354
    public static function ofArrays(array $values, array $spec) : array
355
    {
356
        $results = [];
357
        $errors = [];
358
        foreach ($values as $key => $item) {
359
            if (!is_array($item)) {
360
                $errors[] = "Value at position '{$key}' was not an array";
361
                continue;
362
            }
363
364
            list($status, $result, $error) = self::filter($spec, $item);
365
            if (!$status) {
366
                $errors[] = $error;
367
                continue;
368
            }
369
370
            $results[$key] = $result;
371
        }
372
373
        if (!empty($errors)) {
374
            throw new FilterException(implode("\n", $errors));
375
        }
376
377
        return $results;
378
    }
379
380
    /**
381
     * Filter $value by using a Filterer $spec and Filterer's default options.
382
     *
383
     * @param array $value array to be filtered. Use the Arrays::filter() before this method to ensure counts when you
384
     *                     pass into Filterer
385
     * @param array $spec spec to apply to $value, specified the same as in @see self::filter.
386
     *     Eg ['key' => ['required' => true, ['string', false], ['unit']], 'key2' => ...]
387
     *
388
     * @return array the filtered $value
389
     *
390
     * @throws FilterException if $value fails filtering
391
     */
392
    public static function ofArray(array $value, array $spec) : array
393
    {
394
        list($status, $result, $error) = self::filter($spec, $value);
395
        if (!$status) {
396
            throw new FilterException($error);
397
        }
398
399
        return $result;
400
    }
401
402
    private static function assertIfStringOrInt($alias)
403
    {
404
        if (!is_string($alias) && !is_int($alias)) {
405
            throw new InvalidArgumentException('$alias was not a string or int');
406
        }
407
    }
408
409
    private static function assertIfAliasExists($alias, bool $overwrite)
410
    {
411
        if (array_key_exists($alias, self::$registeredFilterAliases) && !$overwrite) {
412
            throw new Exception("Alias '{$alias}' exists");
413
        }
414
    }
415
416
    private static function checkForUnknowns(array $leftOverInput, array $errors) : array
417
    {
418
        foreach ($leftOverInput as $field => $value) {
419
            $errors[$field] = "Field '{$field}' with value '" . trim(var_export($value, true), "'") . "' is unknown";
420
        }
421
422
        return $errors;
423
    }
424
425
    private static function handleAllowUnknowns(bool $allowUnknowns, array $leftOverInput, array $errors) : array
426
    {
427
        if (!$allowUnknowns) {
428
            $errors = self::checkForUnknowns($leftOverInput, $errors);
429
        }
430
431
        return $errors;
432
    }
433
434
    private static function handleRequiredFields(bool $required, string $field, array $errors) : array
435
    {
436
        if ($required) {
437
            $errors[$field] = "Field '{$field}' was required and not present";
438
        }
439
        return $errors;
440
    }
441
442
    private static function getRequired($filters, $defaultRequired, $field) : bool
443
    {
444
        $required = isset($filters['required']) ? $filters['required'] : $defaultRequired;
445
        if ($required !== false && $required !== true) {
446
            throw new InvalidArgumentException("'required' for field '{$field}' was not a bool");
447
        }
448
449
        return $required;
450
    }
451
452
    private static function assertFiltersIsAnArray($filters, string $field)
453
    {
454
        if (!is_array($filters)) {
455
            throw new InvalidArgumentException("filters for field '{$field}' was not a array");
456
        }
457
    }
458
459
    private static function handleCustomError(
460
        string $field,
461
        $value,
462
        Throwable $e,
463
        array $errors,
464
        string $customError = null
465
    ) : array {
466
        $error = $customError;
467
        if ($error === null) {
468
            $error = sprintf(
469
                "Field '%s' with value '{value}' failed filtering, message '%s'",
470
                $field,
471
                $e->getMessage()
472
            );
473
        }
474
475
        $errors[$field] = str_replace('{value}', trim(var_export($value, true), "'"), $error);
476
        return $errors;
477
    }
478
479
    private static function assertFunctionIsCallable($function, string $field)
480
    {
481
        if (!is_callable($function)) {
482
            throw new Exception(
483
                "Function '" . trim(var_export($function, true), "'") . "' for field '{$field}' is not callable"
484
            );
485
        }
486
    }
487
488
    private static function handleFilterAliases($function, $filterAliases)
489
    {
490
        if ((is_string($function) || is_int($function)) && array_key_exists($function, $filterAliases)) {
491
            $function = $filterAliases[$function];
492
        }
493
494
        return $function;
495
    }
496
497
    private static function assertFilterIsNotArray($filter, string $field)
498
    {
499
        if (!is_array($filter)) {
500
            throw new InvalidArgumentException("filter for field '{$field}' was not a array");
501
        }
502
    }
503
504
    private static function validateCustomError(array &$filters, string $field)
505
    {
506
        $customError = null;
507
        if (array_key_exists('error', $filters)) {
508
            $customError = $filters['error'];
509
            if (!is_string($customError) || trim($customError) === '') {
510
                throw new InvalidArgumentException("error for field '{$field}' was not a non-empty string");
511
            }
512
513
            unset($filters['error']);//unset so its not used as a filter
514
        }
515
516
        return $customError;
517
    }
518
519
    private static function getAllowUnknowns(array $options) : bool
520
    {
521
        $allowUnknowns = $options['allowUnknowns'];
522
        if ($allowUnknowns !== false && $allowUnknowns !== true) {
523
            throw new InvalidArgumentException("'allowUnknowns' option was not a bool");
524
        }
525
526
        return $allowUnknowns;
527
    }
528
529
    private static function getDefaultRequired(array $options) : bool
530
    {
531
        $defaultRequired = $options['defaultRequired'];
532
        if ($defaultRequired !== false && $defaultRequired !== true) {
533
            throw new InvalidArgumentException("'defaultRequired' option was not a bool");
534
        }
535
536
        return $defaultRequired;
537
    }
538
539
    /**
540
     * @param string $responseType  The type of object that should be returned.
541
     * @param array  $filteredValue The filtered input to return.
542
     * @param array  $errors        The errors to return.
543
     * @param array  $unknowns      The unknowns to return.
544
     *
545
     * @return array|FilterResponse
546
     *
547
     * @see filter For more information on how responseType is handled and returns are structured.
548
     */
549
    private static function generateFilterResponse(
550
        string $responseType,
551
        array $filteredValue,
552
        array $errors,
553
        array $unknowns
554
    ) {
555
        $filterResponse = new FilterResponse($filteredValue, $errors, $unknowns);
556
557
        if ($responseType === self::RESPONSE_TYPE_FILTER) {
558
            return $filterResponse;
559
        }
560
561
        if ($responseType === self::RESPONSE_TYPE_ARRAY) {
562
            return [
563
                $filterResponse->success,
564
                $filterResponse->success ? $filterResponse->filteredValue : null,
565
                $filterResponse->errorMessage,
566
                $filterResponse->unknowns
567
            ];
568
        }
569
570
        throw new InvalidArgumentException("'responseType' was not a recognized value");
571
    }
572
}
573