Completed
Push — master ( a8dec1...de16cb )
by
unknown
24s queued 15s
created

Filterer::extractConflicts()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

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

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
602
    {
603
        $allowUnknowns = $options[FiltererOptions::ALLOW_UNKNOWNS];
604
        if ($allowUnknowns !== false && $allowUnknowns !== true) {
605
            throw new InvalidArgumentException(sprintf("'%s' option was not a bool", FiltererOptions::ALLOW_UNKNOWNS));
606
        }
607
608
        return $allowUnknowns;
609
    }
610
611 View Code Duplication
    private static function getDefaultRequired(array $options) : bool
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
612
    {
613
        $defaultRequired = $options[FiltererOptions::DEFAULT_REQUIRED];
614
        if ($defaultRequired !== false && $defaultRequired !== true) {
615
            throw new InvalidArgumentException(
616
                sprintf("'%s' option was not a bool", FiltererOptions::DEFAULT_REQUIRED)
617
            );
618
        }
619
620
        return $defaultRequired;
621
    }
622
623
    /**
624
     * @param string         $responseType   The type of object that should be returned.
625
     * @param FilterResponse $filterResponse The filter response to generate the typed response from.
626
     *
627
     * @return array|FilterResponse
628
     *
629
     * @see filter For more information on how responseType is handled and returns are structured.
630
     */
631
    private static function generateFilterResponse(string $responseType, FilterResponse $filterResponse)
632
    {
633
        if ($responseType === self::RESPONSE_TYPE_FILTER) {
634
            return $filterResponse;
635
        }
636
637
        if ($responseType === self::RESPONSE_TYPE_ARRAY) {
638
            return [
639
                $filterResponse->success,
640
                $filterResponse->success ? $filterResponse->filteredValue : null,
641
                $filterResponse->errorMessage,
642
                $filterResponse->unknowns
643
            ];
644
        }
645
646
        throw new InvalidArgumentException(sprintf("'%s' was not a recognized value", FiltererOptions::RESPONSE_TYPE));
647
    }
648
}
649