diff --git a/components/json_encoder.rst b/components/json_encoder.rst new file mode 100644 index 00000000000..e30e09e9da5 --- /dev/null +++ b/components/json_encoder.rst @@ -0,0 +1,767 @@ +The JsonEncoder Component +========================= + +.. warning:: + + This component is :doc:`experimental ` and + could be changed at any time without prior notice. + +Symfony provides a powerful encoder that is able to encode PHP data +structures to a JSON stream, and the other way around. That encoder is +designed to be really efficient, while being able to deal with streams. + +The JsonEncoder component should typically be used when working with APIs +or integrating with third-party APIs. + +It can convert an incoming JSON request payload into one or several PHP +objects that your application can process. +Then, when sending a response, it can convert the processed PHP objects +back into a JSON stream for the outgoing response. + +Serializer or JsonEncoder? +-------------------------- + +When deciding between using of the :doc:`Serializer component ` +or the JsonEncoder component, consider the specific needs of your use case. + +The Serializer is ideal for scenarios requiring flexibility, such as +dynamically manipulating object shapes using normalizers and denormalizers, +or handling complex objects which multiple serialization representation. +Plus, it allows working with formats beyond JSON (and even with a custom +format of yours). + +On the other hand, the JsonEncoder component is tailored for simple objects +and offers significant advantages in terms of performance and memory +efficiency, especially when working with very large JSON. +Its ability to stream JSON data makes it particularly valuable for handling +large datasets or dealing with real-time data processing without loading the +entire JSON into memory. + +There is no silver bullet between the Serializer and the JsonEncoder, the +choice should be guided by the specific requirements of your use case. + +Installation +------------ + +In applications using :ref:`Symfony Flex `, run this command to +install the JsonEncoder component: + +.. code-block:: terminal + + $ composer require symfony/json-encoder + +.. include:: /components/require_autoload.rst.inc + +Configuration +------------- + +The JsonEncoder achieves speed by generating and storing PHP code. To minimize +code generation during runtime, you can configure it to pre-generate as much +PHP code as possible during cache warm up. This can be done by specifying +where the objects to be encoded or decoded are located: + +.. configuration-block:: + + .. code-block:: yaml + + # config/packages/json_encoder.yaml + framework: + json_encoder: + paths: # where the objects to be encoded/decoded are located + App\Encodable\: '%kernel.project_dir%/src/Encodable/*' + + .. code-block:: xml + + + + + + + + %kernel.project_dir%/src/Encodable/* + + + + + .. code-block:: php + + // config/packages/json_encoder.php + use Symfony\Config\FrameworkConfig; + + return static function (FrameworkConfig $framework): void { + $framework->jsonEncoder() + ->path('App\\Encodable\\', '%kernel.project_dir%/src/Encodable/*') + ; + }; + +Encoding objects +---------------- + +The JsonEncoder works with simple PHP classes that are composed by public +properties only. For example, let's say that we have the following ``Cat`` +class:: + + // src/Dto/Cat.php + namespace App\Dto; + + class Cat + { + public string $name; + public string $age; + } + +If you want to transform ``Cat`` objects to a JSON string (e.g. to send them +via an API response), you can get the `json_encoder` service by using the +:class:`Symfony\\Component\\JsonEncoder\\EncoderInterface` parameter type with +the ``$jsonEncoder`` name, and use the :method:`Symfony\\Component\\JsonEncoder\\EncoderInterface::encode` +method: + +.. configuration-block:: + + .. code-block:: php-symfony + + // src/Controller/CatController.php + namespace App\Controller; + + use App\Dto\Cat; + use App\Repository\CatRepository; + use Symfony\Component\HttpFoundation\Response; + use Symfony\Component\JsonEncoder\EncoderInterface; + use Symfony\Component\TypeInfo\Type; + + class CatController + { + public function retrieveCats(EncoderInterface $jsonEncoder, CatRepository $catRepository): Response + { + $cats = $catRepository->findAll(); + $type = Type::list(Type::object(Cat::class)); + + $json = $jsonEncoder->encode($cats, $type); + + return new Response($json); + } + } + + .. code-block:: php-standalone + + use App\Dto\Cat; + use App\Repository\CatRepository; + use Symfony\Component\HttpFoundation\Response; + use Symfony\Component\JsonEncoder\JsonEncoder; + use Symfony\Component\TypeInfo\Type; + + // ... + + $jsonEncoder = JsonEncoder::create(); + + $cats = $catRepository->findAll(); + $type = Type::list(Type::object(Cat::class)); + + $json = $jsonEncoder->encode($cats, $type); + + $response = new Response($json); + + // ... + +Because the :method:`Symfony\\Component\\JsonEncoder\\EncoderInterface::encode` +method result is either a :phpclass:`Stringable` and a string :phpclass:`Traversable`, +you can leverage the streaming capabilities of the JsonEncoder, +by using instead a :class:`Symfony\\Component\\HttpFoundation\\StreamedResponse`: + +.. code-block:: diff + + // src/Controller/CatController.php + namespace App\Controller; + + use App\Dto\Cat; + use App\Repository\CatRepository; + use Symfony\Component\HttpFoundation\Response; + + use Symfony\Component\HttpFoundation\StreamedResponse; + use Symfony\Component\JsonEncoder\EncoderInterface; + use Symfony\Component\TypeInfo\Type; + + class CatController + { + public function retrieveCats(EncoderInterface $jsonEncoder, CatRepository $catRepository): Response + { + $cats = $catRepository->findAll(); + $type = Type::list(Type::object(Cat::class)); + + $json = $jsonEncoder->encode($cats, $type); + + - return new Response($json); + + return new StreamedResponse($json); + } + } + + +Decoding objects +---------------- + +Besides encoding objects to JSON, you can decode JSON to objects. + +To do so, you can get the ``json_decoder`` service by using the +:class:`Symfony\\Component\\JsonEncoder\\DecoderInterface` parameter type +with the ``$jsonDecoder`` name, and use the :method:`Symfony\\Component\\JsonEncoder\\DecoderInterface::decode` +method: + +.. configuration-block:: + + .. code-block:: php-symfony + + // src/Service/TombolaService.php + namespace App\Service; + + use App\Dto\Cat; + use Symfony\Component\DependencyInjection\Attribute\Autowire; + use Symfony\Component\JsonEncoder\DecoderInterface; + use Symfony\Component\TypeInfo\Type; + + class TombolaService + { + private string $catsJsonFile; + + public function __construct( + private DecoderInterface $jsonDecoder, + #[Autowire(param: 'kernel.root_dir')] + string $rootDir, + ) { + $this->catsJsonFile = sprintf('%s/var/cats.json', $rootDir); + } + + public function pickAWinner(): Cat + { + $jsonResource = fopen($this->catsJsonFile, 'r'); + $type = Type::list(Type::object(Cat::class)); + + $cats = $this->jsonDecoder->decode($jsonResource, $type); + + return $cats[rand(0, count($cats) - 1)]; + } + + /** + * @return list + */ + public function listEligibleCatNames(): array + { + $jsonString = file_get_contents($this->catsJsonFile); + $type = Type::list(Type::object(Cat::class)); + + $cats = $this->jsonDecoder->decode($jsonString, $type); + + return array_column($cats, 'name'); + } + } + + .. code-block:: php-standalone + + // src/Service/TombolaService.php + namespace App\Service; + + use App\Dto\Cat; + use Symfony\Component\JsonEncoder\DecoderInterface; + use Symfony\Component\JsonEncoder\JsonDecoder; + use Symfony\Component\TypeInfo\Type; + + class TombolaService + { + private DecoderInterface $jsonDecoder; + private string $catsJsonFile; + + public function __construct( + private string $catsJsonFile, + ) { + $this->jsonDecoder = JsonDecoder::create(); + } + + public function pickAWinner(): Cat + { + $jsonResource = fopen($this->catsJsonFile, 'r'); + $type = Type::list(Type::object(Cat::class)); + + $cats = $this->jsonDecoder->decode($jsonResource, $type); + + return $cats[rand(0, count($cats) - 1)]; + } + + /** + * @return list + */ + public function listEligibleCatNames(): array + { + $jsonString = file_get_contents($this->catsJsonFile); + $type = Type::list(Type::object(Cat::class)); + + $cats = $this->jsonDecoder->decode($jsonString, $type); + + return array_column($cats, 'name'); + } + } + +The upper code demonstrates two different approaches to decoding JSON data +using the JsonEncoder: + +* decoding from a stream (``pickAWinner``) +* decoding from a string (``listEligibleCatNames``). + +Both methods work with the same JSON data but differ in memory usage and +speed optimization. + + +Decoding from a stream +~~~~~~~~~~~~~~~~~~~~~~ + +In the ``pickAWinner`` method, the JSON data is read as a stream using +:phpfunction:`fopen`. Streams are useful when working with large files +because the data is processed incrementally rather than loading the entire +file into memory. + +To improve memory efficiency, the JsonEncoder creates _`ghost objects`: https://en.wikipedia.org/wiki/Lazy_loading#Ghost +instead of fully instantiating objects. Ghosts objects are lightweight +placeholders that represent the objects but don't fully load their data +into memory until it's needed. This approach reduces memory usage, especially +for large datasets. + +* Advantage: Efficient memory usage, suitable for very large JSON files. +* Disadvantage: Slightly slower than decoding a full string because data is loaded on-demand. + +Decoding from a string +~~~~~~~~~~~~~~~~~~~~~~ + +In the ``listEligibleCatNames`` method, the entire JSON file is read into +a string using :phpfunction:`file_get_contents`. This string is then passed +to the decoder, which fully instantiates all the objects in the JSON data +upfront. + +This approach is faster because all the objects are created immediately, +making subsequent operations on the data quicker. However, it uses more +memory since the entire file content and all objects are loaded at once. + +* Advantage: Faster processing, suitable for small to medium-sized JSON files. +* Disadvantage: Higher memory usage, not ideal for large JSON files. + +.. tip:: + + Prefer stream decoding when working with large JSON files to conserve + memory. + + Prefer string decoding instead when performance is more critical and the + JSON file size is manageable. + +Enabling PHPDoc reading +----------------------- + +The JsonEncoder component can be able to process advanced PHPDoc type +definitions, such as generics, and read/generate JSON for complex PHP +objects. + +For example, let's consider this ``Shelter`` class that defines a generic +``TAnimal`` type, which can be a ``Cat`` or a ``Dog``:: + + // src/Dto/Shelter.php + namespace App\Dto; + + /** + * @template TAnimal of Cat|Dog + */ + class Shelter + { + /** + * @var list + */ + public array $animals; + } + + +To enable PHPDoc interpretation, run the following command: + +.. code-block:: terminal + + $ composer require phpstan/phpdoc-parser + +Then, when encoding/decoding an instance of the ``Shelter`` class, you can +specify the concrete type information, and the JsonEncoder will deal with the +correct JSON structure:: + + use App\Dto\Cat; + use App\Dto\Shelter; + use Symfony\Component\TypeInfo\Type; + + $json = <<decode($json, $type); // will be populated with cats + +Configuring encoding/decoding +----------------------------- + +While it's not recommended to change to object shape and values during +encoding and decoding, it is sometimes unavoidable. + +Configuring the encoded name +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +It is possible to configure the JSON key associated to a property thanks to +the :class:`Symfony\\Component\\JsonEncoder\\Attribute\\EncodedName` +attribute:: + + // src/Dto/Duck.php + namespace App\Dto; + + use Symfony\Component\JsonEncoder\Attribute\EncodedName; + + class Duck + { + #[EncodedName('@id')] + public string $id; + } + +By doing so, the ``Duck::$id`` property will be mapped to the ``@id`` JSON key:: + + use App\Dto\Duck; + use Symfony\Component\TypeInfo\Type; + + // ... + + $duck = new Duck(); + $duck->id = '/ducks/daffy'; + + echo (string) $jsonEncoder->encode($duck, Type::object(Duck::class)); + + // This will output: + // { + // "@id": "/ducks/daffy" + // } + +Configuring the encoded value +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you need to manipulate the value related to a property during the encoding +process, you can use the :class:`Symfony\\Component\\JsonEncoder\\Attribute\\Normalizer` +attribute. This attribute takes a callable, or a :ref:`normalizer service id `. + +When a callable is specified, it must either be a public static method or +non-anonymous function with the following signature:: + + $normalizer = function (mixed $data, array $options = []): mixed { /* ... */ }; + +Then, you just have to specify the function identifier in the attribute:: + + // src/Dto/Duck.php + namespace App\Dto; + + use Symfony\Component\JsonEncoder\Attribute\Normalizer; + + class Duck + { + #[Normalizer('strtoupper')] + public int $name; + + #[Normalizer([self::class, 'formatHeight'])] + public int $height; + + public static function formatHeight(int $denormalized, array $options = []): string + { + return sprintf('%.2fcm', $denormalized / 100); + } + } + +For example, by configuring the ``Duck`` class like above, the ``name`` and +``height`` values will be normalized during encoding:: + + use App\Dto\Duck; + use Symfony\Component\TypeInfo\Type; + + // ... + + $duck = new Duck(); + $duck->name = 'daffy'; + $duck->height = 5083; + + echo (string) $jsonEncoder->encode($duck, Type::object(Duck::class)); + + // This will output: + // { + // "name": "DAFFY", + // "height": "50.83cm" + // } + +.. _json-encoder-using-normalizer-services: + +Configuring the encoded value with a service +............................................ + +When static methods or functions are not enough, you can normalize the value +thanks to a normalizer service. + +To do so, create a service implementing the :class:`Symfony\\Component\\JsonEncoder\\Encode\\Normalizer\\NormalizerInterface`:: + + // src/Encoder/DogUrlNormalizer.php + namespace App\Encoder; + + use Symfony\Component\JsonEncoder\Encode\Normalizer\NormalizerInterface; + use Symfony\Component\Routing\Generator\UrlGeneratorInterface; + use Symfony\Component\TypeInfo\Type; + + class DogUrlNormalizer implements NormalizerInterface + { + public function __construct( + private UrlGeneratorInterface $urlGenerator, + ) { + } + + public function normalize(mixed $denormalized, array $options = []): string + { + if (!is_int($denormalized)) { + throw new \InvalidArgumentException(sprintf('The denormalized data must be "int", "%s" given.', get_debug_type($denormalized))); + } + + return $this->urlGenerator->generate('show_dog', ['id' => $denormalized]); + } + + public static function getNormalizedType(): Type + { + return Type::string(); + } + } + +.. note:: + + The ``getNormalizedType`` method should return the type of what the value + will be in the JSON stream. + +And then, configure the :class:`Symfony\\Component\\JsonEncoder\\Attribute\\Normalizer` +attribute to use that service:: + + // src/Dto/Dog.php + namespace App\Dto; + + use App\Encoder\DogUrlNormalizer; + use Symfony\Component\JsonEncoder\Attribute\EncodedName; + use Symfony\Component\JsonEncoder\Attribute\Normalizer; + + class Dog + { + #[EncodedName('url')] + #[Normalizer(DogUrlNormalizer::class)] + public int $id; + } + +Configuring the decoded value +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can as well manipulate the value related to a property during decoding +using the :class:`Symfony\\Component\\JsonEncoder\\Attribute\\Denormalizer` +attribute, which takes as well a callable, or a :ref:`denormalizer service id `. + +When a callable is specified, it must either be a public static method or +non-anonymous function with the following signature:: + + $denormalizer = function (mixed $data, array $options = []): mixed { /* ... */ }; + +Then, you just have to specify the function identifier in the attribute:: + + // src/Dto/Duck.php + namespace App\Dto; + + use Symfony\Component\JsonEncoder\Attribute\Denormalizer; + + class Duck + { + #[Denormalizer([self::class, 'retrieveFirstName'])] + public string $firstName; + + #[Denormalizer([self::class, 'retrieveLastName'])] + public string $lastName; + + public static function retrieveFirstName(string $normalized, array $options = []): int + { + return explode(' ', $normalized)[0]; + } + + public static function retrieveLastName(string $normalized, array $options = []): int + { + return explode(' ', $normalized)[1]; + } + } + +For example, by configuring the ``Duck`` class like above, the ``height`` +values will be denormalized during decoding:: + + use App\Dto\Duck; + use Symfony\Component\TypeInfo\Type; + + // ... + + $duck = $jsonDecoder->decode( + '{"name": "Daffy Duck"}', + Type::object(Duck::class), + ); + + // The $duck variable will contain: + // object(Duck)#1 (1) { + // ["firstName"] => string(5) "Daffy" + // ["lastName"] => string(4) "Duck" + // } + +.. _json-encoder-using-denormalizer-services: + +Configuring the decoded value with a service +............................................ + +When a simple callable is not enough, you can denormalize the value thanks +to denormalizer services. + +To do so, create a service implementing the :class:`Symfony\\Component\\JsonEncoder\\Decode\\Denormalizer\\DenormalizerInterface`:: + + // src/Encoder/DuckHeightDenormalizer.php + namespace App\Encoder; + + use Symfony\Component\JsonEncoder\Decode\Denormalizer\DenormalizerInterface; + use Symfony\Component\TypeInfo\Type; + + class DuckHeightDenormalizer implements DenormalizerInterface + { + public function __construct( + private HeightConverter $heightConverter, + ) { + } + + public function denormalize(mixed $normalized, array $options = []): int + { + if (!is_string($denormalized)) { + throw new \InvalidArgumentException(sprintf('The denormalized data must be "int", "%s" given.', get_debug_type($denormalized))); + } + + $cm = (float) substr($denormalized, 0, -2); + + return $this->heightConverter->cmToMm($cm); + } + + public static function getNormalizedType(): Type + { + return Type::string(); + } + } + +.. note:: + + The ``getNormalizedType`` method should return the type of what the value + is in the JSON stream. + +And then, configure the :class:`Symfony\\Component\\JsonEncoder\\Attribute\\Denormalizer` +attribute to use that service:: + + // src/Dto/Dog.php + namespace App\Dto; + + use App\Encoder\DuckHeightDenormalizer; + use Symfony\Component\JsonEncoder\Attribute\Denormalizer; + + class Duck + { + #[Denormalizer(DuckHeightDenormalizer::class)] + public int $height; + } + +.. tip:: + + The normalizers and denormalizers will be intesively called during the + encoding. So be sure to keep them as fast as possible (avoid calling + external APIs or the database for example). + +Configure keys and values dynamically +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The JsonEncoder leverages services implementing the :class:`Symfony\\Component\\JsonEncoder\\Mapping\\PropertyMetadataLoaderInterface` +to determine the shape and values of objects during encoding. + +These services are highly flexible and can be decorated to handle dynamic +configurations, offering much greater power compared to using attributes:: + + // src/Encoder/SensitivePropertyMetadataLoader.php + namespace App\Encoder\SensitivePropertyMetadataLoader; + + use App\Dto\SensitiveInterface; + use App\Encode\EncryptorNormalizer; + use Symfony\Component\JsonEncoder\Mapping\PropertyMetadata; + use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoaderInterface; + use Symfony\Component\TypeInfo\Type; + + class SensitivePropertyMetadataLoader implements PropertyMetadataLoaderInterface + { + public function __construct( + private PropertyMetadataLoaderInterface $decorated, + ) { + } + + public function load(string $className, array $options = [], array $context = []): array + { + $propertyMetadataMap = $this->decorated->load($className, $config, $context); + + if (!is_a($className, SensitiveInterface::class, true)) { + return $propertyMetadataMap; + } + + // you can configure normalizers/denormalizers + foreach ($propertyMetadataMap as $jsonKey => $metadata) { + if (!in_array($metadata->getName(), $className::getPropertiesToEncrypt(), true)) { + continue; + } + + $propertyMetadataMap[$jsonKey] = $metadata + ->withType(Type::string()) + ->withAdditionalNormalizer(EncryptorNormalizer::class); + } + + // you can remove existing properties + foreach ($propertyMetadataMap as $jsonKey => $metadata) { + if (!in_array($metadata->getName(), $className::getPropertiesToRemove(), true)) { + continue; + } + + unset($propertyMetadataMap[$jsonKey]); + } + + // you can rename JSON keys + foreach ($propertyMetadataMap as $jsonKey => $metadata) { + $propertyMetadataMap[md5($jsonKey)] = $propertyMetadataMap[$jsonKey]; + unset($propertyMetadataMap[$jsonKey]); + } + + // you can add virtual properties + $propertyMetadataMap['is_sensitive'] = new PropertyMetadata( + name: 'theNameWontBeUsed', + type: Type::bool(), + normalizers: [self::trueValue(...)], + ); + + return $propertyMetadataMap; + } + + public static function trueValue(): bool + { + return true; + } + } + +However, this flexibility comes with complexity. Decorating property metadata +loaders requires a deep understanding of the system. + +For most use cases, the attributes approach is sufficient, and the dynamic +capabilities of property metadata loaders should be reserved for scenarios +where their additional power is genuinely necessary.