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

Filterer::generateFilterResponse()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 18
rs 9.6666
c 0
b 0
f 0
cc 3
nc 3
nop 4
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];
138
139
        $allowUnknowns = self::getAllowUnknowns($options);
140
        $defaultRequired = self::getDefaultRequired($options);
141
142
        $inputToFilter = array_intersect_key($input, $spec);
143
        $leftOverSpec = array_diff_key($spec, $input);
144
        $leftOverInput = array_diff_key($input, $spec);
145
146
        $errors = [];
147
        foreach ($inputToFilter as $field => $value) {
148
            $filters = $spec[$field];
149
            self::assertFiltersIsAnArray($filters, $field);
150
            $customError = self::validateCustomError($filters, $field);
151
            unset($filters['required']);//doesn't matter if required since we have this one
152
            unset($filters['default']);//doesn't matter if there is a default since we have a value
153
            foreach ($filters as $filter) {
154
                self::assertFilterIsNotArray($filter, $field);
155
156
                if (empty($filter)) {
157
                    continue;
158
                }
159
160
                $function = array_shift($filter);
161
                $function = self::handleFilterAliases($function);
162
163
                self::assertFunctionIsCallable($function, $field);
164
165
                array_unshift($filter, $value);
166
                try {
167
                    $value = call_user_func_array($function, $filter);
168
                } catch (Exception $e) {
169
                    $errors = self::handleCustomError($field, $value, $e, $errors, $customError);
170
                    continue 2;//next field
171
                }
172
            }
173
174
            $inputToFilter[$field] = $value;
175
        }
176
177
        foreach ($leftOverSpec as $field => $filters) {
178
            self::assertFiltersIsAnArray($filters, $field);
179
            $required = self::getRequired($filters, $defaultRequired, $field);
180
            if (array_key_exists('default', $filters)) {
181
                $inputToFilter[$field] = $filters['default'];
182
                continue;
183
            }
184
185
            $errors = self::handleRequiredFields($required, $field, $errors);
186
        }
187
188
        $errors = self::handleAllowUnknowns($allowUnknowns, $leftOverInput, $errors);
189
190
        $responseType = $options['responseType'] ?? self::RESPONSE_TYPE_ARRAY;
191
192
        return self::generateFilterResponse($responseType, $inputToFilter, $errors, $leftOverInput);
193
    }
194
195
    /**
196
     * Return the filter aliases.
197
     *
198
     * @return array array where keys are aliases and values pass is_callable().
199
     */
200
    public static function getFilterAliases() : array
201
    {
202
        return self::$filterAliases;
203
    }
204
205
    /**
206
     * Set the filter aliases.
207
     *
208
     * @param array $aliases array where keys are aliases and values pass is_callable().
209
     * @return void
210
     *
211
     * @throws Exception Thrown if any of the given $aliases is not valid. @see registerAlias()
212
     */
213
    public static function setFilterAliases(array $aliases)
214
    {
215
        $originalAliases = self::$filterAliases;
216
        self::$filterAliases = [];
217
        try {
218
            foreach ($aliases as $alias => $callback) {
219
                self::registerAlias($alias, $callback);
220
            }
221
        } catch (Exception $e) {
222
            self::$filterAliases = $originalAliases;
223
            throw $e;
224
        }
225
    }
226
227
    /**
228
     * Register a new alias with the Filterer
229
     *
230
     * @param string|int $alias the alias to register
231
     * @param callable $filter the aliased callable filter
232
     * @param bool $overwrite Flag to overwrite existing alias if it exists
233
     *
234
     * @return void
235
     *
236
     * @throws \InvalidArgumentException if $alias was not a string or int
237
     * @throws Exception if $overwrite is false and $alias exists
238
     */
239
    public static function registerAlias($alias, callable $filter, bool $overwrite = false)
240
    {
241
        self::assertIfStringOrInt($alias);
242
        self::assertIfAliasExists($alias, $overwrite);
243
        self::$filterAliases[$alias] = $filter;
244
    }
245
246
    /**
247
     * Filter an array by applying filters to each member
248
     *
249
     * @param array $values an array to be filtered. Use the Arrays::filter() before this method to ensure counts when
250
     *                      you pass into Filterer
251
     * @param array $filters filters with each specified the same as in @see self::filter.
252
     *                       Eg [['string', false, 2], ['uint']]
253
     *
254
     * @return array the filtered $values
255
     *
256
     * @throws FilterException if any member of $values fails filtering
257
     */
258
    public static function ofScalars(array $values, array $filters) : array
259
    {
260
        $wrappedFilters = [];
261
        foreach ($values as $key => $item) {
262
            $wrappedFilters[$key] = $filters;
263
        }
264
265
        list($status, $result, $error) = self::filter($wrappedFilters, $values);
266
        if (!$status) {
267
            throw new FilterException($error);
268
        }
269
270
        return $result;
271
    }
272
273
    /**
274
     * Filter an array by applying filters to each member
275
     *
276
     * @param array $values as array to be filtered. Use the Arrays::filter() before this method to ensure counts when
277
     *                      you pass into Filterer
278
     * @param array $spec spec to apply to each $values member, specified the same as in @see self::filter.
279
     *     Eg ['key' => ['required' => true, ['string', false], ['unit']], 'key2' => ...]
280
     *
281
     * @return array the filtered $values
282
     *
283
     * @throws Exception if any member of $values fails filtering
284
     */
285
    public static function ofArrays(array $values, array $spec) : array
286
    {
287
        $results = [];
288
        $errors = [];
289
        foreach ($values as $key => $item) {
290
            if (!is_array($item)) {
291
                $errors[] = "Value at position '{$key}' was not an array";
292
                continue;
293
            }
294
295
            list($status, $result, $error) = self::filter($spec, $item);
296
            if (!$status) {
297
                $errors[] = $error;
298
                continue;
299
            }
300
301
            $results[$key] = $result;
302
        }
303
304
        if (!empty($errors)) {
305
            throw new FilterException(implode("\n", $errors));
306
        }
307
308
        return $results;
309
    }
310
311
    /**
312
     * Filter $value by using a Filterer $spec and Filterer's default options.
313
     *
314
     * @param array $value array to be filtered. Use the Arrays::filter() before this method to ensure counts when you
315
     *                     pass into Filterer
316
     * @param array $spec spec to apply to $value, specified the same as in @see self::filter.
317
     *     Eg ['key' => ['required' => true, ['string', false], ['unit']], 'key2' => ...]
318
     *
319
     * @return array the filtered $value
320
     *
321
     * @throws FilterException if $value fails filtering
322
     */
323
    public static function ofArray(array $value, array $spec) : array
324
    {
325
        list($status, $result, $error) = self::filter($spec, $value);
326
        if (!$status) {
327
            throw new FilterException($error);
328
        }
329
330
        return $result;
331
    }
332
333
    private static function assertIfStringOrInt($alias)
334
    {
335
        if (!is_string($alias) && !is_int($alias)) {
336
            throw new InvalidArgumentException('$alias was not a string or int');
337
        }
338
    }
339
340
    private static function assertIfAliasExists($alias, bool $overwrite)
341
    {
342
        if (array_key_exists($alias, self::$filterAliases) && !$overwrite) {
343
            throw new Exception("Alias '{$alias}' exists");
344
        }
345
    }
346
347
    private static function checkForUnknowns(array $leftOverInput, array $errors) : array
348
    {
349
        foreach ($leftOverInput as $field => $value) {
350
            $errors[] = "Field '{$field}' with value '" . trim(var_export($value, true), "'") . "' is unknown";
351
        }
352
353
        return $errors;
354
    }
355
356
    private static function handleAllowUnknowns(bool $allowUnknowns, array $leftOverInput, array $errors) : array
357
    {
358
        if (!$allowUnknowns) {
359
            $errors = self::checkForUnknowns($leftOverInput, $errors);
360
        }
361
362
        return $errors;
363
    }
364
365
    private static function handleRequiredFields(bool $required, string $field, array $errors) : array
366
    {
367
        if ($required) {
368
            $errors[] = "Field '{$field}' was required and not present";
369
        }
370
        return $errors;
371
    }
372
373
    private static function getRequired($filters, $defaultRequired, $field) : bool
374
    {
375
        $required = isset($filters['required']) ? $filters['required'] : $defaultRequired;
376
        if ($required !== false && $required !== true) {
377
            throw new InvalidArgumentException("'required' for field '{$field}' was not a bool");
378
        }
379
380
        return $required;
381
    }
382
383
    private static function assertFiltersIsAnArray($filters, string $field)
384
    {
385
        if (!is_array($filters)) {
386
            throw new InvalidArgumentException("filters for field '{$field}' was not a array");
387
        }
388
    }
389
390
    private static function handleCustomError(
391
        string $field,
392
        $value,
393
        Throwable $e,
394
        array $errors,
395
        string $customError = null
396
    ) : array {
397
        $error = $customError;
398
        if ($error === null) {
399
            $error = sprintf(
400
                "Field '%s' with value '{value}' failed filtering, message '%s'",
401
                $field,
402
                $e->getMessage()
403
            );
404
        }
405
406
        $errors[] = str_replace('{value}', trim(var_export($value, true), "'"), $error);
407
        return $errors;
408
    }
409
410
    private static function assertFunctionIsCallable($function, string $field)
411
    {
412
        if (!is_callable($function)) {
413
            throw new Exception(
414
                "Function '" . trim(var_export($function, true), "'") . "' for field '{$field}' is not callable"
415
            );
416
        }
417
    }
418
419
    private static function handleFilterAliases($function)
420
    {
421
        if ((is_string($function) || is_int($function)) && array_key_exists($function, self::$filterAliases)) {
422
            $function = self::$filterAliases[$function];
423
        }
424
425
        return $function;
426
    }
427
428
    private static function assertFilterIsNotArray($filter, string $field)
429
    {
430
        if (!is_array($filter)) {
431
            throw new InvalidArgumentException("filter for field '{$field}' was not a array");
432
        }
433
    }
434
435
    private static function validateCustomError(array &$filters, string $field)
436
    {
437
        $customError = null;
438
        if (array_key_exists('error', $filters)) {
439
            $customError = $filters['error'];
440
            if (!is_string($customError) || trim($customError) === '') {
441
                throw new InvalidArgumentException("error for field '{$field}' was not a non-empty string");
442
            }
443
444
            unset($filters['error']);//unset so its not used as a filter
445
        }
446
447
        return $customError;
448
    }
449
450
    private static function getAllowUnknowns(array $options) : bool
451
    {
452
        $allowUnknowns = $options['allowUnknowns'];
453
        if ($allowUnknowns !== false && $allowUnknowns !== true) {
454
            throw new InvalidArgumentException("'allowUnknowns' option was not a bool");
455
        }
456
457
        return $allowUnknowns;
458
    }
459
460
    private static function getDefaultRequired(array $options) : bool
461
    {
462
        $defaultRequired = $options['defaultRequired'];
463
        if ($defaultRequired !== false && $defaultRequired !== true) {
464
            throw new InvalidArgumentException("'defaultRequired' option was not a bool");
465
        }
466
467
        return $defaultRequired;
468
    }
469
470
    /**
471
     * @param string $responseType  The type of object that should be returned.
472
     * @param array  $filteredValue The filtered input to return.
473
     * @param array  $errors        The errors to return.
474
     * @param array  $unknowns      The unknowns to return.
475
     *
476
     * @return array|FilterResponse
477
     *
478
     * @see filter For more information on how responseType is handled and returns are structured.
479
     */
480
    private static function generateFilterResponse(
481
        string $responseType,
482
        array $filteredValue,
483
        array $errors,
484
        array $unknowns
485
    ) {
486
        $filterResponse = new FilterResponse($filteredValue, $errors, $unknowns);
487
488
        if ($responseType === self::RESPONSE_TYPE_FILTER) {
489
            return $filterResponse;
490
        }
491
492
        if ($responseType === self::RESPONSE_TYPE_ARRAY) {
493
            return $filterResponse->toArray();
494
        }
495
496
        throw new InvalidArgumentException("'responseType' was not a recognized value");
497
    }
498
}
499