Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.24% covered (success)
95.24%
100 / 105
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
JsonCodec
95.24% covered (success)
95.24%
100 / 105
62.50% covered (warning)
62.50%
5 / 8
58
0.00% covered (danger)
0.00%
0 / 1
 unserialize
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
17
 unserializeArray
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 serializeOne
85.71% covered (warning)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
12.42
 serialize
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 canMakeNewFromValue
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 containsComplexValue
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 detectNonSerializableDataInternal
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
14.03
 detectNonSerializableData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 * @ingroup Json
20 */
21
22namespace MediaWiki\Json;
23
24use FormatJson;
25use JsonException;
26use JsonSerializable;
27use MediaWiki\Parser\ParserOutput;
28use stdClass;
29use Wikimedia\Assert\Assert;
30
31/**
32 * Helper class to serialize/unserialize things to/from JSON.
33 *
34 * @stable to type
35 * @since 1.36
36 * @package MediaWiki\Json
37 */
38class JsonCodec implements JsonUnserializer, JsonSerializer {
39
40    public function unserialize( $json, string $expectedClass = null ) {
41        Assert::parameterType( [ 'stdClass', 'array', 'string' ], $json, '$json' );
42        Assert::precondition(
43            !$expectedClass || is_subclass_of( $expectedClass, JsonUnserializable::class ),
44            '$expectedClass parameter must be subclass of JsonUnserializable, got ' . $expectedClass
45        );
46        if ( is_string( $json ) ) {
47            $jsonStatus = FormatJson::parse( $json, FormatJson::FORCE_ASSOC );
48            if ( !$jsonStatus->isGood() ) {
49                throw new JsonException( "Bad JSON: {$jsonStatus}" );
50            }
51            $json = $jsonStatus->getValue();
52        }
53
54        if ( $json instanceof stdClass ) {
55            $json = (array)$json;
56        }
57
58        if ( $this->containsComplexValue( $json ) ) {
59            // Recursively unserialize the array values before unserializing
60            // the array itself.
61            $json = $this->unserializeArray( $json );
62        }
63
64        if ( !$this->canMakeNewFromValue( $json ) ) {
65            if ( $expectedClass ) {
66                throw new JsonException( 'JSON did not have ' . JsonConstants::TYPE_ANNOTATION );
67            }
68            return $json;
69        }
70
71        $class = $json[JsonConstants::TYPE_ANNOTATION];
72        if ( $class == "ParserOutput" || $class == "MediaWiki\\Parser\\ParserOutput" ) {
73            $class = ParserOutput::class; // T353835
74        } elseif ( $class !== stdClass::class &&
75            !( class_exists( $class ) && is_subclass_of( $class, JsonUnserializable::class ) )
76        ) {
77            throw new JsonException( "Invalid target class {$class}" );
78        }
79
80        if ( $expectedClass && $class !== $expectedClass && !is_subclass_of( $class, $expectedClass ) ) {
81            throw new JsonException(
82                "Refusing to unserialize: expected $expectedClass, got $class"
83            );
84        }
85        if ( $class === stdClass::class ) {
86            unset( $json[JsonConstants::TYPE_ANNOTATION] );
87            return (object)$json;
88        }
89        return $class::newFromJsonArray( $this, $json );
90    }
91
92    public function unserializeArray( array $array ): array {
93        $unserializedExtensionData = [];
94        foreach ( $array as $key => $value ) {
95            if ( $key === JsonConstants::COMPLEX_ANNOTATION ) {
96                /* don't include this in the result */
97            } elseif (
98                $this->canMakeNewFromValue( $value ) ||
99                $this->containsComplexValue( $value )
100            ) {
101                $unserializedExtensionData[$key] = $this->unserialize( $value );
102            } else {
103                $unserializedExtensionData[$key] = $value;
104            }
105        }
106        return $unserializedExtensionData;
107    }
108
109    private function serializeOne( &$value ) {
110        if ( $value instanceof JsonSerializable ) {
111            $value = $value->jsonSerialize();
112            $value[JsonConstants::COMPLEX_ANNOTATION] = true;
113            // The returned array may still have instance of JsonSerializable,
114            // stdClass, or array, so fall through to recursively handle these.
115        } elseif ( is_object( $value ) && get_class( $value ) === stdClass::class ) {
116            // T312589: if $value is stdObject, mark the type
117            // so we unserialize as stdObject as well.
118            $value = (array)$value;
119            $value[JsonConstants::TYPE_ANNOTATION] = stdClass::class;
120            $value[JsonConstants::COMPLEX_ANNOTATION] = true;
121            // Fall through to handle the property values
122        }
123        if ( is_array( $value ) ) {
124            $is_complex = false;
125            // Recursively convert array values to serializable form
126            foreach ( $value as &$v ) {
127                if ( is_object( $v ) || is_array( $v ) ) {
128                    $v = $this->serializeOne( $v );
129                    if ( isset( $v[JsonConstants::COMPLEX_ANNOTATION] ) ) {
130                        $is_complex = true;
131                    }
132                }
133            }
134            if ( $is_complex ) {
135                $value[JsonConstants::COMPLEX_ANNOTATION] = true;
136            }
137        } elseif ( !is_scalar( $value ) && $value !== null ) {
138                throw new JsonException(
139                    'Unable to serialize JSON.'
140                );
141        }
142        return $value;
143    }
144
145    public function serialize( $value ) {
146        // Detect if the array contained any properties non-serializable
147        // to json.
148        // TODO: make detectNonSerializableData not choke on cyclic structures.
149        $unserializablePath = $this->detectNonSerializableDataInternal(
150            $value, false, '$'
151        );
152        if ( $unserializablePath ) {
153            throw new JsonException(
154                "Non-unserializable property set at {$unserializablePath}"
155            );
156        }
157        // Recursively convert stdClass and JsonSerializable
158        // to serializable arrays
159        $value = $this->serializeOne( $value );
160        // Format as JSON
161        $json = FormatJson::encode( $value, false, FormatJson::ALL_OK );
162        if ( !$json ) {
163            throw new JsonException(
164                'Failed to encode JSON. Error ' . json_last_error_msg()
165            );
166        }
167
168        return $json;
169    }
170
171    /**
172     * Is it likely possible to make a new instance from $json serialization?
173     * @param mixed $json
174     * @return bool
175     */
176    private function canMakeNewFromValue( $json ): bool {
177        $classAnnotation = JsonConstants::TYPE_ANNOTATION;
178        if ( is_array( $json ) ) {
179            return array_key_exists( $classAnnotation, $json ) &&
180                # T313818: conflict with ParserOutput::detectAndEncodeBinary()
181                $json[$classAnnotation] !== 'string';
182        }
183
184        if ( is_object( $json ) ) {
185            return property_exists( $json, $classAnnotation );
186        }
187        return false;
188    }
189
190    /**
191     * Does this serialized array contain a complex value (a serialized class
192     * or an array which itself contains a serialized class)?
193     * @param mixed $json
194     * @return bool
195     */
196    private function containsComplexValue( $json ): bool {
197        if ( is_array( $json ) ) {
198            return array_key_exists( JsonConstants::COMPLEX_ANNOTATION, $json );
199        }
200        return false;
201    }
202
203    /**
204     * Recursive check for ability to serialize $value to JSON via FormatJson::encode().
205     *
206     * @param mixed $value
207     * @param bool $expectUnserialize
208     * @param string $accumulatedPath
209     * @return string|null JSON path to first encountered non-serializable property or null.
210     */
211    private function detectNonSerializableDataInternal(
212        $value,
213        bool $expectUnserialize,
214        string $accumulatedPath
215    ): ?string {
216        if (
217            $this->canMakeNewFromValue( $value ) ||
218            $this->containsComplexValue( $value )
219        ) {
220            // Contains a conflicting use of JsonConstants::TYPE_ANNOTATION or
221            // JsonConstants::COMPLEX_ANNOTATION; in the future we might use
222            // an alternative encoding for these objects to allow them.
223            return $accumulatedPath;
224        }
225        if ( is_array( $value ) || (
226            is_object( $value ) && get_class( $value ) === stdClass::class
227        ) ) {
228            foreach ( $value as $key => &$propValue ) {
229                $propValueNonSerializablePath = $this->detectNonSerializableDataInternal(
230                    $propValue,
231                    $expectUnserialize,
232                    $accumulatedPath . '.' . $key
233                );
234                if ( $propValueNonSerializablePath ) {
235                    return $propValueNonSerializablePath;
236                }
237            }
238        } elseif (
239            ( $expectUnserialize && $value instanceof JsonUnserializable )
240            // Trust that JsonSerializable will correctly serialize.
241            || ( !$expectUnserialize && $value instanceof JsonSerializable )
242        ) {
243            return null;
244            // Instances of classes other the \stdClass or JsonSerializable can not be serialized to JSON.
245        } elseif ( !is_scalar( $value ) && $value !== null ) {
246            return $accumulatedPath;
247        }
248        return null;
249    }
250
251    /**
252     * Checks if the $value is JSON-serializable (contains only scalar values)
253     * and returns a JSON-path to the first non-serializable property encountered.
254     *
255     * @param mixed $value
256     * @param bool $expectUnserialize whether to expect the $value to be unserializable with JsonUnserializer.
257     * @return string|null JSON path to first encountered non-serializable property or null.
258     * @see JsonUnserializer
259     * @since 1.36
260     */
261    public function detectNonSerializableData( $value, bool $expectUnserialize = false ): ?string {
262        return $this->detectNonSerializableDataInternal( $value, $expectUnserialize, '$' );
263    }
264}