Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
9.62% |
10 / 104 |
|
20.00% |
2 / 10 |
CRAP | |
0.00% |
0 / 1 |
JsonCodec | |
9.62% |
10 / 104 |
|
20.00% |
2 / 10 |
1895.97 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
toJsonString | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
newFromJsonString | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
codecFor | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
addCodecFor | |
40.00% |
2 / 5 |
|
0.00% |
0 / 1 |
2.86 | |||
toJsonArray | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
342 | |||
newFromJsonArray | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
240 | |||
isArrayMarked | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
markArray | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
42 | |||
unmarkArray | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | declare( strict_types=1 ); |
3 | |
4 | /** |
5 | * This program is free software; you can redistribute it and/or modify |
6 | * it under the terms of the GNU General Public License as published by |
7 | * the Free Software Foundation; either version 2 of the License, or |
8 | * (at your option) any later version. |
9 | * |
10 | * This program is distributed in the hope that it will be useful, |
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
13 | * GNU General Public License for more details. |
14 | * |
15 | * You should have received a copy of the GNU General Public License along |
16 | * with this program; if not, write to the Free Software Foundation, Inc., |
17 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
18 | * http://www.gnu.org/copyleft/gpl.html |
19 | * |
20 | * @file |
21 | */ |
22 | |
23 | namespace Wikimedia\JsonCodec; |
24 | |
25 | use Exception; |
26 | use InvalidArgumentException; |
27 | use Psr\Container\ContainerInterface; |
28 | use Psr\Container\NotFoundExceptionInterface; |
29 | use stdClass; |
30 | |
31 | /** |
32 | * Helper class to serialize/unserialize things to/from JSON. |
33 | */ |
34 | class JsonCodec implements JsonCodecInterface { |
35 | /** @var ContainerInterface Service container */ |
36 | protected ContainerInterface $serviceContainer; |
37 | |
38 | /** @var array<class-string,JsonClassCodec> Class codecs */ |
39 | protected array $codecs; |
40 | |
41 | /** |
42 | * Name of the property where class information is stored; it also |
43 | * is used to mark "complex" arrays, and as a place to store the contents |
44 | * of any pre-existing array property that happened to have the same name. |
45 | */ |
46 | protected const TYPE_ANNOTATION = '_type_'; |
47 | |
48 | /** |
49 | * @param ?ContainerInterface $serviceContainer |
50 | */ |
51 | public function __construct( ?ContainerInterface $serviceContainer = null ) { |
52 | $this->serviceContainer = $serviceContainer ?? |
53 | // Use an empty container if none is provided. |
54 | new class implements ContainerInterface { |
55 | /** |
56 | * @param string $id |
57 | * @return never |
58 | */ |
59 | public function get( $id ) { |
60 | throw new class( "not found" ) extends Exception implements NotFoundExceptionInterface { |
61 | }; |
62 | } |
63 | |
64 | /** @inheritDoc */ |
65 | public function has( string $id ): bool { |
66 | return false; |
67 | } |
68 | }; |
69 | $this->addCodecFor( |
70 | stdClass::class, JsonStdClassCodec::getInstance() |
71 | ); |
72 | } |
73 | |
74 | /** |
75 | * Recursively converts a given object to a JSON-encoded string. |
76 | * While serializing the $value JsonCodec delegates to the appropriate |
77 | * JsonClassCodecs of any classes which implement JsonCodecable. |
78 | * |
79 | * If a $classHint is provided and matches the type of the value, |
80 | * then type information will not be included in the generated JSON; |
81 | * otherwise an appropriate class name will be added to the JSON to |
82 | * guide deserialization. |
83 | * |
84 | * @param mixed|null $value |
85 | * @param ?class-string $classHint An optional hint to |
86 | * the type of the encoded object. If this is provided and matches |
87 | * the type of $value, then explicit type information will be omitted |
88 | * from the generated JSON, which saves some space. |
89 | * @return string |
90 | */ |
91 | public function toJsonString( $value, ?string $classHint = null ): string { |
92 | return json_encode( |
93 | $this->toJsonArray( $value, $classHint ), |
94 | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | |
95 | JSON_HEX_TAG | JSON_HEX_AMP |
96 | ); |
97 | } |
98 | |
99 | /** |
100 | * Recursively converts a JSON-encoded string to an object value or scalar. |
101 | * While deserializing the $json JsonCodec delegates to the appropriate |
102 | * JsonClassCodecs of any classes which implement JsonCodecable. |
103 | * |
104 | * For objects encoded using implicit class information, a "class hint" |
105 | * can be provided to guide deserialization; this is unnecessary for |
106 | * objects serialized with explicit classes. |
107 | * |
108 | * @param string $json A JSON-encoded string |
109 | * @param ?class-string $classHint An optional hint to |
110 | * the type of the encoded object. In the absence of explicit |
111 | * type information in the JSON, this will be used as the type of |
112 | * the created object. |
113 | * @return mixed|null |
114 | */ |
115 | public function newFromJsonString( $json, ?string $classHint = null ) { |
116 | return $this->newFromJsonArray( |
117 | json_decode( $json, true ), $classHint |
118 | ); |
119 | } |
120 | |
121 | /** |
122 | * Maintain a cache giving the codec for a given class name. |
123 | * |
124 | * Reusing this JsonCodec object will also reuse this cache, which |
125 | * could improve performance somewhat. |
126 | * |
127 | * @param class-string $className |
128 | * @return ?JsonClassCodec a codec for the class, or null if the class is |
129 | * not serializable. |
130 | */ |
131 | protected function codecFor( string $className ): ?JsonClassCodec { |
132 | $codec = $this->codecs[$className] ?? null; |
133 | if ( !$codec ) { |
134 | if ( !is_a( $className, JsonCodecable::class, true ) ) { |
135 | return null; |
136 | } |
137 | $codec = $this->codecs[$className] = |
138 | $className::jsonClassCodec( $this, $this->serviceContainer ); |
139 | } |
140 | return $codec; |
141 | } |
142 | |
143 | /** |
144 | * Allow the use of a customized encoding for the given class; the given |
145 | * className need not be a JsonCodecable and if it *does* correspond to |
146 | * a JsonCodecable it will override the class codec specified by the |
147 | * JsonCodecable. |
148 | * @param class-string $className |
149 | * @param JsonClassCodec $codec A codec to use for $className |
150 | */ |
151 | public function addCodecFor( string $className, JsonClassCodec $codec ): void { |
152 | if ( $this->codecs[$className] ?? false ) { |
153 | throw new InvalidArgumentException( |
154 | "Codec already present for $className" |
155 | ); |
156 | } |
157 | $this->codecs[$className] = $codec; |
158 | } |
159 | |
160 | /** |
161 | * Recursively converts a given object to an associative array |
162 | * which can be json-encoded. (When embedding an object into |
163 | * another context it is sometimes useful to have the array |
164 | * representation rather than the string JSON form of the array; |
165 | * this can also be useful if you want to pretty-print the result, |
166 | * etc.) While converting $value the JsonCodec delegates to the |
167 | * appropriate JsonClassCodecs of any classes which implement |
168 | * JsonCodecable. |
169 | * |
170 | * If a $classHint is provided and matches the type of the value, |
171 | * then type information will not be included in the generated JSON; |
172 | * otherwise an appropriate class name will be added to the JSON to |
173 | * guide deserialization. |
174 | * |
175 | * @param mixed|null $value |
176 | * @param ?class-string $classHint An optional hint to |
177 | * the type of the encoded object. If this is provided and matches |
178 | * the type of $value, then explicit type information will be omitted |
179 | * from the generated JSON, which saves some space. |
180 | * @return mixed|null |
181 | */ |
182 | public function toJsonArray( $value, ?string $classHint = null ) { |
183 | $is_complex = false; |
184 | $className = 'array'; |
185 | $codec = null; |
186 | // Adjust class hint for arrays. |
187 | $arrayClassHint = null; |
188 | if ( $classHint !== null && str_ends_with( $classHint, '[]' ) ) { |
189 | $arrayClassHint = substr( $classHint, 0, -2 ); |
190 | $classHint = 'array'; |
191 | } |
192 | if ( is_object( $value ) ) { |
193 | $className = get_class( $value ); |
194 | $codec = $this->codecFor( $className ); |
195 | if ( $codec !== null ) { |
196 | $value = $codec->toJsonArray( $value ); |
197 | $is_complex = true; |
198 | } |
199 | } elseif ( |
200 | is_array( $value ) && $this->isArrayMarked( $value ) |
201 | ) { |
202 | $is_complex = true; |
203 | } |
204 | if ( is_array( $value ) ) { |
205 | // Recursively convert array values to serializable form |
206 | foreach ( $value as $key => &$v ) { |
207 | if ( is_object( $v ) || is_array( $v ) ) { |
208 | $propClassHint = $codec === null ? $arrayClassHint : |
209 | // phan can't tell that $codec is null when $className is 'array' |
210 | // @phan-suppress-next-line PhanUndeclaredClassReference |
211 | $codec->jsonClassHintFor( $className, (string)$key ); |
212 | $v = $this->toJsonArray( $v, $propClassHint ); |
213 | if ( |
214 | $this->isArrayMarked( $v ) || |
215 | $propClassHint !== null |
216 | ) { |
217 | // an array which contains complex components is |
218 | // itself complex. |
219 | $is_complex = true; |
220 | } |
221 | } |
222 | } |
223 | // Ok, now mark the array, being careful to transfer away |
224 | // any fields with the same names as our markers. |
225 | if ( $is_complex || $classHint !== null ) { |
226 | // Even if $className === $classHint we need to record this |
227 | // array as "complex" (ie, requires recursion to process |
228 | // individual values during deserialization) |
229 | // @phan-suppress-next-line PhanUndeclaredClassReference 'array' |
230 | $this->markArray( |
231 | $value, $className, $classHint |
232 | ); |
233 | } |
234 | } elseif ( !is_scalar( $value ) && $value !== null ) { |
235 | throw new InvalidArgumentException( |
236 | 'Unable to serialize JSON.' |
237 | ); |
238 | } |
239 | return $value; |
240 | } |
241 | |
242 | /** |
243 | * Recursively converts an associative array (or scalar) to an |
244 | * object value (or scalar). While converting this value JsonCodec |
245 | * delegates to the appropriate JsonClassCodecs of any classes which |
246 | * implement JsonCodecable. |
247 | * |
248 | * For objects encoded using implicit class information, a "class hint" |
249 | * can be provided to guide deserialization; this is unnecessary for |
250 | * objects serialized with explicit classes. |
251 | * |
252 | * @param mixed|null $json |
253 | * @param ?class-string $classHint An optional hint to |
254 | * the type of the encoded object. In the absence of explicit |
255 | * type information in the JSON, this will be used as the type of |
256 | * the created object. |
257 | * @return mixed|null |
258 | */ |
259 | public function newFromJsonArray( $json, ?string $classHint = null ) { |
260 | if ( $json instanceof stdClass ) { |
261 | // We *shouldn't* be given an object... but we might. |
262 | $json = (array)$json; |
263 | } |
264 | // Adjust class hint for arrays. |
265 | $arrayClassHint = null; |
266 | if ( $classHint !== null && str_ends_with( $classHint, '[]' ) ) { |
267 | $arrayClassHint = substr( $classHint, 0, -2 ); |
268 | $classHint = 'array'; |
269 | } |
270 | // Is this an array containing a complex value? |
271 | if ( |
272 | is_array( $json ) && ( |
273 | $this->isArrayMarked( $json ) || $classHint !== null |
274 | ) |
275 | ) { |
276 | // Read out our metadata |
277 | // @phan-suppress-next-line PhanUndeclaredClassReference 'array' |
278 | $className = $this->unmarkArray( $json, $classHint ); |
279 | // Create appropriate codec |
280 | $codec = null; |
281 | if ( $className !== 'array' ) { |
282 | $codec = $this->codecFor( $className ); |
283 | if ( $codec === null ) { |
284 | throw new InvalidArgumentException( |
285 | "Unable to deserialize JSON for $className" |
286 | ); |
287 | } |
288 | } |
289 | // Recursively unserialize the array contents. |
290 | $unserialized = []; |
291 | foreach ( $json as $key => $value ) { |
292 | $propClassHint = $codec === null ? $arrayClassHint : |
293 | // phan can't tell that $codec is null when $className is 'array' |
294 | // @phan-suppress-next-line PhanUndeclaredClassReference |
295 | $codec->jsonClassHintFor( $className, (string)$key ); |
296 | if ( |
297 | is_array( $value ) && ( |
298 | $this->isArrayMarked( $value ) || $propClassHint !== null |
299 | ) |
300 | ) { |
301 | $unserialized[$key] = $this->newFromJsonArray( $value, $propClassHint ); |
302 | } else { |
303 | $unserialized[$key] = $value; |
304 | } |
305 | } |
306 | // Use a JsonCodec to create the object instance if appropriate. |
307 | if ( $className === 'array' ) { |
308 | $json = $unserialized; |
309 | } else { |
310 | $json = $codec->newFromJsonArray( $className, $unserialized ); |
311 | } |
312 | } |
313 | return $json; |
314 | } |
315 | |
316 | // Functions to mark/unmark arrays and record a class name using a |
317 | // single reserved field, named by self::TYPE_ANNOTATION. A |
318 | // subclass can provide alternate implementations of these methods |
319 | // if it wants to use a different reserved field or else wishes to |
320 | // reserve more fields/encode certain types more compactly/flag |
321 | // certain types of values. For example: a subclass could choose |
322 | // to discard all hints in `markArray` in order to explicitly mark |
323 | // all types in preparation for a format change; or all values of |
324 | // type DocumentFragment might get a marker flag added so they can |
325 | // be identified without knowledge of the class hint; or perhaps a |
326 | // separate schema can be used to record class names more |
327 | // compactly. |
328 | |
329 | /** |
330 | * Determine if the given value is "marked"; that is, either |
331 | * represents a object type encoded using a JsonClassCodec or else |
332 | * is an array which contains values (or contains arrays |
333 | * containing values, etc) which are object types. The values of |
334 | * unmarked arrays are not decoded, in order to speed up the |
335 | * decoding process. Arrays may also be marked even if they do |
336 | * not represent object types (or an array recursively containing |
337 | * them) if they contain keys that need to be escaped ("false |
338 | * marks"); as such this method is called both on the raw results |
339 | * of JsonClassCodec (to check for "false marks") as well as on |
340 | * encoded arrays (to find "true marks"). |
341 | * |
342 | * Arrays do not have to be marked if the decoder has a class hint. |
343 | * |
344 | * @param array $value An array result from `JsonClassCodec::toJsonArray()`, |
345 | * or an array result from `::markArray()` |
346 | * @return bool Whether the $value is marked |
347 | */ |
348 | protected function isArrayMarked( array $value ): bool { |
349 | return array_key_exists( self::TYPE_ANNOTATION, $value ); |
350 | } |
351 | |
352 | /** |
353 | * Record a mark in the array, reversibly. |
354 | * |
355 | * The mark should record the class name, if it is different from |
356 | * the class hint. The result does not need to trigger |
357 | * `::isArrayMarked` if there is an accurate class hint present, |
358 | * but otherwise the result should register as marked. The |
359 | * provided value may be a "complex" array (one that recursively |
360 | * contains encoded object) or an array with a "false mark"; in |
361 | * both cases the provided $className will be `array`. |
362 | * |
363 | * @param array &$value An array result from `JsonClassCodec::toJsonArray()` |
364 | * or a "complex" array |
365 | * @param 'array'|class-string $className The name of the class encoded |
366 | * by the codec, or else `array` if $value is a "complex" array or a |
367 | * "false mark" |
368 | * @param class-string|'array'|null $classHint The class name provided as |
369 | * a hint to the encoder, and which will be in turn provided as a hint |
370 | * to the decoder, or `null` if no hint was provided. The class hint |
371 | * will be `array` when the array is a homogeneous list of objects. |
372 | */ |
373 | protected function markArray( array &$value, string $className, ?string $classHint ): void { |
374 | // We're going to use an array key, but first we have to see whether it |
375 | // was already present in the array we've been given, in which case |
376 | // we need to escape it (by hoisting into a child array). |
377 | if ( array_key_exists( self::TYPE_ANNOTATION, $value ) ) { |
378 | if ( $className !== $classHint ) { |
379 | $value[self::TYPE_ANNOTATION] = [ $value[self::TYPE_ANNOTATION], $className ]; |
380 | } else { |
381 | // Omit $className since it matches the $classHint, but we still |
382 | // need to escape the field to make it clear it was marked. |
383 | // (If the class hint hadn't matched, the proper class name |
384 | // would be here in an array, and we need to distinguish that |
385 | // case from the case where the "actual value" is an array.) |
386 | $value[self::TYPE_ANNOTATION] = [ $value[self::TYPE_ANNOTATION] ]; |
387 | } |
388 | } elseif ( |
389 | $className !== $classHint || |
390 | ( array_is_list( $value ) && $className !== 'array' ) |
391 | ) { |
392 | // Include the type annotation if it doesn't match the hint; |
393 | // but also include it if necessary to break up a list. This |
394 | // ensures that all objects have an encoding in the '{...}' style, |
395 | // even if they happen to have all-numeric keys. |
396 | $value[self::TYPE_ANNOTATION] = $className; |
397 | } |
398 | } |
399 | |
400 | /** |
401 | * Remove a mark from an encoded array, and return an |
402 | * encoded class name if present. |
403 | * |
404 | * The provided array may not trigger `::isArrayMarked` is there |
405 | * was a class hint provided. |
406 | * |
407 | * If the provided array had a "false mark" or recursively |
408 | * contained objects, the returned class name should be 'array'. |
409 | * |
410 | * @param array &$value An encoded array |
411 | * @param 'array'|class-string|null $classHint The class name provided as a hint to |
412 | * the decoder, which was previously provided as a hint to the encoder, |
413 | * or `null` if no hint was provided. |
414 | * @return 'array'|class-string The class name to be used for decoding, or |
415 | * 'array' if the value was a "complex" or "false mark" array. |
416 | */ |
417 | protected function unmarkArray( array &$value, ?string $classHint ): string { |
418 | $className = $value[self::TYPE_ANNOTATION] ?? $classHint; |
419 | // Remove our marker and restore the previous state of the |
420 | // json array (restoring a pre-existing field if needed) |
421 | if ( is_array( $className ) ) { |
422 | $value[self::TYPE_ANNOTATION] = $className[0]; |
423 | $className = $className[1] ?? $classHint; |
424 | } else { |
425 | unset( $value[self::TYPE_ANNOTATION] ); |
426 | } |
427 | return $className; |
428 | } |
429 | |
430 | } |