Completed
Pull Request — master (#76)
by Chad
01:39
created

Filterer::ofArrays()   B

Complexity

Conditions 5
Paths 8

Size

Total Lines 25
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

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