susina/xml-to-array

View on GitHub
src/Converter.php

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
<?php declare(strict_types=1);
/*
 * Copyright (c) Cristiano Cinotti 2024.
 *
 *  Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 *  You may obtain a copy of the License at
 *  http://www.apache.org/licenses/LICENSE-2.0
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */

namespace Susina\XmlToArray;

use SimpleXMLElement;
use Susina\XmlToArray\Exception\ConverterException;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Class to convert an xml string to array
 */
final class Converter
{
    private array $options;

    /**
     * Static constructor.
     */
    public static function create(array $options = []): self
    {
        return new self($options);
    }

    public function __construct(array $options = [])
    {
        $resolver = new OptionsResolver();
        $this->configureOptions($resolver);
        $this->options = $resolver->resolve($options);
    }

    /**
     * Create a PHP array from an XML string.
     *
     * @param string $xmlToParse The XML to parse.
     *
     * @return array
     *
     * @throws ConverterException If errors while parsing XML.
     */
    public function convert(string $xmlToParse): array
    {
        $xmlToParse = $this->normalizeXml($xmlToParse);
        $flags = JSON_THROW_ON_ERROR | ($this->options['typesAsString'] === false ? JSON_NUMERIC_CHECK : 0);

        $content = json_encode($this->getSimpleXml($xmlToParse), $flags);
        /** @var array $array */
        $array = json_decode($content, true, 512, JSON_THROW_ON_ERROR);

        $array = $this->options['mergeAttributes'] === true ? $this->mergeAttributes($array) : $array;
        $array = $this->options['typesAsString'] === false ? $this->convertBool($array) : $array;
        $array = $this->options['typesAsString'] === false ? $this->convertEmptyArrayToNull($array) : $this->convertEmptyArrayToNull($array, true);

        return $array;
    }

    /**
     * Convert an XML string to array and save it to file.
     *
     * @param string $xmlToParse The XML to parse
     * @param string $filename The file name where to save the parsed array
     *
     * @throw \RuntimeException If the file is not writeable or the directory doesn't exist
     */
    public function convertAndSave(string $xmlToParse, string $filename): void
    {
        $dirName = dirname($filename);
        if (!file_exists($dirName)) {
            throw new \RuntimeException("The directory `$dirName` does not exist: you should create it before writing a file.");
        }

        if (!is_writable($dirName)) {
            throw new \RuntimeException("It's impossible to write into `$dirName` directory: do you have the correct permissions?");
        }

        $array = $this->convert($xmlToParse);

        $content = "<?php declare(strict_types=1);
/*
 * This file is auto-generated by susina/xml-to-array library.
 */

return " . var_export($array, true) . ";
";
        file_put_contents($filename, $content);
    }

    private function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'mergeAttributes' => true,
            'typesAsString' => false,
            'preserveFirstTag' => false
        ]);

        $resolver->setAllowedTypes('mergeAttributes', 'bool');
        $resolver->setAllowedTypes('typesAsString', 'bool');
        $resolver->setAllowedTypes('preserveFirstTag', 'bool');
    }

    /**
     * Parse an XML string and return the relative SimpleXmlElement object.
     */
    private function getSimpleXml(string $xmlToParse): SimpleXMLElement
    {
        $currentInternalErrors = libxml_use_internal_errors(true);

        $xml = simplexml_load_string($xmlToParse);
        if ($xml instanceof SimpleXMLElement) {
            dom_import_simplexml($xml)->ownerDocument->xinclude();
        }

        $errors = libxml_get_errors();

        libxml_clear_errors();
        libxml_use_internal_errors($currentInternalErrors);

        if ($xml === false) {
            throw new ConverterException($errors);
        }

        return $xml;
    }

    /**
     * Merge '@attributes' array into parent.
     *
     * @psalm-suppress MixedAssignment
     */
    private function mergeAttributes(array $array): array
    {
        $out = [];
        /** @var mixed $value */
        foreach ($array as $key => $value) {
            if ($key === '@attributes') {
                /** @var array $value */
                $out = array_merge($out, $value);
                continue;
            }

            $out[$key] = is_array($value) ? $this->mergeAttributes($value) : $value;
        }

        return $out;
    }

    /**
     * Convert all truely and falsy strings ('True', 'False' etc.)
     * into boolean values.
     *
     * @psalm-suppress MixedAssignment
     */
    private function convertBool(array $array): array
    {
        array_walk_recursive($array, function (mixed &$value): void {
            $value = match(true) {
                is_string($value) && strtolower($value) === 'true' => true,
                is_string($value) && strtolower($value) === 'false' => false,
                default => $value
            };
        });

        return $array;
    }

    private function convertEmptyArrayToNull(array $array, bool $toString = false): array
    {
        return array_map(function (mixed $value) use ($toString) {
            return match (true) {
                $value === [] => $toString ? 'null' : null,
                is_array($value) => $this->convertEmptyArrayToNull($value),
                default => $value
            };
        }, $array);
    }

    private function normalizeXml(string $xml): string
    {
        $xml = preg_replace_callback_array([
            '/<\?([\\s\\S]*?)\?>/' => fn (): string => '',  //Remove header
            '/<!--([\\s\\S]*?)-->/' => fn (): string => '', //Remove comments
            '/<!\[CDATA\[([\\s\\S]*?)\]\]>/' => function (array $matches): string {
                /** @var string $matches[1] */
                return str_replace(['<', '>'], ['&lt;', '&gt;'], $matches[1]);
            } //Convert CDATA into escaped strings
        ], $xml);

        $xml = $xml ?? '';

        //Add a fake tag to preserve the first one
        $xml = $this->options['preserveFirstTag'] === true ? "<fake-tag>$xml</fake-tag>" : $xml;

        return $xml;
    }
}