vendor/symfony/serializer/Encoder/XmlEncoder.php line 71

  1. <?php
  2. /*
  3.  * This file is part of the Symfony package.
  4.  *
  5.  * (c) Fabien Potencier <fabien@symfony.com>
  6.  *
  7.  * For the full copyright and license information, please view the LICENSE
  8.  * file that was distributed with this source code.
  9.  */
  10. namespace Symfony\Component\Serializer\Encoder;
  11. use Symfony\Component\Serializer\Exception\BadMethodCallException;
  12. use Symfony\Component\Serializer\Exception\NotEncodableValueException;
  13. use Symfony\Component\Serializer\SerializerAwareInterface;
  14. use Symfony\Component\Serializer\SerializerAwareTrait;
  15. /**
  16.  * @author Jordi Boggiano <j.boggiano@seld.be>
  17.  * @author John Wards <jwards@whiteoctober.co.uk>
  18.  * @author Fabian Vogler <fabian@equivalence.ch>
  19.  * @author Kévin Dunglas <dunglas@gmail.com>
  20.  * @author Dany Maillard <danymaillard93b@gmail.com>
  21.  */
  22. class XmlEncoder implements EncoderInterfaceDecoderInterfaceNormalizationAwareInterfaceSerializerAwareInterface
  23. {
  24.     use SerializerAwareTrait;
  25.     public const FORMAT 'xml';
  26.     public const AS_COLLECTION 'as_collection';
  27.     /**
  28.      * An array of ignored XML node types while decoding, each one of the DOM Predefined XML_* constants.
  29.      */
  30.     public const DECODER_IGNORED_NODE_TYPES 'decoder_ignored_node_types';
  31.     /**
  32.      * An array of ignored XML node types while encoding, each one of the DOM Predefined XML_* constants.
  33.      */
  34.     public const ENCODER_IGNORED_NODE_TYPES 'encoder_ignored_node_types';
  35.     public const ENCODING 'xml_encoding';
  36.     public const FORMAT_OUTPUT 'xml_format_output';
  37.     /**
  38.      * A bit field of LIBXML_* constants.
  39.      */
  40.     public const LOAD_OPTIONS 'load_options';
  41.     public const REMOVE_EMPTY_TAGS 'remove_empty_tags';
  42.     public const ROOT_NODE_NAME 'xml_root_node_name';
  43.     public const STANDALONE 'xml_standalone';
  44.     public const TYPE_CAST_ATTRIBUTES 'xml_type_cast_attributes';
  45.     public const VERSION 'xml_version';
  46.     private $defaultContext = [
  47.         self::AS_COLLECTION => false,
  48.         self::DECODER_IGNORED_NODE_TYPES => [\XML_PI_NODE\XML_COMMENT_NODE],
  49.         self::ENCODER_IGNORED_NODE_TYPES => [],
  50.         self::LOAD_OPTIONS => \LIBXML_NONET \LIBXML_NOBLANKS,
  51.         self::REMOVE_EMPTY_TAGS => false,
  52.         self::ROOT_NODE_NAME => 'response',
  53.         self::TYPE_CAST_ATTRIBUTES => true,
  54.     ];
  55.     public function __construct(array $defaultContext = [])
  56.     {
  57.         $this->defaultContext array_merge($this->defaultContext$defaultContext);
  58.     }
  59.     public function encode(mixed $datastring $format, array $context = []): string
  60.     {
  61.         $encoderIgnoredNodeTypes $context[self::ENCODER_IGNORED_NODE_TYPES] ?? $this->defaultContext[self::ENCODER_IGNORED_NODE_TYPES];
  62.         $ignorePiNode \in_array(\XML_PI_NODE$encoderIgnoredNodeTypestrue);
  63.         if ($data instanceof \DOMDocument) {
  64.             return $data->saveXML($ignorePiNode $data->documentElement null);
  65.         }
  66.         $xmlRootNodeName $context[self::ROOT_NODE_NAME] ?? $this->defaultContext[self::ROOT_NODE_NAME];
  67.         $dom $this->createDomDocument($context);
  68.         if (null !== $data && !\is_scalar($data)) {
  69.             $root $dom->createElement($xmlRootNodeName);
  70.             $dom->appendChild($root);
  71.             $this->buildXml($root$data$format$context$xmlRootNodeName);
  72.         } else {
  73.             $this->appendNode($dom$data$format$context$xmlRootNodeName);
  74.         }
  75.         return $dom->saveXML($ignorePiNode $dom->documentElement null);
  76.     }
  77.     public function decode(string $datastring $format, array $context = []): mixed
  78.     {
  79.         if ('' === trim($data)) {
  80.             throw new NotEncodableValueException('Invalid XML data, it cannot be empty.');
  81.         }
  82.         $internalErrors libxml_use_internal_errors(true);
  83.         libxml_clear_errors();
  84.         $dom = new \DOMDocument();
  85.         $dom->loadXML($data$context[self::LOAD_OPTIONS] ?? $this->defaultContext[self::LOAD_OPTIONS]);
  86.         libxml_use_internal_errors($internalErrors);
  87.         if ($error libxml_get_last_error()) {
  88.             libxml_clear_errors();
  89.             throw new NotEncodableValueException($error->message);
  90.         }
  91.         $rootNode null;
  92.         $decoderIgnoredNodeTypes $context[self::DECODER_IGNORED_NODE_TYPES] ?? $this->defaultContext[self::DECODER_IGNORED_NODE_TYPES];
  93.         foreach ($dom->childNodes as $child) {
  94.             if (\in_array($child->nodeType$decoderIgnoredNodeTypestrue)) {
  95.                 continue;
  96.             }
  97.             if (\XML_DOCUMENT_TYPE_NODE === $child->nodeType) {
  98.                 throw new NotEncodableValueException('Document types are not allowed.');
  99.             }
  100.             if (!$rootNode) {
  101.                 $rootNode $child;
  102.             }
  103.         }
  104.         // todo: throw an exception if the root node name is not correctly configured (bc)
  105.         if ($rootNode->hasChildNodes()) {
  106.             $xpath = new \DOMXPath($dom);
  107.             $data = [];
  108.             foreach ($xpath->query('namespace::*'$dom->documentElement) as $nsNode) {
  109.                 $data['@'.$nsNode->nodeName] = $nsNode->nodeValue;
  110.             }
  111.             unset($data['@xmlns:xml']);
  112.             if (empty($data)) {
  113.                 return $this->parseXml($rootNode$context);
  114.             }
  115.             return array_merge($data, (array) $this->parseXml($rootNode$context));
  116.         }
  117.         if (!$rootNode->hasAttributes()) {
  118.             return $rootNode->nodeValue;
  119.         }
  120.         $data = [];
  121.         foreach ($rootNode->attributes as $attrKey => $attr) {
  122.             $data['@'.$attrKey] = $attr->nodeValue;
  123.         }
  124.         $data['#'] = $rootNode->nodeValue;
  125.         return $data;
  126.     }
  127.     public function supportsEncoding(string $format): bool
  128.     {
  129.         return self::FORMAT === $format;
  130.     }
  131.     public function supportsDecoding(string $format): bool
  132.     {
  133.         return self::FORMAT === $format;
  134.     }
  135.     final protected function appendXMLString(\DOMNode $nodestring $val): bool
  136.     {
  137.         if ('' !== $val) {
  138.             $frag $node->ownerDocument->createDocumentFragment();
  139.             $frag->appendXML($val);
  140.             $node->appendChild($frag);
  141.             return true;
  142.         }
  143.         return false;
  144.     }
  145.     final protected function appendText(\DOMNode $nodestring $val): bool
  146.     {
  147.         $nodeText $node->ownerDocument->createTextNode($val);
  148.         $node->appendChild($nodeText);
  149.         return true;
  150.     }
  151.     final protected function appendCData(\DOMNode $nodestring $val): bool
  152.     {
  153.         $nodeText $node->ownerDocument->createCDATASection($val);
  154.         $node->appendChild($nodeText);
  155.         return true;
  156.     }
  157.     final protected function appendDocumentFragment(\DOMNode $node\DOMDocumentFragment $fragment): bool
  158.     {
  159.         if ($fragment instanceof \DOMDocumentFragment) {
  160.             $node->appendChild($fragment);
  161.             return true;
  162.         }
  163.         return false;
  164.     }
  165.     final protected function appendComment(\DOMNode $nodestring $data): bool
  166.     {
  167.         $node->appendChild($node->ownerDocument->createComment($data));
  168.         return true;
  169.     }
  170.     /**
  171.      * Checks the name is a valid xml element name.
  172.      */
  173.     final protected function isElementNameValid(string $name): bool
  174.     {
  175.         return $name &&
  176.             !str_contains($name' ') &&
  177.             preg_match('#^[\pL_][\pL0-9._:-]*$#ui'$name);
  178.     }
  179.     /**
  180.      * Parse the input DOMNode into an array or a string.
  181.      */
  182.     private function parseXml(\DOMNode $node, array $context = []): array|string
  183.     {
  184.         $data $this->parseXmlAttributes($node$context);
  185.         $value $this->parseXmlValue($node$context);
  186.         if (!\count($data)) {
  187.             return $value;
  188.         }
  189.         if (!\is_array($value)) {
  190.             $data['#'] = $value;
  191.             return $data;
  192.         }
  193.         if (=== \count($value) && key($value)) {
  194.             $data[key($value)] = current($value);
  195.             return $data;
  196.         }
  197.         foreach ($value as $key => $val) {
  198.             $data[$key] = $val;
  199.         }
  200.         return $data;
  201.     }
  202.     /**
  203.      * Parse the input DOMNode attributes into an array.
  204.      */
  205.     private function parseXmlAttributes(\DOMNode $node, array $context = []): array
  206.     {
  207.         if (!$node->hasAttributes()) {
  208.             return [];
  209.         }
  210.         $data = [];
  211.         $typeCastAttributes = (bool) ($context[self::TYPE_CAST_ATTRIBUTES] ?? $this->defaultContext[self::TYPE_CAST_ATTRIBUTES]);
  212.         foreach ($node->attributes as $attr) {
  213.             if (!is_numeric($attr->nodeValue) || !$typeCastAttributes || (isset($attr->nodeValue[1]) && '0' === $attr->nodeValue[0] && '.' !== $attr->nodeValue[1])) {
  214.                 $data['@'.$attr->nodeName] = $attr->nodeValue;
  215.                 continue;
  216.             }
  217.             if (false !== $val filter_var($attr->nodeValue\FILTER_VALIDATE_INT)) {
  218.                 $data['@'.$attr->nodeName] = $val;
  219.                 continue;
  220.             }
  221.             $data['@'.$attr->nodeName] = (float) $attr->nodeValue;
  222.         }
  223.         return $data;
  224.     }
  225.     /**
  226.      * Parse the input DOMNode value (content and children) into an array or a string.
  227.      */
  228.     private function parseXmlValue(\DOMNode $node, array $context = []): array|string
  229.     {
  230.         if (!$node->hasChildNodes()) {
  231.             return $node->nodeValue;
  232.         }
  233.         if (=== $node->childNodes->length && \in_array($node->firstChild->nodeType, [\XML_TEXT_NODE\XML_CDATA_SECTION_NODE])) {
  234.             return $node->firstChild->nodeValue;
  235.         }
  236.         $value = [];
  237.         $decoderIgnoredNodeTypes $context[self::DECODER_IGNORED_NODE_TYPES] ?? $this->defaultContext[self::DECODER_IGNORED_NODE_TYPES];
  238.         foreach ($node->childNodes as $subnode) {
  239.             if (\in_array($subnode->nodeType$decoderIgnoredNodeTypestrue)) {
  240.                 continue;
  241.             }
  242.             $val $this->parseXml($subnode$context);
  243.             if ('item' === $subnode->nodeName && isset($val['@key'])) {
  244.                 $value[$val['@key']] = $val['#'] ?? $val;
  245.             } else {
  246.                 $value[$subnode->nodeName][] = $val;
  247.             }
  248.         }
  249.         $asCollection $context[self::AS_COLLECTION] ?? $this->defaultContext[self::AS_COLLECTION];
  250.         foreach ($value as $key => $val) {
  251.             if (!$asCollection && \is_array($val) && === \count($val)) {
  252.                 $value[$key] = current($val);
  253.             }
  254.         }
  255.         return $value;
  256.     }
  257.     /**
  258.      * Parse the data and convert it to DOMElements.
  259.      *
  260.      * @throws NotEncodableValueException
  261.      */
  262.     private function buildXml(\DOMNode $parentNodemixed $datastring $format, array $contextstring $xmlRootNodeName null): bool
  263.     {
  264.         $append true;
  265.         $removeEmptyTags $context[self::REMOVE_EMPTY_TAGS] ?? $this->defaultContext[self::REMOVE_EMPTY_TAGS] ?? false;
  266.         $encoderIgnoredNodeTypes $context[self::ENCODER_IGNORED_NODE_TYPES] ?? $this->defaultContext[self::ENCODER_IGNORED_NODE_TYPES];
  267.         if (\is_array($data) || ($data instanceof \Traversable && (null === $this->serializer || !$this->serializer->supportsNormalization($data$format)))) {
  268.             foreach ($data as $key => $data) {
  269.                 // Ah this is the magic @ attribute types.
  270.                 if (str_starts_with($key'@') && $this->isElementNameValid($attributeName substr($key1))) {
  271.                     if (!\is_scalar($data)) {
  272.                         $data $this->serializer->normalize($data$format$context);
  273.                     }
  274.                     if (\is_bool($data)) {
  275.                         $data = (int) $data;
  276.                     }
  277.                     $parentNode->setAttribute($attributeName$data);
  278.                 } elseif ('#' === $key) {
  279.                     $append $this->selectNodeType($parentNode$data$format$context);
  280.                 } elseif ('#comment' === $key) {
  281.                     if (!\in_array(\XML_COMMENT_NODE$encoderIgnoredNodeTypestrue)) {
  282.                         $append $this->appendComment($parentNode$data);
  283.                     }
  284.                 } elseif (\is_array($data) && false === is_numeric($key)) {
  285.                     // Is this array fully numeric keys?
  286.                     if (ctype_digit(implode(''array_keys($data)))) {
  287.                         /*
  288.                          * Create nodes to append to $parentNode based on the $key of this array
  289.                          * Produces <xml><item>0</item><item>1</item></xml>
  290.                          * From ["item" => [0,1]];.
  291.                          */
  292.                         foreach ($data as $subData) {
  293.                             $append $this->appendNode($parentNode$subData$format$context$key);
  294.                         }
  295.                     } else {
  296.                         $append $this->appendNode($parentNode$data$format$context$key);
  297.                     }
  298.                 } elseif (is_numeric($key) || !$this->isElementNameValid($key)) {
  299.                     $append $this->appendNode($parentNode$data$format$context'item'$key);
  300.                 } elseif (null !== $data || !$removeEmptyTags) {
  301.                     $append $this->appendNode($parentNode$data$format$context$key);
  302.                 }
  303.             }
  304.             return $append;
  305.         }
  306.         if (\is_object($data)) {
  307.             if (null === $this->serializer) {
  308.                 throw new BadMethodCallException(sprintf('The serializer needs to be set to allow "%s()" to be used with object data.'__METHOD__));
  309.             }
  310.             $data $this->serializer->normalize($data$format$context);
  311.             if (null !== $data && !\is_scalar($data)) {
  312.                 return $this->buildXml($parentNode$data$format$context$xmlRootNodeName);
  313.             }
  314.             // top level data object was normalized into a scalar
  315.             if (!$parentNode->parentNode->parentNode) {
  316.                 $root $parentNode->parentNode;
  317.                 $root->removeChild($parentNode);
  318.                 return $this->appendNode($root$data$format$context$xmlRootNodeName);
  319.             }
  320.             return $this->appendNode($parentNode$data$format$context'data');
  321.         }
  322.         throw new NotEncodableValueException('An unexpected value could not be serialized: '.(!\is_resource($data) ? var_export($datatrue) : sprintf('%s resource'get_resource_type($data))));
  323.     }
  324.     /**
  325.      * Selects the type of node to create and appends it to the parent.
  326.      */
  327.     private function appendNode(\DOMNode $parentNodemixed $datastring $format, array $contextstring $nodeNamestring $key null): bool
  328.     {
  329.         $dom $parentNode instanceof \DOMDocument $parentNode $parentNode->ownerDocument;
  330.         $node $dom->createElement($nodeName);
  331.         if (null !== $key) {
  332.             $node->setAttribute('key'$key);
  333.         }
  334.         $appendNode $this->selectNodeType($node$data$format$context);
  335.         // we may have decided not to append this node, either in error or if its $nodeName is not valid
  336.         if ($appendNode) {
  337.             $parentNode->appendChild($node);
  338.         }
  339.         return $appendNode;
  340.     }
  341.     /**
  342.      * Checks if a value contains any characters which would require CDATA wrapping.
  343.      */
  344.     private function needsCdataWrapping(string $val): bool
  345.     {
  346.         return preg_match('/[<>&]/'$val);
  347.     }
  348.     /**
  349.      * Tests the value being passed and decide what sort of element to create.
  350.      *
  351.      * @throws NotEncodableValueException
  352.      */
  353.     private function selectNodeType(\DOMNode $nodemixed $valstring $format, array $context): bool
  354.     {
  355.         if (\is_array($val)) {
  356.             return $this->buildXml($node$val$format$context);
  357.         } elseif ($val instanceof \SimpleXMLElement) {
  358.             $child $node->ownerDocument->importNode(dom_import_simplexml($val), true);
  359.             $node->appendChild($child);
  360.         } elseif ($val instanceof \Traversable) {
  361.             $this->buildXml($node$val$format$context);
  362.         } elseif ($val instanceof \DOMNode) {
  363.             $child $node->ownerDocument->importNode($valtrue);
  364.             $node->appendChild($child);
  365.         } elseif (\is_object($val)) {
  366.             if (null === $this->serializer) {
  367.                 throw new BadMethodCallException(sprintf('The serializer needs to be set to allow "%s()" to be used with object data.'__METHOD__));
  368.             }
  369.             return $this->selectNodeType($node$this->serializer->normalize($val$format$context), $format$context);
  370.         } elseif (is_numeric($val)) {
  371.             return $this->appendText($node, (string) $val);
  372.         } elseif (\is_string($val) && $this->needsCdataWrapping($val)) {
  373.             return $this->appendCData($node$val);
  374.         } elseif (\is_string($val)) {
  375.             return $this->appendText($node$val);
  376.         } elseif (\is_bool($val)) {
  377.             return $this->appendText($node, (int) $val);
  378.         }
  379.         return true;
  380.     }
  381.     /**
  382.      * Create a DOM document, taking serializer options into account.
  383.      */
  384.     private function createDomDocument(array $context): \DOMDocument
  385.     {
  386.         $document = new \DOMDocument();
  387.         // Set an attribute on the DOM document specifying, as part of the XML declaration,
  388.         $xmlOptions = [
  389.             // nicely formats output with indentation and extra space
  390.             self::FORMAT_OUTPUT => 'formatOutput',
  391.             // the version number of the document
  392.             self::VERSION => 'xmlVersion',
  393.             // the encoding of the document
  394.             self::ENCODING => 'encoding',
  395.             // whether the document is standalone
  396.             self::STANDALONE => 'xmlStandalone',
  397.         ];
  398.         foreach ($xmlOptions as $xmlOption => $documentProperty) {
  399.             if ($contextOption $context[$xmlOption] ?? $this->defaultContext[$xmlOption] ?? false) {
  400.                 $document->$documentProperty $contextOption;
  401.             }
  402.         }
  403.         return $document;
  404.     }
  405. }