Completed
Push — master ( 09303e...b0f758 )
by
unknown
10s queued 10s
created

Filterer::filter()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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