Completed
Push — master ( cc0463...09303e )
by Chad
19s
created

Filterer::getFilterAliases()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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