Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
88.89% |
104 / 117 |
|
33.33% |
4 / 12 |
CRAP | |
0.00% |
0 / 1 |
JsonCodec | |
88.89% |
104 / 117 |
|
33.33% |
4 / 12 |
63.78 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
codecFor | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
5.01 | |||
markArray | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
isArrayMarked | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
4.13 | |||
unmarkArray | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
5 | |||
unserialize | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
deserialize | |
89.29% |
25 / 28 |
|
0.00% |
0 / 1 |
14.24 | |||
unserializeArray | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
deserializeArray | |
33.33% |
1 / 3 |
|
0.00% |
0 / 1 |
3.19 | |||
serialize | |
85.71% |
12 / 14 |
|
0.00% |
0 / 1 |
4.05 | |||
detectNonSerializableDataInternal | |
93.94% |
31 / 33 |
|
0.00% |
0 / 1 |
18.07 | |||
detectNonSerializableData | |
100.00% |
1 / 1 |
|
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 | |
22 | namespace MediaWiki\Json; |
23 | |
24 | use InvalidArgumentException; |
25 | use JsonException; |
26 | use JsonSerializable; |
27 | use Psr\Container\ContainerInterface; |
28 | use ReflectionClass; |
29 | use stdClass; |
30 | use Throwable; |
31 | use Wikimedia\Assert\Assert; |
32 | use Wikimedia\JsonCodec\JsonClassCodec; |
33 | use Wikimedia\JsonCodec\JsonCodecable; |
34 | |
35 | /** |
36 | * Helper class to serialize/deserialize things to/from JSON. |
37 | * |
38 | * @stable to type |
39 | * @since 1.36 |
40 | * @package MediaWiki\Json |
41 | */ |
42 | class JsonCodec |
43 | extends \Wikimedia\JsonCodec\JsonCodec |
44 | implements JsonDeserializer, JsonSerializer |
45 | { |
46 | |
47 | /** |
48 | * When true, add extra properties to the serialized output for |
49 | * backwards compatibility. This will eventually be made a |
50 | * configuration variable and/or removed. (T367584) |
51 | */ |
52 | private bool $backCompat = true; |
53 | |
54 | /** |
55 | * Create a new JsonCodec, with optional access to the provided services. |
56 | */ |
57 | public function __construct( ?ContainerInterface $services = null ) { |
58 | parent::__construct( $services ); |
59 | } |
60 | |
61 | /** |
62 | * Support the JsonCodecable interface by maintaining a mapping of |
63 | * class names to codecs. |
64 | * @param class-string $className |
65 | * @return ?JsonClassCodec |
66 | */ |
67 | protected function codecFor( string $className ): ?JsonClassCodec { |
68 | static $deserializableCodec = null; |
69 | static $serializableCodec = null; |
70 | $codec = parent::codecFor( $className ); |
71 | if ( $codec !== null ) { |
72 | return $codec; |
73 | } |
74 | // Resolve class aliases to ensure we don't use split codecs |
75 | $className = ( new ReflectionClass( $className ) )->getName(); |
76 | // Provide a codec for JsonDeserializable objects |
77 | if ( is_a( $className, JsonDeserializable::class, true ) ) { |
78 | if ( $deserializableCodec === null ) { |
79 | $deserializableCodec = new JsonDeserializableCodec( $this ); |
80 | } |
81 | $codec = $deserializableCodec; |
82 | $this->addCodecFor( $className, $codec ); |
83 | return $codec; |
84 | } |
85 | // Provide a codec for JsonSerializable objects: |
86 | // NOTE this is for compatibility only and does not deserialize! |
87 | if ( is_a( $className, JsonSerializable::class, true ) ) { |
88 | $codec = JsonSerializableCodec::getInstance(); |
89 | $this->addCodecFor( $className, $codec ); |
90 | return $codec; |
91 | } |
92 | return null; |
93 | } |
94 | |
95 | /** @inheritDoc */ |
96 | protected function markArray( array &$value, string $className, ?string $classHint ): void { |
97 | parent::markArray( $value, $className, $classHint ); |
98 | // Temporarily for backward compatibility add COMPLEX_ANNOTATION as well |
99 | if ( $this->backCompat ) { |
100 | $value[JsonConstants::COMPLEX_ANNOTATION] = true; |
101 | if ( ( $value[JsonConstants::TYPE_ANNOTATION] ?? null ) === 'array' ) { |
102 | unset( $value[JsonConstants::TYPE_ANNOTATION] ); |
103 | } |
104 | } |
105 | } |
106 | |
107 | /** @inheritDoc */ |
108 | protected function isArrayMarked( array $value ): bool { |
109 | // Temporarily for backward compatibility look for COMPLEX_ANNOTATION as well |
110 | if ( $this->backCompat && array_key_exists( JsonConstants::COMPLEX_ANNOTATION, $value ) ) { |
111 | return true; |
112 | } |
113 | if ( ( $value['_type_'] ?? null ) === 'string' ) { |
114 | // T313818: see ParserOutput::detectAndEncodeBinary() |
115 | return false; |
116 | } |
117 | return parent::isArrayMarked( $value ); |
118 | } |
119 | |
120 | /** @inheritDoc */ |
121 | protected function unmarkArray( array &$value, ?string $classHint ): string { |
122 | // Temporarily use the presence of COMPLEX_ANNOTATION as a hint that |
123 | // the type is 'array' |
124 | if ( |
125 | $this->backCompat && |
126 | $classHint === null && |
127 | array_key_exists( JsonConstants::COMPLEX_ANNOTATION, $value ) |
128 | ) { |
129 | $classHint = 'array'; |
130 | } |
131 | // @phan-suppress-next-line PhanUndeclaredClassReference 'array' |
132 | $className = parent::unmarkArray( $value, $classHint ); |
133 | // Remove the temporarily added COMPLEX_ANNOTATION |
134 | if ( $this->backCompat ) { |
135 | unset( $value[JsonConstants::COMPLEX_ANNOTATION] ); |
136 | } |
137 | return $className; |
138 | } |
139 | |
140 | /** @deprecated since 1.43; use ::deserialize() */ |
141 | public function unserialize( $json, ?string $expectedClass = null ) { |
142 | return $this->deserialize( $json, $expectedClass ); |
143 | } |
144 | |
145 | public function deserialize( $json, ?string $expectedClass = null ) { |
146 | Assert::parameterType( [ 'stdClass', 'array', 'string' ], $json, '$json' ); |
147 | Assert::precondition( |
148 | !$expectedClass || |
149 | is_subclass_of( $expectedClass, JsonDeserializable::class ) || |
150 | is_subclass_of( $expectedClass, JsonCodecable::class ), |
151 | '$expectedClass parameter must be subclass of JsonDeserializable or JsonCodecable, got ' . $expectedClass |
152 | ); |
153 | if ( is_string( $json ) ) { |
154 | $jsonStatus = FormatJson::parse( $json, FormatJson::FORCE_ASSOC ); |
155 | if ( !$jsonStatus->isGood() ) { |
156 | throw new JsonException( "Bad JSON: {$jsonStatus}" ); |
157 | } |
158 | $json = $jsonStatus->getValue(); |
159 | } |
160 | |
161 | if ( $json instanceof stdClass ) { |
162 | $json = (array)$json; |
163 | } |
164 | |
165 | if ( $expectedClass !== null ) { |
166 | // Make copy of $json to avoid unmarking the 'real thing' |
167 | $jsonCopy = $json; |
168 | if ( is_array( $jsonCopy ) && $this->isArrayMarked( $jsonCopy ) ) { |
169 | $got = $this->unmarkArray( $jsonCopy, $expectedClass ); |
170 | // Compare $got to $expectedClass in a way that works in the |
171 | // presence of aliases |
172 | if ( !is_a( $got, $expectedClass, true ) ) { |
173 | throw new JsonException( "Expected {$expectedClass} got {$got}" ); |
174 | } |
175 | } else { |
176 | $got = get_debug_type( $json ); |
177 | throw new JsonException( "Expected {$expectedClass} got {$got}" ); |
178 | } |
179 | } |
180 | try { |
181 | $result = is_array( $json ) ? $this->newFromJsonArray( $json ) : $json; |
182 | } catch ( InvalidArgumentException $e ) { |
183 | throw new JsonException( $e->getMessage() ); |
184 | } |
185 | if ( $expectedClass && !is_a( $result, $expectedClass, false ) ) { |
186 | throw new JsonException( "Unexpected class: {$expectedClass}" ); |
187 | } |
188 | return $result; |
189 | } |
190 | |
191 | /** @deprecated since 1.43; use ::deserializeArray() */ |
192 | public function unserializeArray( array $array ): array { |
193 | return $this->deserializeArray( $array ); |
194 | } |
195 | |
196 | public function deserializeArray( array $array ): array { |
197 | try { |
198 | // Pass a class hint here to ensure we recurse into the array. |
199 | // @phan-suppress-next-line PhanUndeclaredClassReference 'array' |
200 | return $this->newFromJsonArray( $array, 'array' ); |
201 | } catch ( InvalidArgumentException $e ) { |
202 | throw new JsonException( $e->getMessage() ); |
203 | } |
204 | } |
205 | |
206 | public function serialize( $value ) { |
207 | // Recursively convert stdClass, JsonSerializable, and JsonCodecable |
208 | // to serializable arrays |
209 | try { |
210 | $value = $this->toJsonArray( $value ); |
211 | } catch ( InvalidArgumentException $e ) { |
212 | throw new JsonException( $e->getMessage() ); |
213 | } |
214 | // Format as JSON |
215 | $json = FormatJson::encode( $value, false, FormatJson::ALL_OK ); |
216 | if ( !$json ) { |
217 | try { |
218 | // Try to collect more information on the failure. |
219 | $details = $this->detectNonSerializableData( $value ); |
220 | } catch ( Throwable $t ) { |
221 | $details = $t->getMessage(); |
222 | } |
223 | throw new JsonException( |
224 | 'Failed to encode JSON. ' . |
225 | 'Error: ' . json_last_error_msg() . '. ' . |
226 | 'Details: ' . $details |
227 | ); |
228 | } |
229 | |
230 | return $json; |
231 | } |
232 | |
233 | // The code below this point is used only for diagnostics; in particular |
234 | // for the ::detectNonSerializableData() method which is used to provide |
235 | // debugging information in the event of a serialization failure. |
236 | |
237 | /** |
238 | * Recursive check for the ability to serialize $value to JSON via FormatJson::encode(). |
239 | * |
240 | * @param mixed $value |
241 | * @param bool $expectDeserialize |
242 | * @param string $accumulatedPath |
243 | * @param bool $exhaustive Whether to (slowly) completely traverse the |
244 | * $value in order to find the precise location of a problem |
245 | * @return string|null JSON path to the first encountered non-serializable property or null. |
246 | */ |
247 | private function detectNonSerializableDataInternal( |
248 | $value, |
249 | bool $expectDeserialize, |
250 | string $accumulatedPath, |
251 | bool $exhaustive = false |
252 | ): ?string { |
253 | if ( |
254 | ( is_array( $value ) && $this->isArrayMarked( $value ) ) || |
255 | ( $value instanceof stdClass && $this->isArrayMarked( (array)$value ) ) |
256 | ) { |
257 | // Contains a conflicting use of JsonConstants::TYPE_ANNOTATION or |
258 | // JsonConstants::COMPLEX_ANNOTATION; in the future we might use |
259 | // an alternative encoding for these objects to allow them. |
260 | return $accumulatedPath . ': conflicting use of protected property'; |
261 | } |
262 | if ( is_object( $value ) ) { |
263 | if ( get_class( $value ) === stdClass::class ) { |
264 | $value = (array)$value; |
265 | } elseif ( $value instanceof JsonCodecable ) { |
266 | if ( $exhaustive ) { |
267 | // Call the appropriate serialization method and recurse to |
268 | // ensure contents are also serializable. |
269 | $codec = $this->codecFor( get_class( $value ) ); |
270 | $value = $codec->toJsonArray( $value ); |
271 | } else { |
272 | // Assume that serializable objects contain 100% |
273 | // serializable contents in their representation. |
274 | return null; |
275 | } |
276 | } elseif ( |
277 | $expectDeserialize ? |
278 | $value instanceof JsonDeserializable : |
279 | $value instanceof JsonSerializable |
280 | ) { |
281 | if ( $exhaustive ) { |
282 | // Call the appropriate serialization method and recurse to |
283 | // ensure contents are also serializable. |
284 | '@phan-var JsonSerializable $value'; |
285 | $value = $value->jsonSerialize(); |
286 | if ( !is_array( $value ) ) { |
287 | return $accumulatedPath . ": jsonSerialize didn't return array"; |
288 | } |
289 | } else { |
290 | // Assume that serializable objects contain 100% |
291 | // serializable contents in their representation. |
292 | return null; |
293 | } |
294 | } else { |
295 | // Instances of classes other the \stdClass or JsonSerializable cannot be serialized to JSON. |
296 | return $accumulatedPath . ': ' . get_debug_type( $value ); |
297 | } |
298 | } |
299 | if ( is_array( $value ) ) { |
300 | foreach ( $value as $key => $propValue ) { |
301 | $propValueNonSerializablePath = $this->detectNonSerializableDataInternal( |
302 | $propValue, |
303 | $expectDeserialize, |
304 | $accumulatedPath . '.' . $key, |
305 | $exhaustive |
306 | ); |
307 | if ( $propValueNonSerializablePath !== null ) { |
308 | return $propValueNonSerializablePath; |
309 | } |
310 | } |
311 | } elseif ( !is_scalar( $value ) && $value !== null ) { |
312 | return $accumulatedPath . ': nonscalar ' . get_debug_type( $value ); |
313 | } |
314 | return null; |
315 | } |
316 | |
317 | /** |
318 | * Checks if the $value is JSON-serializable (contains only scalar values) |
319 | * and returns a JSON-path to the first non-serializable property encountered. |
320 | * |
321 | * @param mixed $value |
322 | * @param bool $expectDeserialize whether to expect the $value to be deserializable with JsonDeserializer. |
323 | * @return string|null JSON path to the first encountered non-serializable property or null. |
324 | * @see JsonDeserializer |
325 | * @since 1.36 |
326 | */ |
327 | public function detectNonSerializableData( $value, bool $expectDeserialize = false ): ?string { |
328 | return $this->detectNonSerializableDataInternal( $value, $expectDeserialize, '$', true ); |
329 | } |
330 | } |