1<?php 2 3/* 4 * This file is part of the Symfony package. 5 * 6 * (c) Fabien Potencier <fabien@symfony.com> 7 * 8 * For the full copyright and license information, please view the LICENSE 9 * file that was distributed with this source code. 10 */ 11 12namespace Symfony\Component\Serializer\Encoder; 13 14use Symfony\Component\Serializer\Exception\BadMethodCallException; 15use Symfony\Component\Serializer\Exception\NotEncodableValueException; 16 17/** 18 * Encodes XML data. 19 * 20 * @author Jordi Boggiano <j.boggiano@seld.be> 21 * @author John Wards <jwards@whiteoctober.co.uk> 22 * @author Fabian Vogler <fabian@equivalence.ch> 23 * @author Kévin Dunglas <dunglas@gmail.com> 24 */ 25class XmlEncoder extends SerializerAwareEncoder implements EncoderInterface, DecoderInterface, NormalizationAwareInterface 26{ 27 const FORMAT = 'xml'; 28 29 /** 30 * @var \DOMDocument 31 */ 32 private $dom; 33 private $format; 34 private $context; 35 private $rootNodeName = 'response'; 36 private $loadOptions; 37 38 /** 39 * Construct new XmlEncoder and allow to change the root node element name. 40 * 41 * @param string $rootNodeName 42 * @param int|null $loadOptions A bit field of LIBXML_* constants 43 */ 44 public function __construct($rootNodeName = 'response', $loadOptions = null) 45 { 46 $this->rootNodeName = $rootNodeName; 47 $this->loadOptions = null !== $loadOptions ? $loadOptions : LIBXML_NONET | LIBXML_NOBLANKS; 48 } 49 50 /** 51 * {@inheritdoc} 52 */ 53 public function encode($data, $format, array $context = []) 54 { 55 if ($data instanceof \DOMDocument) { 56 return $data->saveXML(); 57 } 58 59 $xmlRootNodeName = $this->resolveXmlRootName($context); 60 61 $this->dom = $this->createDomDocument($context); 62 $this->format = $format; 63 $this->context = $context; 64 65 if (null !== $data && !is_scalar($data)) { 66 $root = $this->dom->createElement($xmlRootNodeName); 67 $this->dom->appendChild($root); 68 $this->buildXml($root, $data, $xmlRootNodeName); 69 } else { 70 $this->appendNode($this->dom, $data, $xmlRootNodeName); 71 } 72 73 return $this->dom->saveXML(); 74 } 75 76 /** 77 * {@inheritdoc} 78 */ 79 public function decode($data, $format, array $context = []) 80 { 81 if ('' === trim($data)) { 82 throw new NotEncodableValueException('Invalid XML data, it can not be empty.'); 83 } 84 85 $internalErrors = libxml_use_internal_errors(true); 86 $disableEntities = libxml_disable_entity_loader(true); 87 libxml_clear_errors(); 88 89 $dom = new \DOMDocument(); 90 $dom->loadXML($data, $this->loadOptions); 91 92 libxml_use_internal_errors($internalErrors); 93 libxml_disable_entity_loader($disableEntities); 94 95 if ($error = libxml_get_last_error()) { 96 libxml_clear_errors(); 97 98 throw new NotEncodableValueException($error->message); 99 } 100 101 $rootNode = null; 102 foreach ($dom->childNodes as $child) { 103 if (XML_DOCUMENT_TYPE_NODE === $child->nodeType) { 104 throw new NotEncodableValueException('Document types are not allowed.'); 105 } 106 if (!$rootNode && XML_PI_NODE !== $child->nodeType) { 107 $rootNode = $child; 108 } 109 } 110 111 // todo: throw an exception if the root node name is not correctly configured (bc) 112 113 if ($rootNode->hasChildNodes()) { 114 $xpath = new \DOMXPath($dom); 115 $data = []; 116 foreach ($xpath->query('namespace::*', $dom->documentElement) as $nsNode) { 117 $data['@'.$nsNode->nodeName] = $nsNode->nodeValue; 118 } 119 120 unset($data['@xmlns:xml']); 121 122 if (empty($data)) { 123 return $this->parseXml($rootNode, $context); 124 } 125 126 return array_merge($data, (array) $this->parseXml($rootNode, $context)); 127 } 128 129 if (!$rootNode->hasAttributes()) { 130 return $rootNode->nodeValue; 131 } 132 133 $data = []; 134 135 foreach ($rootNode->attributes as $attrKey => $attr) { 136 $data['@'.$attrKey] = $attr->nodeValue; 137 } 138 139 $data['#'] = $rootNode->nodeValue; 140 141 return $data; 142 } 143 144 /** 145 * {@inheritdoc} 146 */ 147 public function supportsEncoding($format) 148 { 149 return self::FORMAT === $format; 150 } 151 152 /** 153 * {@inheritdoc} 154 */ 155 public function supportsDecoding($format) 156 { 157 return self::FORMAT === $format; 158 } 159 160 /** 161 * Sets the root node name. 162 * 163 * @param string $name Root node name 164 */ 165 public function setRootNodeName($name) 166 { 167 $this->rootNodeName = $name; 168 } 169 170 /** 171 * Returns the root node name. 172 * 173 * @return string 174 */ 175 public function getRootNodeName() 176 { 177 return $this->rootNodeName; 178 } 179 180 /** 181 * @param string $val 182 * 183 * @return bool 184 */ 185 final protected function appendXMLString(\DOMNode $node, $val) 186 { 187 if (\strlen($val) > 0) { 188 $frag = $this->dom->createDocumentFragment(); 189 $frag->appendXML($val); 190 $node->appendChild($frag); 191 192 return true; 193 } 194 195 return false; 196 } 197 198 /** 199 * @param string $val 200 * 201 * @return bool 202 */ 203 final protected function appendText(\DOMNode $node, $val) 204 { 205 $nodeText = $this->dom->createTextNode($val); 206 $node->appendChild($nodeText); 207 208 return true; 209 } 210 211 /** 212 * @param string $val 213 * 214 * @return bool 215 */ 216 final protected function appendCData(\DOMNode $node, $val) 217 { 218 $nodeText = $this->dom->createCDATASection($val); 219 $node->appendChild($nodeText); 220 221 return true; 222 } 223 224 /** 225 * @param \DOMDocumentFragment $fragment 226 * 227 * @return bool 228 */ 229 final protected function appendDocumentFragment(\DOMNode $node, $fragment) 230 { 231 if ($fragment instanceof \DOMDocumentFragment) { 232 $node->appendChild($fragment); 233 234 return true; 235 } 236 237 return false; 238 } 239 240 /** 241 * Checks the name is a valid xml element name. 242 * 243 * @param string $name 244 * 245 * @return bool 246 */ 247 final protected function isElementNameValid($name) 248 { 249 return $name && 250 false === strpos($name, ' ') && 251 preg_match('#^[\pL_][\pL0-9._:-]*$#ui', $name); 252 } 253 254 /** 255 * Parse the input DOMNode into an array or a string. 256 * 257 * @return array|string 258 */ 259 private function parseXml(\DOMNode $node, array $context = []) 260 { 261 $data = $this->parseXmlAttributes($node, $context); 262 263 $value = $this->parseXmlValue($node, $context); 264 265 if (!\count($data)) { 266 return $value; 267 } 268 269 if (!\is_array($value)) { 270 $data['#'] = $value; 271 272 return $data; 273 } 274 275 if (1 === \count($value) && key($value)) { 276 $data[key($value)] = current($value); 277 278 return $data; 279 } 280 281 foreach ($value as $key => $val) { 282 $data[$key] = $val; 283 } 284 285 return $data; 286 } 287 288 /** 289 * Parse the input DOMNode attributes into an array. 290 * 291 * @return array 292 */ 293 private function parseXmlAttributes(\DOMNode $node, array $context = []) 294 { 295 if (!$node->hasAttributes()) { 296 return []; 297 } 298 299 $data = []; 300 $typeCastAttributes = $this->resolveXmlTypeCastAttributes($context); 301 302 foreach ($node->attributes as $attr) { 303 if (!is_numeric($attr->nodeValue) || !$typeCastAttributes || (isset($attr->nodeValue[1]) && '0' === $attr->nodeValue[0])) { 304 $data['@'.$attr->nodeName] = $attr->nodeValue; 305 306 continue; 307 } 308 309 if (false !== $val = filter_var($attr->nodeValue, FILTER_VALIDATE_INT)) { 310 $data['@'.$attr->nodeName] = $val; 311 312 continue; 313 } 314 315 $data['@'.$attr->nodeName] = (float) $attr->nodeValue; 316 } 317 318 return $data; 319 } 320 321 /** 322 * Parse the input DOMNode value (content and children) into an array or a string. 323 * 324 * @return array|string 325 */ 326 private function parseXmlValue(\DOMNode $node, array $context = []) 327 { 328 if (!$node->hasChildNodes()) { 329 return $node->nodeValue; 330 } 331 332 if (1 === $node->childNodes->length && \in_array($node->firstChild->nodeType, [XML_TEXT_NODE, XML_CDATA_SECTION_NODE])) { 333 return $node->firstChild->nodeValue; 334 } 335 336 $value = []; 337 338 foreach ($node->childNodes as $subnode) { 339 if (XML_PI_NODE === $subnode->nodeType) { 340 continue; 341 } 342 343 $val = $this->parseXml($subnode, $context); 344 345 if ('item' === $subnode->nodeName && isset($val['@key'])) { 346 if (isset($val['#'])) { 347 $value[$val['@key']] = $val['#']; 348 } else { 349 $value[$val['@key']] = $val; 350 } 351 } else { 352 $value[$subnode->nodeName][] = $val; 353 } 354 } 355 356 foreach ($value as $key => $val) { 357 if (\is_array($val) && 1 === \count($val)) { 358 $value[$key] = current($val); 359 } 360 } 361 362 return $value; 363 } 364 365 /** 366 * Parse the data and convert it to DOMElements. 367 * 368 * @param array|object $data 369 * @param string|null $xmlRootNodeName 370 * 371 * @return bool 372 * 373 * @throws NotEncodableValueException 374 */ 375 private function buildXml(\DOMNode $parentNode, $data, $xmlRootNodeName = null) 376 { 377 $append = true; 378 379 if (\is_array($data) || ($data instanceof \Traversable && (null === $this->serializer || !$this->serializer->supportsNormalization($data, $this->format)))) { 380 foreach ($data as $key => $data) { 381 //Ah this is the magic @ attribute types. 382 if (0 === strpos($key, '@') && $this->isElementNameValid($attributeName = substr($key, 1))) { 383 if (!is_scalar($data)) { 384 $data = $this->serializer->normalize($data, $this->format, $this->context); 385 } 386 $parentNode->setAttribute($attributeName, $data); 387 } elseif ('#' === $key) { 388 $append = $this->selectNodeType($parentNode, $data); 389 } elseif (\is_array($data) && false === is_numeric($key)) { 390 // Is this array fully numeric keys? 391 if (ctype_digit(implode('', array_keys($data)))) { 392 /* 393 * Create nodes to append to $parentNode based on the $key of this array 394 * Produces <xml><item>0</item><item>1</item></xml> 395 * From ["item" => [0,1]];. 396 */ 397 foreach ($data as $subData) { 398 $append = $this->appendNode($parentNode, $subData, $key); 399 } 400 } else { 401 $append = $this->appendNode($parentNode, $data, $key); 402 } 403 } elseif (is_numeric($key) || !$this->isElementNameValid($key)) { 404 $append = $this->appendNode($parentNode, $data, 'item', $key); 405 } elseif (null !== $data || !isset($this->context['remove_empty_tags']) || false === $this->context['remove_empty_tags']) { 406 $append = $this->appendNode($parentNode, $data, $key); 407 } 408 } 409 410 return $append; 411 } 412 413 if (\is_object($data)) { 414 if (null === $this->serializer) { 415 throw new BadMethodCallException(sprintf('The serializer needs to be set to allow "%s()" to be used with object data.', __METHOD__)); 416 } 417 418 $data = $this->serializer->normalize($data, $this->format, $this->context); 419 if (null !== $data && !is_scalar($data)) { 420 return $this->buildXml($parentNode, $data, $xmlRootNodeName); 421 } 422 423 // top level data object was normalized into a scalar 424 if (!$parentNode->parentNode->parentNode) { 425 $root = $parentNode->parentNode; 426 $root->removeChild($parentNode); 427 428 return $this->appendNode($root, $data, $xmlRootNodeName); 429 } 430 431 return $this->appendNode($parentNode, $data, 'data'); 432 } 433 434 throw new NotEncodableValueException('An unexpected value could not be serialized: '.(!\is_resource($data) ? var_export($data, true) : sprintf('%s resource', get_resource_type($data)))); 435 } 436 437 /** 438 * Selects the type of node to create and appends it to the parent. 439 * 440 * @param array|object $data 441 * @param string $nodeName 442 * @param string $key 443 * 444 * @return bool 445 */ 446 private function appendNode(\DOMNode $parentNode, $data, $nodeName, $key = null) 447 { 448 $node = $this->dom->createElement($nodeName); 449 if (null !== $key) { 450 $node->setAttribute('key', $key); 451 } 452 $appendNode = $this->selectNodeType($node, $data); 453 // we may have decided not to append this node, either in error or if its $nodeName is not valid 454 if ($appendNode) { 455 $parentNode->appendChild($node); 456 } 457 458 return $appendNode; 459 } 460 461 /** 462 * Checks if a value contains any characters which would require CDATA wrapping. 463 * 464 * @param string $val 465 * 466 * @return bool 467 */ 468 private function needsCdataWrapping($val) 469 { 470 return 0 < preg_match('/[<>&]/', $val); 471 } 472 473 /** 474 * Tests the value being passed and decide what sort of element to create. 475 * 476 * @param mixed $val 477 * 478 * @return bool 479 * 480 * @throws NotEncodableValueException 481 */ 482 private function selectNodeType(\DOMNode $node, $val) 483 { 484 if (\is_array($val)) { 485 return $this->buildXml($node, $val); 486 } elseif ($val instanceof \SimpleXMLElement) { 487 $child = $this->dom->importNode(dom_import_simplexml($val), true); 488 $node->appendChild($child); 489 } elseif ($val instanceof \Traversable) { 490 $this->buildXml($node, $val); 491 } elseif (\is_object($val)) { 492 if (null === $this->serializer) { 493 throw new BadMethodCallException(sprintf('The serializer needs to be set to allow "%s()" to be used with object data.', __METHOD__)); 494 } 495 496 return $this->selectNodeType($node, $this->serializer->normalize($val, $this->format, $this->context)); 497 } elseif (is_numeric($val)) { 498 return $this->appendText($node, (string) $val); 499 } elseif (\is_string($val) && $this->needsCdataWrapping($val)) { 500 return $this->appendCData($node, $val); 501 } elseif (\is_string($val)) { 502 return $this->appendText($node, $val); 503 } elseif (\is_bool($val)) { 504 return $this->appendText($node, (int) $val); 505 } elseif ($val instanceof \DOMNode) { 506 $child = $this->dom->importNode($val, true); 507 $node->appendChild($child); 508 } 509 510 return true; 511 } 512 513 /** 514 * Get real XML root node name, taking serializer options into account. 515 * 516 * @return string 517 */ 518 private function resolveXmlRootName(array $context = []) 519 { 520 return isset($context['xml_root_node_name']) 521 ? $context['xml_root_node_name'] 522 : $this->rootNodeName; 523 } 524 525 /** 526 * Get XML option for type casting attributes Defaults to true. 527 * 528 * @return bool 529 */ 530 private function resolveXmlTypeCastAttributes(array $context = []) 531 { 532 return isset($context['xml_type_cast_attributes']) 533 ? (bool) $context['xml_type_cast_attributes'] 534 : true; 535 } 536 537 /** 538 * Create a DOM document, taking serializer options into account. 539 * 540 * @param array $context Options that the encoder has access to 541 * 542 * @return \DOMDocument 543 */ 544 private function createDomDocument(array $context) 545 { 546 $document = new \DOMDocument(); 547 548 // Set an attribute on the DOM document specifying, as part of the XML declaration, 549 $xmlOptions = [ 550 // nicely formats output with indentation and extra space 551 'xml_format_output' => 'formatOutput', 552 // the version number of the document 553 'xml_version' => 'xmlVersion', 554 // the encoding of the document 555 'xml_encoding' => 'encoding', 556 // whether the document is standalone 557 'xml_standalone' => 'xmlStandalone', 558 ]; 559 foreach ($xmlOptions as $xmlOption => $documentProperty) { 560 if (isset($context[$xmlOption])) { 561 $document->$documentProperty = $context[$xmlOption]; 562 } 563 } 564 565 return $document; 566 } 567} 568