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.
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.
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
22 namespace MediaWiki\Json
;
24 use InvalidArgumentException
;
27 use Psr\Container\ContainerInterface
;
31 use Wikimedia\Assert\Assert
;
32 use Wikimedia\JsonCodec\JsonClassCodec
;
33 use Wikimedia\JsonCodec\JsonCodecable
;
36 * Helper class to serialize/deserialize things to/from JSON.
40 * @package MediaWiki\Json
43 extends \Wikimedia\JsonCodec\JsonCodec
44 implements JsonDeserializer
, JsonSerializer
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)
52 private bool $backCompat = true;
55 * Create a new JsonCodec, with optional access to the provided services.
57 public function __construct( ?ContainerInterface
$services = null ) {
58 parent
::__construct( $services );
62 * Support the JsonCodecable interface by maintaining a mapping of
63 * class names to codecs.
64 * @param class-string $className
65 * @return ?JsonClassCodec
67 protected function codecFor( string $className ): ?JsonClassCodec
{
68 static $deserializableCodec = null;
69 static $serializableCodec = null;
70 $codec = parent
::codecFor( $className );
71 if ( $codec !== null ) {
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 );
81 $codec = $deserializableCodec;
82 $this->addCodecFor( $className, $codec );
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 );
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
] );
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 ) ) {
113 if ( ( $value['_type_'] ??
null ) === 'string' ) {
114 // T313818: see ParserOutput::detectAndEncodeBinary()
117 return parent
::isArrayMarked( $value );
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'
126 $classHint === null &&
127 array_key_exists( JsonConstants
::COMPLEX_ANNOTATION
, $value )
129 $classHint = 'array';
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
] );
140 /** @deprecated since 1.43; use ::deserialize() */
141 public function unserialize( $json, ?
string $expectedClass = null ) {
142 return $this->deserialize( $json, $expectedClass );
145 public function deserialize( $json, ?
string $expectedClass = null ) {
146 Assert
::parameterType( [ 'stdClass', 'array', 'string' ], $json, '$json' );
147 Assert
::precondition(
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
153 if ( is_string( $json ) ) {
154 $jsonStatus = FormatJson
::parse( $json, FormatJson
::FORCE_ASSOC
);
155 if ( !$jsonStatus->isGood() ) {
156 throw new JsonException( "Bad JSON: {$jsonStatus}" );
158 $json = $jsonStatus->getValue();
161 if ( $json instanceof stdClass
) {
162 $json = (array)$json;
165 if ( $expectedClass !== null ) {
166 // Make copy of $json to avoid unmarking the 'real thing'
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}" );
176 $got = get_debug_type( $json );
177 throw new JsonException( "Expected {$expectedClass} got {$got}" );
181 $result = is_array( $json ) ?
$this->newFromJsonArray( $json ) : $json;
182 } catch ( InvalidArgumentException
$e ) {
183 throw new JsonException( $e->getMessage() );
185 if ( $expectedClass && !is_a( $result, $expectedClass, false ) ) {
186 throw new JsonException( "Unexpected class: {$expectedClass}" );
191 /** @deprecated since 1.43; use ::deserializeArray() */
192 public function unserializeArray( array $array ): array {
193 return $this->deserializeArray( $array );
196 public function deserializeArray( array $array ): array {
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() );
206 public function serialize( $value ) {
207 // Recursively convert stdClass, JsonSerializable, and JsonCodecable
208 // to serializable arrays
210 $value = $this->toJsonArray( $value );
211 } catch ( InvalidArgumentException
$e ) {
212 throw new JsonException( $e->getMessage() );
215 $json = FormatJson
::encode( $value, false, FormatJson
::ALL_OK
);
218 // Try to collect more information on the failure.
219 $details = $this->detectNonSerializableData( $value );
220 } catch ( Throwable
$t ) {
221 $details = $t->getMessage();
223 throw new JsonException(
224 'Failed to encode JSON. ' .
225 'Error: ' . json_last_error_msg() . '. ' .
226 'Details: ' . $details
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.
238 * Recursive check for the ability to serialize $value to JSON via FormatJson::encode().
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.
247 private function detectNonSerializableDataInternal(
249 bool $expectDeserialize,
250 string $accumulatedPath,
251 bool $exhaustive = false
254 ( is_array( $value ) && $this->isArrayMarked( $value ) ) ||
255 ( $value instanceof stdClass
&& $this->isArrayMarked( (array)$value ) )
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';
262 if ( is_object( $value ) ) {
263 if ( get_class( $value ) === stdClass
::class ) {
264 $value = (array)$value;
265 } elseif ( $value instanceof JsonCodecable
) {
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 );
272 // Assume that serializable objects contain 100%
273 // serializable contents in their representation.
278 $value instanceof JsonDeserializable
:
279 $value instanceof JsonSerializable
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";
290 // Assume that serializable objects contain 100%
291 // serializable contents in their representation.
295 // Instances of classes other the \stdClass or JsonSerializable cannot be serialized to JSON.
296 return $accumulatedPath . ': ' . get_debug_type( $value );
299 if ( is_array( $value ) ) {
300 foreach ( $value as $key => $propValue ) {
301 $propValueNonSerializablePath = $this->detectNonSerializableDataInternal(
304 $accumulatedPath . '.' . $key,
307 if ( $propValueNonSerializablePath !== null ) {
308 return $propValueNonSerializablePath;
311 } elseif ( !is_scalar( $value ) && $value !== null ) {
312 return $accumulatedPath . ': nonscalar ' . get_debug_type( $value );
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.
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
327 public function detectNonSerializableData( $value, bool $expectDeserialize = false ): ?
string {
328 return $this->detectNonSerializableDataInternal( $value, $expectDeserialize, '$', true );