Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.89% covered (warning)
88.89%
104 / 117
33.33% covered (danger)
33.33%
4 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
JsonCodec
88.89% covered (warning)
88.89%
104 / 117
33.33% covered (danger)
33.33%
4 / 12
63.78
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 codecFor
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
5.01
 markArray
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isArrayMarked
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 unmarkArray
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 unserialize
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 deserialize
89.29% covered (warning)
89.29%
25 / 28
0.00% covered (danger)
0.00%
0 / 1
14.24
 unserializeArray
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 deserializeArray
33.33% covered (danger)
33.33%
1 / 3
0.00% covered (danger)
0.00%
0 / 1
3.19
 serialize
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
4.05
 detectNonSerializableDataInternal
93.94% covered (success)
93.94%
31 / 33
0.00% covered (danger)
0.00%
0 / 1
18.07
 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 InvalidArgumentException;
25use JsonException;
26use JsonSerializable;
27use Psr\Container\ContainerInterface;
28use ReflectionClass;
29use stdClass;
30use Throwable;
31use Wikimedia\Assert\Assert;
32use Wikimedia\JsonCodec\JsonClassCodec;
33use 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 */
42class 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}