1<?php 2 3declare(strict_types=1); 4 5namespace JMS\Serializer; 6 7use JMS\Serializer\Exception\NotAcceptableException; 8use JMS\Serializer\Exception\RuntimeException; 9use JMS\Serializer\Metadata\ClassMetadata; 10use JMS\Serializer\Metadata\PropertyMetadata; 11use JMS\Serializer\Visitor\SerializationVisitorInterface; 12 13/** 14 * XmlSerializationVisitor. 15 * 16 * @author Johannes M. Schmitt <schmittjoh@gmail.com> 17 */ 18final class XmlSerializationVisitor extends AbstractVisitor implements SerializationVisitorInterface 19{ 20 /** 21 * @var \DOMDocument 22 */ 23 private $document; 24 25 /** 26 * @var string 27 */ 28 private $defaultRootName = 'result'; 29 30 /** 31 * @var string|null 32 */ 33 private $defaultRootNamespace; 34 35 /** 36 * @var string|null 37 */ 38 private $defaultRootPrefix; 39 40 /** 41 * @var \SplStack 42 */ 43 private $stack; 44 45 /** 46 * @var \SplStack 47 */ 48 private $metadataStack; 49 50 /** 51 * @var \DOMNode|\DOMElement|null 52 */ 53 private $currentNode; 54 55 /** 56 * @var ClassMetadata|PropertyMetadata|null 57 */ 58 private $currentMetadata; 59 60 /** 61 * @var bool 62 */ 63 private $hasValue; 64 65 /** 66 * @var bool 67 */ 68 private $nullWasVisited; 69 70 /** 71 * @var \SplStack 72 */ 73 private $objectMetadataStack; 74 75 public function __construct( 76 bool $formatOutput = true, 77 string $defaultEncoding = 'UTF-8', 78 string $defaultVersion = '1.0', 79 string $defaultRootName = 'result', 80 ?string $defaultRootNamespace = null, 81 ?string $defaultRootPrefix = null 82 ) { 83 $this->objectMetadataStack = new \SplStack(); 84 $this->stack = new \SplStack(); 85 $this->metadataStack = new \SplStack(); 86 87 $this->currentNode = null; 88 $this->nullWasVisited = false; 89 90 $this->document = $this->createDocument($formatOutput, $defaultVersion, $defaultEncoding); 91 92 $this->defaultRootName = $defaultRootName; 93 $this->defaultRootNamespace = $defaultRootNamespace; 94 $this->defaultRootPrefix = $defaultRootPrefix; 95 } 96 97 private function createDocument(bool $formatOutput, string $defaultVersion, string $defaultEncoding): \DOMDocument 98 { 99 $document = new \DOMDocument($defaultVersion, $defaultEncoding); 100 $document->formatOutput = $formatOutput; 101 102 return $document; 103 } 104 105 public function createRoot(?ClassMetadata $metadata = null, ?string $rootName = null, ?string $rootNamespace = null, ?string $rootPrefix = null): \DOMElement 106 { 107 if (null !== $metadata && !empty($metadata->xmlRootName)) { 108 $rootPrefix = $metadata->xmlRootPrefix; 109 $rootName = $metadata->xmlRootName; 110 $rootNamespace = $metadata->xmlRootNamespace ?: $this->getClassDefaultNamespace($metadata); 111 } else { 112 $rootName = $rootName ?: $this->defaultRootName; 113 $rootNamespace = $rootNamespace ?: $this->defaultRootNamespace; 114 $rootPrefix = $rootPrefix ?: $this->defaultRootPrefix; 115 } 116 117 $document = $this->getDocument(); 118 if ($rootNamespace) { 119 $rootNode = $document->createElementNS($rootNamespace, (null !== $rootPrefix ? ($rootPrefix . ':') : '') . $rootName); 120 } else { 121 $rootNode = $document->createElement($rootName); 122 } 123 $document->appendChild($rootNode); 124 $this->setCurrentNode($rootNode); 125 126 return $rootNode; 127 } 128 /** 129 * {@inheritdoc} 130 */ 131 public function visitNull($data, array $type) 132 { 133 $node = $this->document->createAttribute('xsi:nil'); 134 $node->value = 'true'; 135 $this->nullWasVisited = true; 136 137 return $node; 138 } 139 /** 140 * {@inheritdoc} 141 */ 142 public function visitString(string $data, array $type) 143 { 144 $doCData = null !== $this->currentMetadata ? $this->currentMetadata->xmlElementCData : true; 145 146 return $doCData ? $this->document->createCDATASection($data) : $this->document->createTextNode((string) $data); 147 } 148 149 /** 150 * @param mixed $data 151 * @param array $type 152 */ 153 public function visitSimpleString($data, array $type): \DOMText 154 { 155 return $this->document->createTextNode((string) $data); 156 } 157 158 /** 159 * {@inheritdoc} 160 */ 161 public function visitBoolean(bool $data, array $type) 162 { 163 return $this->document->createTextNode($data ? 'true' : 'false'); 164 } 165 166 /** 167 * {@inheritdoc} 168 */ 169 public function visitInteger(int $data, array $type) 170 { 171 return $this->document->createTextNode((string) $data); 172 } 173 174 /** 175 * {@inheritdoc} 176 */ 177 public function visitDouble(float $data, array $type) 178 { 179 return $this->document->createTextNode(var_export((float) $data, true)); 180 } 181 182 /** 183 * {@inheritdoc} 184 */ 185 public function visitArray(array $data, array $type): void 186 { 187 if (null === $this->currentNode) { 188 $this->createRoot(); 189 } 190 191 $entryName = null !== $this->currentMetadata && null !== $this->currentMetadata->xmlEntryName ? $this->currentMetadata->xmlEntryName : 'entry'; 192 $keyAttributeName = null !== $this->currentMetadata && null !== $this->currentMetadata->xmlKeyAttribute ? $this->currentMetadata->xmlKeyAttribute : null; 193 $namespace = null !== $this->currentMetadata && null !== $this->currentMetadata->xmlEntryNamespace ? $this->currentMetadata->xmlEntryNamespace : null; 194 195 $elType = $this->getElementType($type); 196 foreach ($data as $k => $v) { 197 $tagName = null !== $this->currentMetadata && $this->currentMetadata->xmlKeyValuePairs && $this->isElementNameValid((string) $k) ? $k : $entryName; 198 199 $entryNode = $this->createElement($tagName, $namespace); 200 $this->currentNode->appendChild($entryNode); 201 $this->setCurrentNode($entryNode); 202 203 if (null !== $keyAttributeName) { 204 $entryNode->setAttribute($keyAttributeName, (string) $k); 205 } 206 207 try { 208 if (null !== $node = $this->navigator->accept($v, $elType)) { 209 $this->currentNode->appendChild($node); 210 } 211 } catch (NotAcceptableException $e) { 212 $this->currentNode->parentNode->removeChild($this->currentNode); 213 } 214 215 $this->revertCurrentNode(); 216 } 217 } 218 219 /** 220 * {@inheritdoc} 221 */ 222 public function startVisitingObject(ClassMetadata $metadata, object $data, array $type): void 223 { 224 $this->objectMetadataStack->push($metadata); 225 226 if (null === $this->currentNode) { 227 $this->createRoot($metadata); 228 } 229 230 $this->addNamespaceAttributes($metadata, $this->currentNode); 231 232 $this->hasValue = false; 233 } 234 235 /** 236 * {@inheritdoc} 237 */ 238 public function visitProperty(PropertyMetadata $metadata, $v): void 239 { 240 if ($metadata->xmlAttribute) { 241 $this->setCurrentMetadata($metadata); 242 $node = $this->navigator->accept($v, $metadata->type); 243 $this->revertCurrentMetadata(); 244 245 if (!$node instanceof \DOMCharacterData) { 246 throw new RuntimeException(sprintf('Unsupported value for XML attribute for %s. Expected character data, but got %s.', $metadata->name, json_encode($v))); 247 } 248 249 $this->setAttributeOnNode($this->currentNode, $metadata->serializedName, $node->nodeValue, $metadata->xmlNamespace); 250 251 return; 252 } 253 254 if (($metadata->xmlValue && $this->currentNode->childNodes->length > 0) 255 || (!$metadata->xmlValue && $this->hasValue) 256 ) { 257 throw new RuntimeException(sprintf('If you make use of @XmlValue, all other properties in the class must have the @XmlAttribute annotation. Invalid usage detected in class %s.', $metadata->class)); 258 } 259 260 if ($metadata->xmlValue) { 261 $this->hasValue = true; 262 263 $this->setCurrentMetadata($metadata); 264 $node = $this->navigator->accept($v, $metadata->type); 265 $this->revertCurrentMetadata(); 266 267 if (!$node instanceof \DOMCharacterData) { 268 throw new RuntimeException(sprintf('Unsupported value for property %s::$%s. Expected character data, but got %s.', $metadata->reflection->class, $metadata->reflection->name, \is_object($node) ? \get_class($node) : \gettype($node))); 269 } 270 271 $this->currentNode->appendChild($node); 272 273 return; 274 } 275 276 if ($metadata->xmlAttributeMap) { 277 if (!\is_array($v)) { 278 throw new RuntimeException(sprintf('Unsupported value type for XML attribute map. Expected array but got %s.', \gettype($v))); 279 } 280 281 foreach ($v as $key => $value) { 282 $this->setCurrentMetadata($metadata); 283 $node = $this->navigator->accept($value, null); 284 $this->revertCurrentMetadata(); 285 286 if (!$node instanceof \DOMCharacterData) { 287 throw new RuntimeException(sprintf('Unsupported value for a XML attribute map value. Expected character data, but got %s.', json_encode($v))); 288 } 289 290 $this->setAttributeOnNode($this->currentNode, $key, $node->nodeValue, $metadata->xmlNamespace); 291 } 292 293 return; 294 } 295 296 if ($addEnclosingElement = !$this->isInLineCollection($metadata) && !$metadata->inline) { 297 $namespace = null !== $metadata->xmlNamespace 298 ? $metadata->xmlNamespace 299 : $this->getClassDefaultNamespace($this->objectMetadataStack->top()); 300 301 $element = $this->createElement($metadata->serializedName, $namespace); 302 $this->currentNode->appendChild($element); 303 $this->setCurrentNode($element); 304 } 305 306 $this->setCurrentMetadata($metadata); 307 308 try { 309 if (null !== $node = $this->navigator->accept($v, $metadata->type)) { 310 $this->currentNode->appendChild($node); 311 } 312 } catch (NotAcceptableException $e) { 313 $this->currentNode->parentNode->removeChild($this->currentNode); 314 $this->revertCurrentMetadata(); 315 $this->revertCurrentNode(); 316 $this->hasValue = false; 317 return; 318 } 319 320 $this->revertCurrentMetadata(); 321 322 if ($addEnclosingElement) { 323 $this->revertCurrentNode(); 324 325 if ($this->isElementEmpty($element) && (null === $v || $this->isSkippableCollection($metadata) || $this->isSkippableEmptyObject($node, $metadata))) { 326 $this->currentNode->removeChild($element); 327 } 328 } 329 330 $this->hasValue = false; 331 } 332 333 private function isInLineCollection(PropertyMetadata $metadata): bool 334 { 335 return $metadata->xmlCollection && $metadata->xmlCollectionInline; 336 } 337 338 private function isSkippableEmptyObject(?\DOMElement $node, PropertyMetadata $metadata): bool 339 { 340 return null === $node && !$metadata->xmlCollection && $metadata->skipWhenEmpty; 341 } 342 343 private function isSkippableCollection(PropertyMetadata $metadata): bool 344 { 345 return $metadata->xmlCollection && $metadata->xmlCollectionSkipWhenEmpty; 346 } 347 348 private function isElementEmpty(\DOMElement $element): bool 349 { 350 return !$element->hasChildNodes() && !$element->hasAttributes(); 351 } 352 353 public function endVisitingObject(ClassMetadata $metadata, object $data, array $type): void 354 { 355 $this->objectMetadataStack->pop(); 356 } 357 358 /** 359 * {@inheritdoc} 360 */ 361 public function getResult($node) 362 { 363 if (null === $this->document->documentElement) { 364 if ($node instanceof \DOMElement) { 365 $this->document->appendChild($node); 366 } else { 367 $this->createRoot(); 368 if ($node) { 369 $this->document->documentElement->appendChild($node); 370 } 371 } 372 } 373 374 if ($this->nullWasVisited) { 375 $this->document->documentElement->setAttributeNS( 376 'http://www.w3.org/2000/xmlns/', 377 'xmlns:xsi', 378 'http://www.w3.org/2001/XMLSchema-instance' 379 ); 380 } 381 return $this->document->saveXML(); 382 } 383 384 public function getCurrentNode(): ?\DOMNode 385 { 386 return $this->currentNode; 387 } 388 389 public function getCurrentMetadata(): ?PropertyMetadata 390 { 391 return $this->currentMetadata; 392 } 393 394 public function getDocument(): \DOMDocument 395 { 396 if (null === $this->document) { 397 $this->document = $this->createDocument(); 398 } 399 return $this->document; 400 } 401 402 public function setCurrentMetadata(PropertyMetadata $metadata): void 403 { 404 $this->metadataStack->push($this->currentMetadata); 405 $this->currentMetadata = $metadata; 406 } 407 408 public function setCurrentNode(\DOMNode $node): void 409 { 410 $this->stack->push($this->currentNode); 411 $this->currentNode = $node; 412 } 413 414 public function setCurrentAndRootNode(\DOMNode $node): void 415 { 416 $this->setCurrentNode($node); 417 $this->document->appendChild($node); 418 } 419 420 public function revertCurrentNode(): ?\DOMNode 421 { 422 return $this->currentNode = $this->stack->pop(); 423 } 424 425 public function revertCurrentMetadata(): ?PropertyMetadata 426 { 427 return $this->currentMetadata = $this->metadataStack->pop(); 428 } 429 430 /** 431 * {@inheritdoc} 432 */ 433 public function prepare($data) 434 { 435 $this->nullWasVisited = false; 436 437 return $data; 438 } 439 440 /** 441 * Checks that the name is a valid XML element name. 442 */ 443 private function isElementNameValid(string $name): bool 444 { 445 return $name && false === strpos($name, ' ') && preg_match('#^[\pL_][\pL0-9._-]*$#ui', $name); 446 } 447 448 /** 449 * Adds namespace attributes to the XML root element 450 */ 451 private function addNamespaceAttributes(ClassMetadata $metadata, \DOMElement $element): void 452 { 453 foreach ($metadata->xmlNamespaces as $prefix => $uri) { 454 $attribute = 'xmlns'; 455 if ('' !== $prefix) { 456 $attribute .= ':' . $prefix; 457 } elseif ($element->namespaceURI === $uri) { 458 continue; 459 } 460 $element->setAttributeNS('http://www.w3.org/2000/xmlns/', $attribute, $uri); 461 } 462 } 463 464 private function createElement(string $tagName, ?string $namespace = null): \DOMElement 465 { 466 if (null === $namespace) { 467 return $this->document->createElement($tagName); 468 } 469 if ($this->currentNode->isDefaultNamespace($namespace)) { 470 return $this->document->createElementNS($namespace, $tagName); 471 } 472 if (!($prefix = $this->currentNode->lookupPrefix($namespace)) && !($prefix = $this->document->lookupPrefix($namespace))) { 473 $prefix = 'ns-' . substr(sha1($namespace), 0, 8); 474 } 475 return $this->document->createElementNS($namespace, $prefix . ':' . $tagName); 476 } 477 478 private function setAttributeOnNode(\DOMElement $node, string $name, string $value, ?string $namespace = null): void 479 { 480 if (null !== $namespace) { 481 if (!$prefix = $node->lookupPrefix($namespace)) { 482 $prefix = 'ns-' . substr(sha1($namespace), 0, 8); 483 } 484 $node->setAttributeNS($namespace, $prefix . ':' . $name, $value); 485 } else { 486 $node->setAttribute($name, $value); 487 } 488 } 489 490 private function getClassDefaultNamespace(ClassMetadata $metadata): ?string 491 { 492 return $metadata->xmlNamespaces[''] ?? null; 493 } 494} 495