Merge "docs: Fix typo"
[mediawiki.git] / includes / json / JsonCodec.php
blob47f697fb7138dbd6e3c4b26df42c7ee8eb8faa01
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.
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
18 * @file
19 * @ingroup Json
22 namespace MediaWiki\Json;
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;
35 /**
36 * Helper class to serialize/deserialize things to/from JSON.
38 * @stable to type
39 * @since 1.36
40 * @package MediaWiki\Json
42 class JsonCodec
43 extends \Wikimedia\JsonCodec\JsonCodec
44 implements JsonDeserializer, JsonSerializer
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)
52 private bool $backCompat = true;
54 /**
55 * Create a new JsonCodec, with optional access to the provided services.
57 public function __construct( ?ContainerInterface $services = null ) {
58 parent::__construct( $services );
61 /**
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 ) {
72 return $codec;
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 );
83 return $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 );
90 return $codec;
92 return null;
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] );
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;
113 if ( ( $value['_type_'] ?? null ) === 'string' ) {
114 // T313818: see ParserOutput::detectAndEncodeBinary()
115 return false;
117 return parent::isArrayMarked( $value );
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 )
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] );
137 return $className;
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(
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
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'
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}" );
175 } else {
176 $got = get_debug_type( $json );
177 throw new JsonException( "Expected {$expectedClass} got {$got}" );
180 try {
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}" );
188 return $result;
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 {
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() );
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() );
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();
223 throw new JsonException(
224 'Failed to encode JSON. ' .
225 'Error: ' . json_last_error_msg() . '. ' .
226 'Details: ' . $details
230 return $json;
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(
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 ) )
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 ) {
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;
276 } elseif (
277 $expectDeserialize ?
278 $value instanceof JsonDeserializable :
279 $value instanceof JsonSerializable
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";
289 } else {
290 // Assume that serializable objects contain 100%
291 // serializable contents in their representation.
292 return null;
294 } else {
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(
302 $propValue,
303 $expectDeserialize,
304 $accumulatedPath . '.' . $key,
305 $exhaustive
307 if ( $propValueNonSerializablePath !== null ) {
308 return $propValueNonSerializablePath;
311 } elseif ( !is_scalar( $value ) && $value !== null ) {
312 return $accumulatedPath . ': nonscalar ' . get_debug_type( $value );
314 return null;
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
325 * @since 1.36
327 public function detectNonSerializableData( $value, bool $expectDeserialize = false ): ?string {
328 return $this->detectNonSerializableDataInternal( $value, $expectDeserialize, '$', true );