Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
79.29% |
157 / 198 |
|
47.37% |
9 / 19 |
CRAP | |
0.00% |
0 / 1 |
JCObjContent | |
79.29% |
157 / 198 |
|
47.37% |
9 / 19 |
229.39 | |
0.00% |
0 / 1 |
getWikitextForTransclusion | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
20 | |||
createDefaultView | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getDataWithDefaults | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
getValidationData | |
40.00% |
2 / 5 |
|
0.00% |
0 / 1 |
2.86 | |||
initValidation | |
80.00% |
8 / 10 |
|
0.00% |
0 / 1 |
6.29 | |||
finishValidation | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getData | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
validate | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
validateContent | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
testOptional | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
test | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
testEach | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
56 | |||
testInt | |
42.86% |
3 / 7 |
|
0.00% |
0 / 1 |
4.68 | |||
testRecursive | |
83.72% |
36 / 43 |
|
0.00% |
0 / 1 |
28.92 | |||
testValue | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
markUnchecked | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
18 | |||
addValidationError | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getField | |
85.00% |
17 / 20 |
|
0.00% |
0 / 1 |
13.57 | |||
normalizeField | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
11 | |||
convertValidators | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
4.13 |
1 | <?php |
2 | namespace JsonConfig; |
3 | |
4 | use InvalidArgumentException; |
5 | use LogicException; |
6 | use Message; |
7 | use 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 | */ |
14 | abstract 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 | } |