251 lines
8.5 KiB
PHP
251 lines
8.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/*
|
|
* This file is part of the TYPO3 CMS project.
|
|
*
|
|
* It is free software; you can redistribute it and/or modify it under
|
|
* the terms of the GNU General Public License, either version 2
|
|
* of the License, or any later version.
|
|
*
|
|
* For the full copyright and license information, please read the
|
|
* LICENSE.txt file that was distributed with this source code.
|
|
*
|
|
* The TYPO3 project - inspiring people to share!
|
|
*/
|
|
|
|
namespace TYPO3\CMS\Core\Serializer;
|
|
|
|
use TYPO3\CMS\Core\Serializer\Exception\InvalidDataException;
|
|
use TYPO3\CMS\Core\Utility\MathUtility;
|
|
|
|
/**
|
|
* Decodes XML string to PHP array.
|
|
*
|
|
* A dedicated set of node attributes is considered during conversion:
|
|
* - attribute "index" specifies the final node name which is used as key in the PHP array
|
|
* - attribute "type" specifies the node value type which is used for casting
|
|
* - attribute "base64" specifies the node value type being binary and requiring a
|
|
* base64-decoding
|
|
* These attributes were applied during encoding of the PHP array with XmlEncoder::encode().
|
|
*
|
|
* The node name "n{number}" is converted to a number-indexed array key "{number}".
|
|
*
|
|
* @internal still experimental
|
|
*/
|
|
readonly class Typo3XmlParser
|
|
{
|
|
/**
|
|
* This method serves as a wrapper for decode() and is used to replace
|
|
* GeneralUtility::xml2array(), which returns an exception as a string instead of throwing it.
|
|
* In perspective, all uses of this method should be replaced by decode() and the exceptions
|
|
* should be handled locally.
|
|
*
|
|
* @param string $xml XML string
|
|
* @param Typo3XmlSerializerOptions|null $options Decoding configuration - see decode() for details
|
|
* @return array|string PHP array - or a string if the XML root node is empty or an exception
|
|
*/
|
|
public function decodeWithReturningExceptionAsString(
|
|
string $xml,
|
|
?Typo3XmlSerializerOptions $options = null
|
|
): array|string {
|
|
try {
|
|
return $this->decode($xml, $options);
|
|
} catch (\Throwable $e) {
|
|
return $e->getMessage();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param string $xml XML string
|
|
* @param Typo3XmlSerializerOptions|null $options Apply specific decoding configuration - Ignored node types, libxml2 options, ...
|
|
* @return array|string PHP array - or a string if the XML root node is empty
|
|
* @throws InvalidDataException
|
|
*/
|
|
public function decode(
|
|
string $xml,
|
|
?Typo3XmlSerializerOptions $options = null
|
|
): array|string {
|
|
$xml = trim($xml);
|
|
if ($xml === '') {
|
|
throw new InvalidDataException(
|
|
'Invalid XML data, it can not be empty.',
|
|
1630773210
|
|
);
|
|
}
|
|
|
|
$options = $options ?? new Typo3XmlSerializerOptions();
|
|
|
|
if ($options->allowUndefinedNamespaces()) {
|
|
$xml = $this->disableNamespaceInNodeNames($xml);
|
|
}
|
|
|
|
$internalErrors = libxml_use_internal_errors(true);
|
|
libxml_clear_errors();
|
|
|
|
$dom = new \DOMDocument();
|
|
$dom->loadXML($xml, $options->getLoadOptions());
|
|
|
|
libxml_use_internal_errors($internalErrors);
|
|
|
|
if ($error = libxml_get_last_error()) {
|
|
libxml_clear_errors();
|
|
throw new InvalidDataException(
|
|
'Line ' . $error->line . ': ' . xml_error_string($error->code),
|
|
1630773230
|
|
);
|
|
}
|
|
|
|
$rootNode = null;
|
|
foreach ($dom->childNodes as $child) {
|
|
if ($child->nodeType === \XML_DOCUMENT_TYPE_NODE) {
|
|
throw new InvalidDataException(
|
|
'Document types are not allowed.',
|
|
1630773261
|
|
);
|
|
}
|
|
if (in_array($child->nodeType, $options->getIgnoredNodeTypes(), true)) {
|
|
continue;
|
|
}
|
|
$rootNode = $child;
|
|
break;
|
|
}
|
|
if ($rootNode === null) {
|
|
throw new InvalidDataException(
|
|
'Root node cannot be determined.',
|
|
1630773276
|
|
);
|
|
}
|
|
|
|
$rootNodeName = $rootNode->nodeName;
|
|
if ($options->allowUndefinedNamespaces()) {
|
|
$rootNodeName = $this->reactivateNamespaceInNodeName($rootNodeName);
|
|
}
|
|
if (!$rootNode->hasChildNodes()) {
|
|
if ($options->includeRootNode()) {
|
|
$result = [$rootNodeName => $rootNode->nodeValue];
|
|
} else {
|
|
$result = $rootNode->nodeValue;
|
|
}
|
|
} else {
|
|
if ($options->includeRootNode()) {
|
|
$result = [$rootNodeName => $this->parseXml($rootNode, $options)];
|
|
} else {
|
|
$result = $this->parseXml($rootNode, $options);
|
|
}
|
|
}
|
|
if ($options->returnRootNodeName() && is_array($result)) {
|
|
$result['_DOCUMENT_TAG'] = $rootNodeName;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* DOMDocument::loadXML() breaks if prefixes of undefined namespaces are used in node names:
|
|
* Replace namespace divider ":" by temporary "___" string before parsing the XML.
|
|
*/
|
|
protected function disableNamespaceInNodeNames(string $value): string
|
|
{
|
|
return preg_replace(
|
|
['#<([/]?)([[:alnum:]_-]*):([[:alnum:]_-]*)([ >]?)#'],
|
|
['<$1$2___$3$4'],
|
|
$value
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Re-insert the namespace divider ":" into all node names again after parsing the XML.
|
|
*/
|
|
protected function reactivateNamespaceInNodeNames(string $value): string
|
|
{
|
|
if (!str_contains($value, '___')) {
|
|
return $value;
|
|
}
|
|
|
|
return preg_replace(
|
|
['#<([/]?)([[:alnum:]_-]*)___([[:alnum:]_-]*)([ >]?)#'],
|
|
['<$1$2:$3$4'],
|
|
$value
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Re-insert the namespace divider ":" into single node name again after parsing the XML.
|
|
*/
|
|
protected function reactivateNamespaceInNodeName(string $value): string
|
|
{
|
|
return str_replace('___', ':', $value);
|
|
}
|
|
|
|
protected function parseXml(\DOMNode $node, Typo3XmlSerializerOptions $options): array|string|null
|
|
{
|
|
if (!$node->hasChildNodes()) {
|
|
return $node->nodeValue;
|
|
}
|
|
|
|
if ($node->childNodes->length === 1
|
|
&& in_array($node->firstChild->nodeType, [\XML_TEXT_NODE, \XML_CDATA_SECTION_NODE])
|
|
) {
|
|
$value = $node->firstChild->nodeValue;
|
|
if ($options->allowUndefinedNamespaces()) {
|
|
$value = $this->reactivateNamespaceInNodeNames($value);
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
$result = [];
|
|
foreach ($node->childNodes as $child) {
|
|
if (in_array($child->nodeType, $options->getIgnoredNodeTypes(), true)) {
|
|
continue;
|
|
}
|
|
|
|
$value = $this->parseXml($child, $options);
|
|
|
|
if ($child instanceof \DOMElement && $child->hasAttribute('index')) {
|
|
$key = $child->getAttribute('index');
|
|
} else {
|
|
$key = $child->nodeName;
|
|
if ($options->allowUndefinedNamespaces()) {
|
|
$key = $this->reactivateNamespaceInNodeName($key);
|
|
}
|
|
if ($options->hasNamespacePrefix()
|
|
&& str_starts_with($key, $options->getNamespacePrefix())
|
|
) {
|
|
$key = substr($key, strlen($options->getNamespacePrefix()));
|
|
}
|
|
if (str_starts_with($key, 'n')
|
|
&& MathUtility::canBeInterpretedAsInteger($index = substr($key, 1))
|
|
) {
|
|
$key = (int)$index;
|
|
}
|
|
}
|
|
|
|
if ($child instanceof \DOMElement && $child->hasAttribute('base64') && is_string($value)) {
|
|
$value = base64_decode($value);
|
|
} elseif ($child instanceof \DOMElement && $child->hasAttribute('type')) {
|
|
switch ($child->getAttribute('type')) {
|
|
case 'integer':
|
|
$value = (int)$value;
|
|
break;
|
|
case 'double':
|
|
$value = (float)$value;
|
|
break;
|
|
case 'boolean':
|
|
$value = (bool)$value;
|
|
break;
|
|
case 'NULL':
|
|
$value = null;
|
|
break;
|
|
case 'array':
|
|
$value = is_array($value) ? $value : (empty(trim($value)) ? [] : (array)$value);
|
|
break;
|
|
}
|
|
}
|
|
$result[$key] = $value;
|
|
}
|
|
return $result;
|
|
}
|
|
}
|