Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
9.62% covered (danger)
9.62%
10 / 104
20.00% covered (danger)
20.00%
2 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
JsonCodec
9.62% covered (danger)
9.62%
10 / 104
20.00% covered (danger)
20.00%
2 / 10
1895.97
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 toJsonString
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 newFromJsonString
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 codecFor
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 addCodecFor
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
2.86
 toJsonArray
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
342
 newFromJsonArray
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
240
 isArrayMarked
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 markArray
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
 unmarkArray
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2declare( 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
23namespace Wikimedia\JsonCodec;
24
25use Exception;
26use InvalidArgumentException;
27use Psr\Container\ContainerInterface;
28use Psr\Container\NotFoundExceptionInterface;
29use stdClass;
30
31/**
32 * Helper class to serialize/unserialize things to/from JSON.
33 */
34class 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}