RequestParam::setJSON()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 1
c 1
b 0
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 0
1
<?php
2
/**
3
 * File containing the {@link \AppUtils\Request\RequestParam} class.
4
 * @package Application Utils
5
 * @subpackage Request
6
 * @see \AppUtils\Request\RequestParam
7
 */
8
9
declare(strict_types=1);
10
11
namespace AppUtils\Request;
12
13
use AppUtils\RegexHelper;
14
use AppUtils\Request;
15
use AppUtils\Request_Exception;
16
use AppUtils\Request_Param_Filter;
17
use AppUtils\Request_Param_Validator;
18
19
/**
20
 * Class used for handling a single request parameter - implements
21
 * validation and filtering. It is possible to filter values or
22
 * validate them, or both. Filtering is done before validation.
23
 *
24
 * Usage: use any of the setXX() methods to set the validation
25
 * type to use (only one validation type may be used per parameter),
26
 * and any of the addXXFilter() methods to specific filters to use.
27
 * Filters can be stacked, and are applied in the order that they
28
 * are added.
29
 *
30
 * @package Application Utils
31
 * @subpackage Request
32
 * @author Sebastian Mordziol <[email protected]>
33
 */
34
class RequestParam
35
{
36
    public const ERROR_UNKNOWN_VALIDATION_TYPE = 16301;
37
    public const ERROR_INVALID_FILTER_TYPE = 16303;
38
    public const ERROR_INVALID_FILTER_CLASS = 16304;
39
40
    public const VALIDATION_TYPE_NONE = 'none';
41
    public const VALIDATION_TYPE_NUMERIC = 'numeric';
42
    public const VALIDATION_TYPE_INTEGER = 'integer';
43
    public const VALIDATION_TYPE_REGEX = 'regex';
44
    public const VALIDATION_TYPE_ALPHA = 'alpha';
45
    public const VALIDATION_TYPE_ALNUM = 'alnum';
46
    public const VALIDATION_TYPE_ENUM = 'enum';
47
    public const VALIDATION_TYPE_ARRAY = 'array';
48
    public const VALIDATION_TYPE_CALLBACK = 'callback';
49
    public const VALIDATION_TYPE_URL = 'url';
50
    public const VALIDATION_TYPE_VALUESLIST = 'valueslist';
51
    public const VALIDATION_TYPE_JSON = 'json';
52
53
    public const FILTER_TYPE_CALLBACK = 'callback';
54
    public const FILTER_TYPE_CLASS = 'class';
55
56
    public const VALUE_TYPE_STRING = 'string';
57
    public const VALUE_TYPE_LIST = 'ids_list';
58
59
    protected Request $request;
60
    protected string $paramName;
61
    protected bool $required = false;
62
    protected string $valueType = self::VALUE_TYPE_STRING;
63
64
    /**
65
     * @var array<int,array{type:string,params:mixed}>
66
     */
67
    protected array $filters = array();
68
69
    /**
70
     * @var string[]
71
     */
72
    protected static array $validationTypes = array(
73
        self::VALIDATION_TYPE_NONE,
74
        self::VALIDATION_TYPE_ALPHA,
75
        self::VALIDATION_TYPE_ALNUM,
76
        self::VALIDATION_TYPE_ENUM,
77
        self::VALIDATION_TYPE_INTEGER,
78
        self::VALIDATION_TYPE_NUMERIC,
79
        self::VALIDATION_TYPE_REGEX,
80
        self::VALIDATION_TYPE_ARRAY,
81
        self::VALIDATION_TYPE_CALLBACK,
82
        self::VALIDATION_TYPE_URL,
83
        self::VALIDATION_TYPE_VALUESLIST,
84
        self::VALIDATION_TYPE_JSON
85
    );
86
87
    /**
88
     * @var string[]
89
     */
90
    protected static array $filterTypes = array(
91
        self::FILTER_TYPE_CALLBACK,
92
        self::FILTER_TYPE_CLASS
93
    );
94
95
    /**
96
     * @var array<int,array{type:string,params:array<string,mixed>}>
97
     */
98
    protected array $validations = array();
99
100
    /**
101
     * Constructor for the specified parameter name. Note that this
102
     * is instantiated automatically by the request class itself. You
103
     * should never have a situation where you instantiate this manually.
104
     *
105
     * @param Request $request
106
     * @param string $paramName
107
     */
108
    public function __construct(Request $request, string $paramName)
109
    {
110
        $this->request = $request;
111
        $this->paramName = $paramName;
112
    }
113
114
    /**
115
     * Adds a callback as a validation method. The callback gets the
116
     * value to validate as first parameter, and any additional
117
     * parameters passed here get appended to that.
118
     *
119
     * The callback must return boolean true or false depending on
120
     * whether the value is valid.
121
     *
122
     * @param callable $callback
123
     * @param array<mixed> $args
124
     * @return $this
125
     * @throws Request_Exception
126
     */
127
    public function setCallback(callable $callback, array $args=array()) : self
128
    {
129
        return $this->setValidation(
130
            self::VALIDATION_TYPE_CALLBACK, 
131
            array(
132
                'callback' => $callback,
133
                'arguments' => $args
134
            )
135
        );
136
    }
137
    
138
    /**
139
     * Validates a request parameter: called automatically for all
140
     * registered parameters by the request class. If no specific
141
     * parameter type has been selected, the value will simply be
142
     * passed through.
143
     *
144
     * @param mixed $value
145
     * @return mixed
146
     */
147
    public function validate($value)
148
    {
149
        // first off, apply filtering
150
        $value = $this->filter($value);
151
        
152
        if($this->valueType === self::VALUE_TYPE_LIST)
153
        {
154
            if(!is_array($value))
155
            {
156
                $value = explode(',', $value);
157
            }
158
            
159
            $keep = array();
160
            
161
            foreach($value as $subval)
162
            {
163
                $subval = $this->filter($subval);
164
                
165
                $subval = $this->applyValidations($subval, true);
166
167
                if($subval !== null) {
168
                    $keep[] = $subval;
169
                }
170
            }
171
            
172
            return $keep;
173
        }
174
        
175
        $value = $this->filter($value);
176
        
177
        return $this->applyValidations($value);
178
    }
179
    
180
   /**
181
    * Runs the value through all validations that were added.
182
    * 
183
    * @param mixed $value
184
    * @return mixed
185
    */
186
    protected function applyValidations($value, bool $subval=false)
187
    {
188
        // go through all enqueued validations in turn, each time
189
        // replacing the value with the adjusted, validated value.
190
        foreach($this->validations as $validateDef)
191
        {
192
            $value = $this->validateType($value, $validateDef['type'], $validateDef['params'], $subval);
193
        }
194
        
195
        return $value;
196
    }
197
    
198
   /**
199
    * Validates the specified value using the validation type. Returns
200
    * the validated value. 
201
    * 
202
    * @param mixed $value
203
    * @param string $type
204
    * @param array<string,mixed> $params
205
    * @param bool $subval Whether this is a subvalue in a list
206
    * @throws Request_Exception
207
    * @return mixed
208
    */
209
    protected function validateType($value, string $type, array $params, bool $subval)
210
    {
211
        $class = Request_Param_Validator::class.'_'.ucfirst($type);
212
        
213
        if(!class_exists($class))
214
        {
215
            throw new Request_Exception(
216
                'Unknown validation type.',
217
                sprintf(
218
                    'Cannot validate using type [%s], the target class [%s] does not exist.',
219
                    $type,
220
                    $class
221
                ),
222
                self::ERROR_UNKNOWN_VALIDATION_TYPE
223
            );
224
        }
225
        
226
        $validator = new $class($this, $subval);
227
        $validator->setOptions($params);
228
        
229
        return $validator->validate($value);
230
    }
231
    
232
    /**
233
     * Sets the parameter value as numeric, meaning it will be validated
234
     * using PHP's is_numeric method.
235
     *
236
     * @return $this
237
     */
238
    public function setNumeric() : self
239
    {
240
        return $this->setValidation(self::VALIDATION_TYPE_NUMERIC);
241
    }
242
243
    /**
244
     * Sets the parameter value as integer, it will be validated using a
245
     * regex to match only integer values.
246
     *
247
     * @return $this
248
     */
249
    public function setInteger() : self
250
    {
251
        return $this->setValidation(self::VALIDATION_TYPE_INTEGER);
252
    }
253
    
254
    /**
255
     * Sets a regex to bu used for validation parameter values.
256
     * @param string $regex
257
     * @return $this
258
     */
259
    public function setRegex(string $regex) : self
260
    {
261
        return $this->setValidation(self::VALIDATION_TYPE_REGEX, array('regex' => $regex));
262
    }
263
264
    /**
265
     * @return $this
266
     * @throws Request_Exception
267
     */
268
    public function setURL() : self
269
    {
270
        return $this->setValidation(self::VALIDATION_TYPE_URL);
271
    }
272
    
273
   /**
274
    * Sets the variable to contain a comma-separated list of integer IDs.
275
    * Example: <code>145,248,4556</code>. A single ID is also allowed, e.g.
276
    * <code>145</code>.
277
    * 
278
    * @return $this
279
    */
280
    public function setIDList() : self
281
    {
282
        $this->valueType = self::VALUE_TYPE_LIST;
283
        $this->addFilterTrim();
284
        $this->setInteger();
285
        
286
        return $this;
287
    }
288
    
289
   /**
290
    * Sets the variable to be an alias, as defined by the
291
    * {@link RegexHelper::REGEX_ALIAS} regular expression.
292
    * 
293
    * @return $this
294
    * @see RegexHelper::REGEX_ALIAS
295
    */
296
    public function setAlias() : self
297
    {
298
        return $this->setRegex(RegexHelper::REGEX_ALIAS);
299
    }
300
301
    /**
302
     * Sets the variable to be a name or title, as defined by the
303
     * {@link RegexHelper::REGEX_NAME_OR_TITLE} regular expression.
304
     *
305
     * @return $this
306
     * @see RegexHelper::REGEX_NAME_OR_TITLE
307
     */
308
    public function setNameOrTitle() : self
309
    {
310
        return $this->setRegex(RegexHelper::REGEX_NAME_OR_TITLE);
311
    }
312
    
313
    /**
314
     * Sets the variable to be a name or title, as defined by the
315
     * {@link RegexHelper::REGEX_LABEL} regular expression.
316
     *
317
     * @return $this
318
     * @see RegexHelper::REGEX_LABEL
319
     */
320
    public function setLabel() : self
321
    {
322
        return $this->setRegex(RegexHelper::REGEX_LABEL);
323
    }
324
325
    /**
326
     * Sets the parameter value as a string containing only lowercase
327
     * and/or uppercase letters.
328
     *
329
     * @return $this
330
     */
331
    public function setAlpha() : self
332
    {
333
        return $this->setValidation(self::VALIDATION_TYPE_ALPHA);
334
    }
335
    
336
   /**
337
    * Sets the parameter value as a string containing lowercase
338
    * and/or uppercase letters, as well as numbers.
339
    * 
340
    * @return $this
341
    */
342
    public function setAlnum() : self
343
    {
344
        return $this->setValidation(self::VALIDATION_TYPE_ALNUM);   
345
    }
346
347
    /**
348
     * Validates that the parameter value is one of the specified values.
349
     *
350
     * Note: specify possible values as parameters to this function.
351
     * If you do not specify any values, the validation will always
352
     * fail.
353
     *
354
     * It is also possible to specify an array of possible values
355
     * as the first parameter.
356
     *
357
     * @return $this
358
     * @throws Request_Exception
359
     */
360
    public function setEnum() : self
361
    {
362
        $args = func_get_args(); // cannot be used as function parameter in some PHP versions
363
        
364
        if(is_array($args[0])) 
365
        {
366
            $args = $args[0];
367
        }
368
369
        return $this->setValidation(
370
            self::VALIDATION_TYPE_ENUM, 
371
            array('values' => $args)
372
        );
373
    }
374
375
    /**
376
     * Only available for array values: the parameter must be
377
     * an array value, and the array may only contain values
378
     * specified in the values array.
379
     *
380
     * Submitted values that are not in the allowed list of
381
     * values are stripped from the value.
382
     *
383
     * @param array<int,string|number> $values List of allowed values
384
     * @return $this
385
     * @throws Request_Exception
386
     */
387
    public function setValuesList(array $values) : self
388
    {
389
        $this->setArray();
390
        
391
        return $this->setValidation(
392
            self::VALIDATION_TYPE_VALUESLIST, 
393
            array(
394
                'values' => $values
395
            )
396
        );
397
    }
398
    
399
   /**
400
    * Whether the parameter is a list of values.
401
    * 
402
    * @return bool
403
    */
404
    public function isList() : bool
405
    {
406
        return $this->valueType === self::VALUE_TYPE_LIST;
407
    }
408
409
    /**
410
     * @return $this
411
     * @throws Request_Exception
412
     */
413
    public function setArray() : self
414
    {
415
        return $this->setValidation(self::VALIDATION_TYPE_ARRAY);
416
    }
417
418
    /**
419
     * Specifies that a JSON-encoded string is expected.
420
     *
421
     * NOTE: Numbers or quoted strings are technically valid
422
     * JSON, but are not accepted, because it is assumed
423
     * at least an array or object are expected.
424
     *
425
     * @return $this
426
     * @throws Request_Exception
427
     */
428
    public function setJSON() : self
429
    {
430
        return $this->setValidation(self::VALIDATION_TYPE_JSON, array('arrays' => true));
431
    }
432
433
    /**
434
     * Like {@link RequestParam::setJSON()}, but accepts
435
     * only JSON objects. Arrays will not be accepted.
436
     *
437
     * @return $this
438
     * @throws Request_Exception
439
     */
440
    public function setJSONObject() : self
441
    {
442
        return $this->setValidation(self::VALIDATION_TYPE_JSON, array('arrays' => false));
443
    }
444
    
445
   /**
446
    * The parameter is a string boolean representation. This means
447
    * it can be any of the following: "yes", "true", "no", "false".
448
    * The value is automatically converted to a boolean when retrieving
449
    * the parameter.
450
    * 
451
    * @return $this
452
    */
453
    public function setBoolean() : self
454
    {
455
        return $this->addClassFilter('Boolean');
456
    }
457
    
458
   /**
459
    * Validates the request parameter as an MD5 string,
460
    * so that only values resembling md5 values are accepted.
461
    * 
462
    * NOTE: This can only guarantee the format, not whether
463
    * it is an actual valid hash of something.
464
    * 
465
    * @return $this
466
    */
467
    public function setMD5() : self
468
    {
469
        return $this->setRegex(RegexHelper::REGEX_MD5);
470
    }
471
472
    /**
473
     * Sets the validation type to use. See the VALIDATION_TYPE_XX class
474
     * constants for a list of types to use, or use any of the setXX methods
475
     * directly as shorthand.
476
     *
477
     * @param string $type
478
     * @param array<string,mixed> $params
479
     * @return $this
480
     * @throws Request_Exception
481
     * 
482
     * @see RequestParam::ERROR_UNKNOWN_VALIDATION_TYPE
483
     */
484
    public function setValidation(string $type, array $params = array()) : self
485
    {
486
        if (!in_array($type, self::$validationTypes)) {
487
            throw new Request_Exception(
488
                'Invalid validation type',
489
                sprintf(
490
                    'Tried setting the validation type to "%1$s". Possible validation types are: %2$s. Use the class constants VALIDATION_TYPE_XXX to set the desired validation type to avoid errors like this.',
491
                    $type,
492
                    implode(', ', self::$validationTypes)
493
                ),
494
                self::ERROR_UNKNOWN_VALIDATION_TYPE
495
            );
496
        }
497
498
        $this->validations[] = array(
499
            'type' => $type,
500
            'params' => $params
501
        );
502
503
        return $this;
504
    }
505
    
506
   /**
507
    * Retrieves the value of the request parameter,
508
    * applying all filters (if any) and validation
509
    * (if any).
510
    * 
511
    * @param mixed $default
512
    * @return mixed
513
    */
514
    public function get($default=null)
515
    {
516
        $value = $this->request->getParam($this->paramName);
517
        if($value !== null && $value !== '') {
518
            return $value;
519
        }
520
521
        return $this->validate($default);
522
    }
523
524
    // region: Filtering
525
526
   /**
527
    * Filters the specified value by going through all available
528
    * filters, if any. If none have been set, the value is simply
529
    * passed through.
530
    *
531
    * @param mixed $value
532
    * @return mixed
533
    *
534
    * @see RequestParam::applyFilter_callback()
535
    * @see RequestParam::applyFilter_class()
536
    */
537
    protected function filter($value)
538
    {
539
        foreach ($this->filters as $filter)
540
        {
541
            $method = 'applyFilter_' . $filter['type'];
542
            $value = $this->$method($value, $filter['params']);
543
        }
544
545
        return $value;
546
    }
547
548
    /**
549
     * @param mixed $value
550
     * @param array<string,mixed> $config
551
     * @return mixed
552
     * @throws Request_Exception
553
     */
554
    protected function applyFilter_class($value, array $config)
555
    {
556
        $class = Request_Param_Filter::class.'_'.$config['name'];
557
        
558
        $filter = new $class($this);
559
560
        if($filter instanceof Request_Param_Filter)
561
        {
562
            $filter->setOptions($config['params']);
563
            return $filter->filter($value);
564
        }
565
        
566
        throw new Request_Exception(
567
            'Not a valid filter class',
568
            sprintf(
569
                'The class [%s] does not extend [%s].',
570
                $class,
571
                Request_Param_Filter::class
572
            ),
573
            self::ERROR_INVALID_FILTER_CLASS
574
        );
575
    }
576
577
    /**
578
     * Applies the callback filter.
579
     * @param mixed $value
580
     * @param array<mixed> $callbackDef
581
     * @return mixed
582
     */
583
    protected function applyFilter_callback($value, array $callbackDef)
584
    {
585
        $params = $callbackDef['params'];
586
        array_unshift($params, $value);
587
588
        return call_user_func_array($callbackDef['callback'], $params);
589
    }
590
591
    /**
592
     * Adds a filter to apply to the parameter value before validation.
593
     * See the FILTER_XX class constants for available types, or use any
594
     * of the addXXFilter methods as shorthand.
595
     *
596
     * @param string $type
597
     * @param mixed $params
598
     * @return $this
599
     * @throws Request_Exception
600
     * 
601
     * @see RequestParam::ERROR_INVALID_FILTER_TYPE
602
     */
603
    public function addFilter(string $type, $params = null) : self
604
    {
605
        if (!in_array($type, self::$filterTypes)) {
606
            throw new Request_Exception(
607
                'Invalid filter type',
608
                sprintf(
609
                    'Tried setting the filter type to "%1$s". Possible validation types are: %2$s. Use the class constants FILTER_XXX to set the desired validation type to avoid errors like this.',
610
                    $type,
611
                    implode(', ', self::$filterTypes)
612
                ),
613
                self::ERROR_INVALID_FILTER_TYPE
614
            );
615
        }
616
617
        $this->filters[] = array(
618
            'type' => $type,
619
            'params' => $params
620
        );
621
622
        return $this;
623
    }
624
    
625
   /**
626
    * Adds a filter that trims whitespace from the request
627
    * parameter using the PHP <code>trim</code> function.
628
    * 
629
    * @return $this
630
    */
631
    public function addFilterTrim() : self
632
    {
633
        // to guarantee we only work with strings
634
        $this->addStringFilter();
635
        
636
        return $this->addCallbackFilter('trim');
637
    }
638
639
   /**
640
    * Converts the value to a string, even if it is not
641
    * a string value. Complex types like arrays and objects
642
    * are converted to an empty string.
643
    * 
644
    * @return $this
645
    */
646
    public function addStringFilter() : self
647
    {
648
        return $this->addClassFilter('String');
649
    }
650
651
    /**
652
     * Adds a filter using the specified callback. Can be any
653
     * type of callback, for example:
654
     *
655
     * // use the trim() function on the value
656
     * addCallbackFilter('trim');
657
     *
658
     * // use an object's method
659
     * addCallbackFilter(array($object, 'methodName'));
660
     *
661
     * // specify additional callback function parameters using an array (first one is always the value)
662
     * addCallbackFilter('strip_tags', array('<b><a><ul>'));
663
     *
664
     * @param mixed $callback
665
     * @param array<mixed> $params
666
     * @return $this
667
     *
668
     * @throws Request_Exception
669
     */
670
    public function addCallbackFilter($callback, array $params = array()) : self
671
    {
672
        return $this->addFilter(
673
            self::FILTER_TYPE_CALLBACK,
674
            array(
675
                'callback' => $callback,
676
                'params' => $params
677
            )
678
        );
679
    }
680
681
    /**
682
     * Adds a strip tags filter to the stack using PHP's strip_tags
683
     * function. Specify allowed tags like you would for the function
684
     * like this: "<b><a><ul>", or leave it empty for none.
685
     *
686
     * @param string $allowedTags
687
     * @return $this
688
     */
689
    public function addStripTagsFilter(string $allowedTags = '') : self
690
    {
691
        // to ensure we work only with string values.
692
        $this->addStringFilter();
693
        
694
        return $this->addCallbackFilter('strip_tags', array($allowedTags));
695
    }
696
    
697
   /**
698
    * Adds a filter that strips all whitespace from the
699
    * request parameter, from spaces to tabs and newlines.
700
    * 
701
    * @return $this
702
    */
703
    public function addStripWhitespaceFilter() : self
704
    {
705
        // to ensure we only work with strings.
706
        $this->addStringFilter();
707
        
708
        return $this->addClassFilter('StripWhitespace');
709
    }   
710
    
711
   /**
712
    * Adds a filter that transforms comma separated values
713
    * into an array of values.
714
    * 
715
    * @param bool $trimEntries Trim whitespace from each entry?
716
    * @param bool $stripEmptyEntries Remove empty entries from the array?
717
    * @return $this
718
    */
719
    public function addCommaSeparatedFilter(bool $trimEntries=true, bool $stripEmptyEntries=true) : self
720
    {
721
        $this->setArray();
722
        
723
        return $this->addClassFilter(
724
            'CommaSeparated', 
725
            array(
726
                'trimEntries' => $trimEntries,
727
                'stripEmptyEntries' => $stripEmptyEntries
728
            )
729
        );
730
    }
731
732
    /**
733
     * @param string $name
734
     * @param array<string,mixed> $params
735
     * @return $this
736
     * @throws Request_Exception
737
     */
738
    protected function addClassFilter(string $name, array $params=array()) : self
739
    {
740
        return $this->addFilter(
741
            self::FILTER_TYPE_CLASS,
742
            array(
743
                'name' => $name,
744
                'params' => $params
745
            )
746
        );
747
    }
748
    
749
   /**
750
    * Adds a filter that encodes all HTML special characters
751
    * using the PHP <code>htmlspecialchars</code> function.
752
    * 
753
    * @return $this
754
    */
755
    public function addHTMLSpecialcharsFilter() : self
756
    {
757
        return $this->addCallbackFilter('htmlspecialchars', array(ENT_QUOTES, 'UTF-8'));
758
    }
759
760
    // endregion
761
762
    public function getName() : string
763
    {
764
        return $this->paramName;
765
    }
766
    
767
   /**
768
    * Marks this request parameter as required. To use this feature,
769
    * you have to call the request's {@link Request::validate()}
770
    * method.
771
    * 
772
    * @return RequestParam
773
    * @see Request::validate()
774
    */
775
    public function makeRequired() : RequestParam
776
    {
777
        $this->required = true;
778
        return $this;
779
    }
780
    
781
    public function isRequired() : bool
782
    {
783
        return $this->required;
784
    }
785
}
786