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