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