Completed
Pull Request — master (#75)
by
unknown
01:26
created

Filterer::assertIfStringOrInt()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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