Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
183 / 183 |
|
100.00% |
9 / 9 |
CRAP | |
100.00% |
1 / 1 |
TemplateDataValidator | |
100.00% |
183 / 183 |
|
100.00% |
9 / 9 |
94 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
validate | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
13 | |||
validateParameters | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
7 | |||
validateParameter | |
100.00% |
60 / 60 |
|
100.00% |
1 / 1 |
32 | |||
validateParameterOrder | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
7 | |||
validateSets | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
11 | |||
validateMaps | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
15 | |||
isValidCustomFormatString | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
isValidInterfaceText | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\TemplateData; |
4 | |
5 | use MediaWiki\Status\Status; |
6 | use stdClass; |
7 | |
8 | /** |
9 | * @license GPL-2.0-or-later |
10 | */ |
11 | class TemplateDataValidator { |
12 | |
13 | public const PREDEFINED_FORMATS = [ |
14 | 'block' => "{{_\n| _ = _\n}}", |
15 | 'inline' => '{{_|_=_}}', |
16 | ]; |
17 | |
18 | private const VALID_ROOT_KEYS = [ |
19 | 'description', |
20 | 'params', |
21 | 'paramOrder', |
22 | 'sets', |
23 | 'maps', |
24 | 'format', |
25 | ]; |
26 | |
27 | private const VALID_PARAM_KEYS = [ |
28 | 'label', |
29 | 'required', |
30 | 'suggested', |
31 | 'description', |
32 | 'example', |
33 | 'deprecated', |
34 | 'aliases', |
35 | 'autovalue', |
36 | 'default', |
37 | 'inherits', |
38 | 'type', |
39 | 'suggestedvalues', |
40 | ]; |
41 | |
42 | private const VALID_TYPES = [ |
43 | 'content', |
44 | 'line', |
45 | 'number', |
46 | 'boolean', |
47 | 'string', |
48 | 'date', |
49 | 'unbalanced-wikitext', |
50 | 'unknown', |
51 | 'url', |
52 | 'wiki-page-name', |
53 | 'wiki-user-name', |
54 | 'wiki-file-name', |
55 | 'wiki-template-name', |
56 | ]; |
57 | |
58 | /** @var string[] */ |
59 | private array $validParameterTypes; |
60 | |
61 | /** |
62 | * @param string[] $additionalParameterTypes |
63 | */ |
64 | public function __construct( array $additionalParameterTypes ) { |
65 | $this->validParameterTypes = array_merge( self::VALID_TYPES, $additionalParameterTypes ); |
66 | } |
67 | |
68 | /** |
69 | * @param mixed $data |
70 | * |
71 | * @return Status |
72 | */ |
73 | public function validate( $data ): Status { |
74 | if ( $data === null ) { |
75 | return Status::newFatal( 'templatedata-invalid-parse' ); |
76 | } |
77 | |
78 | if ( !( $data instanceof stdClass ) ) { |
79 | return Status::newFatal( 'templatedata-invalid-type', 'templatedata', 'object' ); |
80 | } |
81 | |
82 | foreach ( $data as $key => $value ) { |
83 | if ( !in_array( $key, self::VALID_ROOT_KEYS ) ) { |
84 | return Status::newFatal( 'templatedata-invalid-unknown', $key ); |
85 | } |
86 | } |
87 | |
88 | // Root.description |
89 | if ( isset( $data->description ) ) { |
90 | if ( !$this->isValidInterfaceText( $data->description ) ) { |
91 | return Status::newFatal( 'templatedata-invalid-type', 'description', |
92 | 'string|object' ); |
93 | } |
94 | } |
95 | |
96 | // Root.format |
97 | if ( isset( $data->format ) ) { |
98 | if ( !is_string( $data->format ) || |
99 | !( isset( self::PREDEFINED_FORMATS[$data->format] ) || |
100 | $this->isValidCustomFormatString( $data->format ) |
101 | ) |
102 | ) { |
103 | return Status::newFatal( 'templatedata-invalid-format', 'format' ); |
104 | } |
105 | } |
106 | |
107 | // Root.params |
108 | if ( !isset( $data->params ) ) { |
109 | return Status::newFatal( 'templatedata-invalid-missing', 'params', 'object' ); |
110 | } |
111 | |
112 | if ( !( $data->params instanceof stdClass ) ) { |
113 | return Status::newFatal( 'templatedata-invalid-type', 'params', 'object' ); |
114 | } |
115 | |
116 | return $this->validateParameters( $data->params ) ?? |
117 | $this->validateParameterOrder( $data->paramOrder ?? null, $data->params ) ?? |
118 | $this->validateSets( $data->sets ?? [], $data->params ) ?? |
119 | $this->validateMaps( $data->maps ?? (object)[], $data->params ) ?? |
120 | Status::newGood( $data ); |
121 | } |
122 | |
123 | /** |
124 | * @param stdClass $params |
125 | * @return Status|null Null on success, otherwise a Status object with the error message |
126 | */ |
127 | private function validateParameters( stdClass $params ): ?Status { |
128 | foreach ( $params as $paramName => $param ) { |
129 | if ( trim( $paramName ) === '' ) { |
130 | return Status::newFatal( 'templatedata-invalid-unnamed-parameter' ); |
131 | } |
132 | |
133 | if ( !( $param instanceof stdClass ) ) { |
134 | return Status::newFatal( 'templatedata-invalid-type', "params.{$paramName}", |
135 | 'object' ); |
136 | } |
137 | |
138 | $status = $this->validateParameter( $paramName, $param ); |
139 | if ( $status ) { |
140 | return $status; |
141 | } |
142 | |
143 | if ( isset( $param->inherits ) && !isset( $params->{ $param->inherits } ) ) { |
144 | return Status::newFatal( 'templatedata-invalid-missing', |
145 | "params.{$param->inherits}" ); |
146 | } |
147 | } |
148 | |
149 | return null; |
150 | } |
151 | |
152 | /** |
153 | * @param string $paramName |
154 | * @param stdClass $param |
155 | * @return Status|null Null on success, otherwise a Status object with the error message |
156 | */ |
157 | private function validateParameter( string $paramName, stdClass $param ): ?Status { |
158 | foreach ( $param as $key => $value ) { |
159 | if ( !in_array( $key, self::VALID_PARAM_KEYS ) ) { |
160 | return Status::newFatal( 'templatedata-invalid-unknown', |
161 | "params.{$paramName}.{$key}" ); |
162 | } |
163 | } |
164 | |
165 | // Param.label |
166 | if ( isset( $param->label ) ) { |
167 | if ( !$this->isValidInterfaceText( $param->label ) ) { |
168 | return Status::newFatal( 'templatedata-invalid-type', |
169 | "params.{$paramName}.label", 'string|object' ); |
170 | } |
171 | } |
172 | |
173 | // Param.required |
174 | if ( isset( $param->required ) ) { |
175 | if ( !is_bool( $param->required ) ) { |
176 | return Status::newFatal( 'templatedata-invalid-type', |
177 | "params.{$paramName}.required", 'boolean' ); |
178 | } |
179 | } |
180 | |
181 | // Param.suggested |
182 | if ( isset( $param->suggested ) ) { |
183 | if ( !is_bool( $param->suggested ) ) { |
184 | return Status::newFatal( 'templatedata-invalid-type', |
185 | "params.{$paramName}.suggested", 'boolean' ); |
186 | } |
187 | } |
188 | |
189 | // Param.description |
190 | if ( isset( $param->description ) ) { |
191 | if ( !$this->isValidInterfaceText( $param->description ) ) { |
192 | return Status::newFatal( 'templatedata-invalid-type', |
193 | "params.{$paramName}.description", 'string|object' ); |
194 | } |
195 | } |
196 | |
197 | // Param.example |
198 | if ( isset( $param->example ) ) { |
199 | if ( !$this->isValidInterfaceText( $param->example ) ) { |
200 | return Status::newFatal( 'templatedata-invalid-type', |
201 | "params.{$paramName}.example", 'string|object' ); |
202 | } |
203 | } |
204 | |
205 | // Param.deprecated |
206 | if ( isset( $param->deprecated ) ) { |
207 | if ( !is_bool( $param->deprecated ) && !is_string( $param->deprecated ) ) { |
208 | return Status::newFatal( 'templatedata-invalid-type', |
209 | "params.{$paramName}.deprecated", 'boolean|string' ); |
210 | } |
211 | } |
212 | |
213 | // Param.aliases |
214 | if ( isset( $param->aliases ) ) { |
215 | if ( !is_array( $param->aliases ) ) { |
216 | return Status::newFatal( 'templatedata-invalid-type', |
217 | "params.{$paramName}.aliases", 'array' ); |
218 | } |
219 | foreach ( $param->aliases as $i => $alias ) { |
220 | if ( !is_int( $alias ) && !is_string( $alias ) ) { |
221 | return Status::newFatal( 'templatedata-invalid-type', |
222 | "params.{$paramName}.aliases[$i]", 'int|string' ); |
223 | } |
224 | } |
225 | } |
226 | |
227 | // Param.autovalue |
228 | if ( isset( $param->autovalue ) ) { |
229 | if ( !is_string( $param->autovalue ) ) { |
230 | // TODO: Validate the autovalue values. |
231 | return Status::newFatal( 'templatedata-invalid-type', |
232 | "params.{$paramName}.autovalue", 'string' ); |
233 | } |
234 | } |
235 | |
236 | // Param.default |
237 | if ( isset( $param->default ) ) { |
238 | if ( !$this->isValidInterfaceText( $param->default ) ) { |
239 | return Status::newFatal( 'templatedata-invalid-type', |
240 | "params.{$paramName}.default", 'string|object' ); |
241 | } |
242 | } |
243 | |
244 | // Param.type |
245 | if ( isset( $param->type ) ) { |
246 | if ( !is_string( $param->type ) ) { |
247 | return Status::newFatal( 'templatedata-invalid-type', |
248 | "params.{$paramName}.type", 'string' ); |
249 | } |
250 | |
251 | if ( !in_array( $param->type, $this->validParameterTypes ) ) { |
252 | return Status::newFatal( 'templatedata-invalid-value', |
253 | 'params.' . $paramName . '.type' ); |
254 | } |
255 | } |
256 | |
257 | // Param.suggestedvalues |
258 | if ( isset( $param->suggestedvalues ) ) { |
259 | if ( !is_array( $param->suggestedvalues ) ) { |
260 | return Status::newFatal( 'templatedata-invalid-type', |
261 | "params.{$paramName}.suggestedvalues", 'array' ); |
262 | } |
263 | foreach ( $param->suggestedvalues as $i => $value ) { |
264 | if ( !is_string( $value ) ) { |
265 | return Status::newFatal( 'templatedata-invalid-type', |
266 | "params.{$paramName}.suggestedvalues[$i]", 'string' ); |
267 | } |
268 | } |
269 | } |
270 | |
271 | return null; |
272 | } |
273 | |
274 | /** |
275 | * @param mixed $paramOrder |
276 | * @param stdClass $params |
277 | * |
278 | * @return Status|null |
279 | */ |
280 | private function validateParameterOrder( $paramOrder, stdClass $params ): ?Status { |
281 | if ( $paramOrder === null ) { |
282 | return null; |
283 | } elseif ( !is_array( $paramOrder ) ) { |
284 | return Status::newFatal( 'templatedata-invalid-type', 'paramOrder', 'array' ); |
285 | } elseif ( count( $paramOrder ) < count( (array)$params ) ) { |
286 | $missing = array_diff( array_keys( (array)$params ), $paramOrder ); |
287 | return Status::newFatal( 'templatedata-invalid-missing', |
288 | 'paramOrder[ "' . implode( '", "', $missing ) . '" ]' ); |
289 | } |
290 | |
291 | // Validate each of the values corresponds to a parameter and that there are no |
292 | // duplicates |
293 | $seen = []; |
294 | foreach ( $paramOrder as $i => $param ) { |
295 | if ( !isset( $params->$param ) ) { |
296 | return Status::newFatal( 'templatedata-invalid-value', "paramOrder[ \"$param\" ]" ); |
297 | } |
298 | if ( isset( $seen[$param] ) ) { |
299 | return Status::newFatal( 'templatedata-invalid-duplicate-value', |
300 | "paramOrder[$i]", "paramOrder[{$seen[$param]}]", $param ); |
301 | } |
302 | $seen[$param] = $i; |
303 | } |
304 | |
305 | return null; |
306 | } |
307 | |
308 | /** |
309 | * @param mixed $sets |
310 | * @param stdClass $params |
311 | * |
312 | * @return Status|null |
313 | */ |
314 | private function validateSets( $sets, stdClass $params ): ?Status { |
315 | if ( !is_array( $sets ) ) { |
316 | return Status::newFatal( 'templatedata-invalid-type', 'sets', 'array' ); |
317 | } |
318 | |
319 | foreach ( $sets as $setNr => $setObj ) { |
320 | if ( !( $setObj instanceof stdClass ) ) { |
321 | return Status::newFatal( 'templatedata-invalid-value', "sets.{$setNr}" ); |
322 | } |
323 | |
324 | if ( !isset( $setObj->label ) ) { |
325 | return Status::newFatal( 'templatedata-invalid-missing', "sets.{$setNr}.label", |
326 | 'string|object' ); |
327 | } |
328 | |
329 | if ( !$this->isValidInterfaceText( $setObj->label ) ) { |
330 | return Status::newFatal( 'templatedata-invalid-type', "sets.{$setNr}.label", |
331 | 'string|object' ); |
332 | } |
333 | |
334 | if ( !isset( $setObj->params ) ) { |
335 | return Status::newFatal( 'templatedata-invalid-missing', "sets.{$setNr}.params", |
336 | 'array' ); |
337 | } |
338 | |
339 | if ( !is_array( $setObj->params ) ) { |
340 | return Status::newFatal( 'templatedata-invalid-type', "sets.{$setNr}.params", |
341 | 'array' ); |
342 | } |
343 | |
344 | if ( !$setObj->params ) { |
345 | return Status::newFatal( 'templatedata-invalid-empty-array', |
346 | "sets.{$setNr}.params" ); |
347 | } |
348 | |
349 | foreach ( $setObj->params as $i => $param ) { |
350 | if ( !isset( $params->$param ) ) { |
351 | return Status::newFatal( 'templatedata-invalid-value', |
352 | "sets.{$setNr}.params[$i]" ); |
353 | } |
354 | } |
355 | } |
356 | |
357 | return null; |
358 | } |
359 | |
360 | /** |
361 | * @param mixed $maps |
362 | * @param stdClass $params |
363 | * |
364 | * @return Status|null |
365 | */ |
366 | private function validateMaps( $maps, stdClass $params ): ?Status { |
367 | if ( !( $maps instanceof stdClass ) ) { |
368 | return Status::newFatal( 'templatedata-invalid-type', 'maps', 'object' ); |
369 | } |
370 | |
371 | foreach ( $maps as $consumerId => $map ) { |
372 | if ( !( $map instanceof stdClass ) ) { |
373 | return Status::newFatal( 'templatedata-invalid-type', "maps.$consumerId", |
374 | 'object' ); |
375 | } |
376 | |
377 | foreach ( $map as $key => $value ) { |
378 | // Key is not validated as this is used by a third-party application |
379 | // Value must be 2d array of parameter names, 1d array of parameter names, or valid |
380 | // parameter name |
381 | if ( is_array( $value ) ) { |
382 | foreach ( $value as $key2 => $value2 ) { |
383 | if ( is_array( $value2 ) ) { |
384 | foreach ( $value2 as $key3 => $value3 ) { |
385 | if ( !is_string( $value3 ) ) { |
386 | return Status::newFatal( 'templatedata-invalid-type', |
387 | "maps.{$consumerId}.{$key}[$key2][$key3]", 'string' ); |
388 | } |
389 | if ( !isset( $params->$value3 ) ) { |
390 | return Status::newFatal( 'templatedata-invalid-param', $value3, |
391 | "maps.$consumerId.{$key}[$key2][$key3]" ); |
392 | } |
393 | } |
394 | } elseif ( is_string( $value2 ) ) { |
395 | if ( !isset( $params->$value2 ) ) { |
396 | return Status::newFatal( 'templatedata-invalid-param', $value2, |
397 | "maps.$consumerId.{$key}[$key2]" ); |
398 | } |
399 | } else { |
400 | return Status::newFatal( 'templatedata-invalid-type', |
401 | "maps.{$consumerId}.{$key}[$key2]", 'string|array' ); |
402 | } |
403 | } |
404 | } elseif ( is_string( $value ) ) { |
405 | if ( !isset( $params->$value ) ) { |
406 | return Status::newFatal( 'templatedata-invalid-param', $value, |
407 | "maps.{$consumerId}.{$key}" ); |
408 | } |
409 | } else { |
410 | return Status::newFatal( 'templatedata-invalid-type', |
411 | "maps.{$consumerId}.{$key}", 'string|array' ); |
412 | } |
413 | } |
414 | } |
415 | |
416 | return null; |
417 | } |
418 | |
419 | private function isValidCustomFormatString( ?string $format ): bool { |
420 | return $format && preg_match( '/^\n?{{ *_+\n? *\|\n? *_+ *= *_+\n? *}}\n?$/', $format ); |
421 | } |
422 | |
423 | /** |
424 | * @param mixed $text |
425 | * @return bool |
426 | */ |
427 | private function isValidInterfaceText( $text ): bool { |
428 | if ( $text instanceof stdClass ) { |
429 | $isEmpty = true; |
430 | // An (array) cast would return private/protected properties as well |
431 | foreach ( get_object_vars( $text ) as $languageCode => $string ) { |
432 | // TODO: Do we need to validate if these are known interface language codes? |
433 | if ( !is_string( $languageCode ) || |
434 | ltrim( $languageCode ) === '' || |
435 | !is_string( $string ) |
436 | ) { |
437 | return false; |
438 | } |
439 | $isEmpty = false; |
440 | } |
441 | return !$isEmpty; |
442 | } |
443 | |
444 | return is_string( $text ); |
445 | } |
446 | |
447 | } |