Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.29% covered (warning)
79.29%
157 / 198
47.37% covered (danger)
47.37%
9 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
JCObjContent
79.29% covered (warning)
79.29%
157 / 198
47.37% covered (danger)
47.37%
9 / 19
229.39
0.00% covered (danger)
0.00%
0 / 1
 getWikitextForTransclusion
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 createDefaultView
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDataWithDefaults
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getValidationData
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
2.86
 initValidation
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
6.29
 finishValidation
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 validate
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 validateContent
n/a
0 / 0
n/a
0 / 0
0
 testOptional
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 test
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 testEach
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 testInt
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
4.68
 testRecursive
83.72% covered (warning)
83.72%
36 / 43
0.00% covered (danger)
0.00%
0 / 1
28.92
 testValue
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 markUnchecked
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
18
 addValidationError
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getField
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
13.57
 normalizeField
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
11
 convertValidators
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
1<?php
2namespace JsonConfig;
3
4use InvalidArgumentException;
5use LogicException;
6use Message;
7use stdClass;
8
9/**
10 * This class treats all configs as proper object representation of JSON,
11 * and offers a number of primitives to simplify validation on all levels
12 * @package JsonConfig
13 */
14abstract class JCObjContent extends JCContent {
15
16    /**
17     * @var bool if false, prevents multiple fields from having identical names that differ
18     *   only by casing
19     */
20    protected $isCaseSensitive = false;
21
22    /** @var bool if false, ensure the root to be an stdClass, otherwise - an array */
23    protected $isRootArray = false;
24
25    /**
26     * @var JCValue contains raw validation results. At first it is a parsed JSON value, with the
27     *   root element wrapped into JCValue. As validation progresses, all visited values become
28     *   wrapped with JCValue.
29     */
30    protected $validationData;
31
32    /** @var mixed */
33    protected $dataWithDefaults;
34
35    /** @var bool|null validation status - null=before, true=during, false=done */
36    protected $isValidating = null;
37
38    /**
39     * Override default behavior to include defaults if validation succeeded.
40     *
41     * @return string|bool The raw text, or false if the conversion failed.
42     */
43    public function getWikitextForTransclusion() {
44        if ( !$this->getStatus()->isGood() ) {
45            // If validation failed, return original text
46            return parent::getWikitextForTransclusion();
47        }
48        if ( !$this->thorough() && $this->validationData !== null ) {
49            // ensure that data is sorted in the right order
50            self::markUnchecked( $this->validationData );
51        }
52        return \FormatJson::encode( $this->getDataWithDefaults(), true, \FormatJson::ALL_OK );
53    }
54
55    protected function createDefaultView() {
56        return new JCDefaultObjContentView();
57    }
58
59    /**
60     * Get configuration data with custom defaults
61     * @return mixed
62     */
63    public function getDataWithDefaults() {
64        if ( $this->isValidating !== false ) {
65            throw new LogicException( 'This method may only be called after validation is complete' );
66        }
67        if ( $this->dataWithDefaults === null ) {
68            $this->dataWithDefaults = JCUtils::sanitize( $this->validationData );
69        }
70        return $this->dataWithDefaults;
71    }
72
73    /**
74     * Get status array that recursively describes dataWithDefaults
75     * @return JCValue
76     */
77    public function getValidationData() {
78        if ( $this->isValidating === null ) {
79            throw new LogicException(
80                'This method may only be called during or after validation has started'
81            );
82        }
83        return $this->validationData;
84    }
85
86    /**
87     * Call this function before performing data validation inside the derived validate()
88     * @param mixed $data
89     * @return bool if true, validation should be performed, otherwise all checks will be ignored
90     */
91    protected function initValidation( $data ) {
92        if ( $this->isValidating !== null ) {
93            throw new LogicException( 'This method may only be called before validation has started' );
94        }
95        $this->isValidating = true;
96        if ( !$this->isRootArray && !is_object( $data ) ) {
97            $this->getStatus()->fatal( 'jsonconfig-err-root-object-expected' );
98        } elseif ( $this->isRootArray && !is_array( $data ) ) {
99            $this->getStatus()->fatal( 'jsonconfig-err-root-array-expected' );
100        } else {
101            $this->validationData = new JCValue( JCValue::UNCHECKED, $data );
102            return true;
103        }
104        return false;
105    }
106
107    /**
108     * Derived validate() must return the result of this function
109     * @return array|null
110     */
111    protected function finishValidation() {
112        if ( !$this->getStatus()->isGood() ) {
113            return $this->getRawData(); // validation failed, do not modify
114        }
115        return null; // Data will be filter-cloned on demand inside self::getData()
116    }
117
118    /**
119     * Populate this data on-demand for efficiency
120     * @return stdClass
121     */
122    public function getData() {
123        if ( $this->data === null ) {
124            $this->data = JCUtils::sanitize( $this->validationData, true );
125        }
126        return $this->data;
127    }
128
129    public function validate( $data ) {
130        if ( $this->initValidation( $data ) ) {
131            $this->validateContent();
132            $data = $this->finishValidation();
133        }
134        if ( $this->thorough() && $this->validationData !== null ) {
135            self::markUnchecked( $this->validationData );
136        }
137        $this->isValidating = false;
138        return $data;
139    }
140
141    /**
142     * Derived classes must implement this method to perform custom validation
143     * using the test(...) calls
144     */
145    abstract public function validateContent();
146
147    /**
148     * Use this function to test a value, or if the value is missing, use the default value.
149     * The value will be tested with validator(s) if provided, even if it was the default.
150     * @param string|array $path name of the root field to check, or a path to the field in a nested
151     *        structure. Nested path should be in the form of
152     *        [ 'field-level1', 'field-level2', ... ]. For example, if client needs to check
153     *        validity of the 'value1' in the structure {'key':{'sub-key':['value0','value1']}},
154     *        $field should be set to [ 'key', 'sub-key', 1 ].
155     * @param mixed $default value to be used in case field is not found. $default is passed to the
156     *        validator if validation fails. If validation of the default passes,
157     *        the value is considered optional.
158     * @param callable|null $validator callback function as defined in JCValidators::run(). More than
159     *        one  validator may be given. If validators are not provided, any value is accepted
160     * @return bool true if ok, false otherwise
161     */
162    public function testOptional( $path, $default, $validator = null ) {
163        $vld = self::convertValidators( $validator, func_get_args(), 2 );
164        // first validator will replace missing with the default
165        array_unshift( $vld, JCValidators::useDefault( $default ) );
166        return $this->testInt( $path, $vld );
167    }
168
169    /**
170     * Use this function to test a field in the data. If missing, the validator(s) will receive
171     * JCMissing singleton as a value, and it will be up to the validator(s) to accept it or not.
172     * @param string|array $path name of the root field to check, or a path to the field in a nested
173     *        structure. Nested path should be in the form of
174     *        [ 'field-level1', 'field-level2', ... ]. For example, if client needs to check
175     *        validity of the 'value1' in the structure {'key':{'sub-key':['value0','value1']}},
176     *        $field should be set to [ 'key', 'sub-key', 1 ].
177     * @param callable $validator callback function as defined in JCValidators::run().
178     *        More than one validator may be given.
179     *        If validators are not provided, any value is accepted
180     * @param callable ...$extraValidators
181     * @return bool true if ok, false otherwise
182     */
183    public function test( $path, $validator, ...$extraValidators ) {
184        $vld = self::convertValidators( $validator, func_get_args(), 1 );
185        return $this->testInt( $path, $vld );
186    }
187
188    /**
189     * Use this function to test all values inside an array or an object at a given path.
190     * All validators will be called for each of the sub-values. If there is no value
191     * at the given $path, or it is not a container, no action will be taken and no errors reported
192     * @param string|array $path path to the container field in a nested structure.
193     *        Nested path should be in the form of [ 'field-level1', 'field-level2', ... ].
194     *        For example, if client needs to check validity of the 'value1' in the structure
195     *        {'key':{'sub-key':['value0','value1']}},
196     *        $field should be set to [ 'key', 'sub-key', 1 ].
197     * @param callable|null $validator callback function as defined in JCValidators::run().
198     *        More than one validator may be given.
199     *        If validators are not provided, any value is accepted
200     * @param callable ...$extraValidators
201     * @return bool true if all values tested ok, false otherwise
202     */
203    public function testEach( $path, $validator = null, ...$extraValidators ) {
204        $vld = self::convertValidators( $validator, func_get_args(), 1 );
205        $isOk = true;
206        $path = (array)$path;
207        $containerField = $this->getField( $path );
208        if ( $containerField ) {
209            $container = $containerField->getValue();
210            if ( is_array( $container ) || is_object( $container ) ) {
211                $lastIdx = count( $path );
212                if ( is_object( $container ) ) {
213                    $container = get_object_vars( $container );
214                }
215                foreach ( array_keys( $container ) as $k ) {
216                    $path[$lastIdx] = $k;
217                    $isOk = $this->testInt( $path, $vld ) && $isOk;
218                }
219            }
220        }
221        return $isOk;
222    }
223
224    /**
225     * @param array|string $path
226     * @param array $validators
227     * @return bool
228     */
229    private function testInt( $path, $validators ) {
230        if ( !$this->getStatus()->isOK() ) {
231            return false; // skip all validation in case of a fatal error
232        }
233        if ( $this->isValidating !== true ) {
234            throw new LogicException(
235                'This function should only be called inside the validateContent() override'
236            );
237        }
238        return $this->testRecursive( (array)$path, [], $this->validationData, $validators );
239    }
240
241    /**
242     * @param array $path
243     * @param array $fldPath For error reporting, path to the current field
244     * @param JCValue $jcv
245     * @param mixed $validators
246     * @internal param JCValue $status
247     * @return bool
248     */
249    private function testRecursive( array $path, array $fldPath, JCValue $jcv, $validators ) {
250        // Go recursively through all fields in path until empty, and validate last
251        if ( !$path ) {
252            // keep this branch here since we allow validation of the whole object ($path==[])
253            return $this->testValue( $fldPath, $jcv, $validators );
254        }
255        $fld = array_shift( $path );
256        if ( is_array( $jcv->getValue() ) && ctype_digit( (string)$fld ) ) {
257            $fld = (int)$fld;
258        }
259        if ( !is_int( $fld ) && !is_string( $fld ) ) {
260            throw new InvalidArgumentException( 'Unexpected field type, only strings and integers are allowed' );
261        }
262        $fldPath[] = $fld;
263
264        $subJcv = $this->getField( $fld, $jcv );
265        if ( $subJcv === null ) {
266            $msg =
267                is_int( $fld ) && !is_array( $jcv->getValue() ) ? 'jsonconfig-err-array-expected'
268                    : 'jsonconfig-err-object-expected';
269            $this->addValidationError( wfMessage( $msg, JCUtils::fieldPathToString( $fldPath ) ) );
270            return false;
271        }
272
273        /** @var bool $reposition - should the field be deleted and re-added at the end
274         * this is only needed for viewing and saving
275         */
276        $reposition = $this->thorough() && is_string( $fld ) && $subJcv !== false;
277        if ( $subJcv === false || $subJcv->isUnchecked() ) {
278            // We never went down this path before
279            // Check that field exists, and is not case-duplicated
280            if ( is_int( $fld ) ) {
281                if ( count( $jcv->getValue() ) < $fld ) {
282                    // Allow existing index or index+1 for appending last item
283                    throw new InvalidArgumentException( "List index is too large at '" .
284                                         JCUtils::fieldPathToString( $fldPath ) .
285                                         "'. Index may not exceed list size." );
286                }
287            } elseif ( !$this->isCaseSensitive ) {
288                // if we didn't find it before, it could have been misnamed
289                $norm = $this->normalizeField( $jcv, $fld, $fldPath );
290                if ( $norm === null ) {
291                    return false;
292                } elseif ( $norm ) {
293                    $subJcv = $this->getField( $fld, $jcv );
294                    $reposition = false; // normalization already does that
295                }
296            }
297            if ( $subJcv === null ) {
298                throw new LogicException( 'Logic error - subJcv must be valid here' );
299            } elseif ( $subJcv === false ) {
300                // field does not exist
301                // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
302                $initValue = !$path ? null : ( is_string( $path[0] ) ? (object)[] : [] );
303                $subJcv = new JCValue( JCValue::MISSING, $initValue );
304            }
305        }
306        $isOk = $this->testRecursive( $path, $fldPath, $subJcv, $validators );
307
308        // Always remove and re-append the field
309        if ( $subJcv->isMissing() ) {
310            $jcv->deleteField( $fld );
311        } else {
312            if ( $reposition ) {
313                $jcv->deleteField( $fld );
314            }
315            $jcv->setField( $fld, $subJcv );
316            if ( $jcv->isMissing() || $jcv->isUnchecked() ) {
317                $jcv->status( JCValue::VISITED );
318            }
319        }
320        return $isOk;
321    }
322
323    /**
324     * @param array $fldPath
325     * @param JCValue $jcv
326     * @param array $validators
327     * @return bool
328     */
329    private function testValue( array $fldPath, JCValue $jcv, $validators ) {
330        // We have reached the last level of the path, test the actual value
331        if ( $validators !== null ) {
332            $isRequired = $jcv->defaultUsed();
333            JCValidators::run( $validators, $jcv, $fldPath, $this );
334            $err = $jcv->error();
335            if ( $err ) {
336                if ( is_object( $err ) ) {
337                    // if ( !$isRequired ) {
338                    // // User supplied value, so we don't know if the value is required or not
339                    // // if $default passes validation, original value was optional
340                    // $isRequired = !JCValidators::run(
341                    // $validators, $fldPath, JCValue::getMissing(), $this
342                    // );
343                    // }
344                    $this->addValidationError( $err, !$isRequired );
345                }
346                return false;
347            } elseif ( $jcv->isUnchecked() ) {
348                $jcv->status( JCValue::CHECKED );
349            }
350        }
351        // if ( $this->thorough() && $jcv->status() === JCValue::CHECKED ) {
352        // // Check if the value is the same as default - use a cast to array
353        // // hack to compare objects
354        // $isRequired = (bool)JCValidators::run( $validators, $fldPath, JCMissing::get(), $this );
355        // if ( ( is_object( $jcv ) && is_object( $default ) && (array)$jcv === (array)$default )
356        // || ( !is_object( $default ) && $jcv === $default )
357        // ) {
358        // $newStatus = JCValue::SAME_AS_DEFAULT;
359        // }
360        // }
361        return true;
362    }
363
364    /**
365     * Recursively reorder all sub-elements - checked first, followed by unchecked.
366     * Also, convert all sub-elements to JCValue(UNCHECKED) if at least one of them was JCValue
367     * This is useful for HTML rendering to indicate unchecked items
368     * @param JCValue $data
369     */
370    private static function markUnchecked( JCValue $data ) {
371        $val = $data->getValue();
372        $isObject = is_object( $val );
373        if ( !$isObject && !is_array( $val ) ) {
374            return;
375        }
376        $result = null;
377        $firstPass = true;
378        $hasJcv = false;
379        // Two pass loop - first pass moves all checked values to the result,
380        // second pass moves the rest of of the values, possibly converting them to JCValue
381        while ( true ) {
382            foreach ( $val as $key => $subVal ) {
383                /** @var JCValue|mixed $subVal */
384                $isJcv = $subVal instanceof JCValue;
385                if ( $firstPass && $isJcv ) {
386                    // On the first pass, recursively process subelements if they were visited
387                    self::markUnchecked( $subVal );
388                    $move = $isObject && !$subVal->isUnchecked();
389                    $hasJcv = true;
390                } else {
391                    $move = false;
392                }
393                if ( $move || !$firstPass ) {
394                    if ( !$isJcv ) {
395                        $subVal = new JCValue( JCValue::UNCHECKED, $subVal );
396                    }
397                    if ( $result === null ) {
398                        $result = $isObject ? (object)[] : [];
399                    }
400                    if ( $isObject ) {
401                        $result->$key = $subVal;
402                        unset( $val->$key );
403                    } else {
404                        // No need to unset - all values in an array are moved in the second pass
405                        $result[] = $subVal;
406                    }
407                }
408            }
409
410            if ( ( $result === null && !$hasJcv ) || !$firstPass ) {
411                // either nothing was found, or we are done with the second pass
412                if ( $result !== null ) {
413                    $data->setValue( $result );
414                }
415                return;
416            }
417            $firstPass = false;
418        }
419    }
420
421    /**
422     * @param Message $error
423     * @param bool $isOptional
424     */
425    public function addValidationError( Message $error, $isOptional = false ) {
426        // @TODO fixme - need to re-enable optional field detection & reporting.
427        // Note the string append logic here is broken.
428        // if ( $isOptional ) {
429        // $error .= ' ' . wfMessage( 'jsonconfig-optional-field' )->plain();
430        // }
431        $this->getStatus()->error( $error );
432    }
433
434    /** Get field from data object/array
435     * @param string|int|array $field
436     * @param stdClass|array|JCValue|null $data
437     * @return false|null|JCValue search result:
438     *      false if not found
439     *      null if error (argument type does not match storage)
440     *      JCValue if the value is found
441     */
442    public function getField( $field, $data = null ) {
443        if ( $data === null ) {
444            $data = $this->getValidationData();
445        }
446        foreach ( (array)$field as $fld ) {
447            if ( !is_int( $fld ) && !is_string( $fld ) ) {
448                throw new InvalidArgumentException( 'Field must be either int or string' );
449            }
450            if ( $data instanceof JCValue ) {
451                $data = $data->getValue();
452            }
453            $isObject = is_object( $data );
454            $isArray = is_array( $data );
455            if ( is_string( $fld ) ? !( $isObject || $isArray ) : !$isArray ) {
456                return null;
457            }
458            $exists = $isObject ? property_exists( $data, $fld ) : array_key_exists( $fld, $data );
459            if ( !$exists ) {
460                return false;
461            }
462            if ( $isObject ) {
463                $data = $data->$fld;
464            } else {
465                $data = $data[$fld];
466            }
467        }
468        if ( $data instanceof JCValue ) {
469            return $data;
470        } else {
471            return new JCValue( JCValue::UNCHECKED, $data );
472        }
473    }
474
475    /**
476     * @param JCValue $jcv
477     * @param int|string $fld
478     * @param array $fldPath
479     * @return bool|null true if renamed, false if not found or original unchanged,
480     *   null if duplicate (error)
481     */
482    private function normalizeField( JCValue $jcv, $fld, array $fldPath ) {
483        $valueRef = $jcv->getValue();
484        $foundFld = false;
485        $isError = false;
486        foreach ( $valueRef as $k => $v ) {
487            if ( strcasecmp( $k, $fld ) === 0 ) {
488                if ( $foundFld !== false ) {
489                    $isError = true;
490                    break;
491                }
492                $foundFld = $k;
493            }
494        }
495        if ( $isError ) {
496            $this->addValidationError( wfMessage( 'jsonconfig-duplicate-field',
497                    JCUtils::fieldPathToString( $fldPath ) ) );
498            if ( $this->thorough() ) {
499                // Mark all duplicate fields as errors
500                foreach ( $valueRef as $k => $v ) {
501                    if ( strcasecmp( $k, $fld ) === 0 ) {
502                        if ( !( $v instanceof JCValue ) ) {
503                            $v = new JCValue( JCValue::UNCHECKED, $v );
504                            $jcv->setField( $k, $v );
505                        }
506                        $v->error( true );
507                    }
508                }
509            }
510            return null;
511        } elseif ( $foundFld !== false && $foundFld !== $fld ) {
512            // key had different casing, rename it to canonical
513            $jcv->setField( $fld, $jcv->deleteField( $foundFld ) );
514            return true;
515        }
516        return false;
517    }
518
519    /**
520     * @param null|callable|array $param first validator parameter
521     * @param array $funcArgs result of func_get_args() call
522     * @param int $skipArgs how many non-validator arguments to remove
523     *   from the beginning of the $funcArgs
524     * @return array of validators
525     */
526    private static function convertValidators( $param, $funcArgs, $skipArgs ) {
527        if ( $param === null ) {
528            return []; // no validators given
529        } elseif ( is_array( $param ) && !is_callable( $param, true ) ) {
530            return $param; // first argument is an array of validators
531        } else {
532            return array_slice( $funcArgs, $skipArgs ); // remove fixed params from the beginning
533        }
534    }
535}