biurad/php-dependency-injection

View on GitHub
src/Adapters/XmlAdapter.php

Summary

Maintainability
D
3 days
Test Coverage
<?php

declare(strict_types=1);

/*
 * This file is part of Biurad opensource projects.
 *
 * PHP version 7.2 and above required
 *
 * @author    Divine Niiquaye Ibok <divineibok@gmail.com>
 * @copyright 2019 Biurad Group (https://biurad.com/)
 * @license   https://opensource.org/licenses/BSD-3-Clause License
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Biurad\DependencyInjection\Adapters;

use Closure;
use DOMComment;
use DOMDocument;
use DOMElement;
use DOMText;
use Exception;
use LogicException;
use Nette\DI;
use Nette\InvalidStateException;
use ReflectionObject;
use RuntimeException;
use stdClass;
use XMLWriter;

/**
 * Reading and generating XML/Html files for DI.
 *
 * @author Divine Niiquaye Ibok <divineibok@gmail.com>
 */
final class XmlAdapter implements Di\Config\Adapter
{
    public function __construct()
    {
        if (!\extension_loaded('dom') && !\extension_loaded('xmlwriter')) {
            throw new LogicException('Extension DOM and XmlWriter is required.');
        }
    }

    /**
     * Parses an XML string.
     *
     * @param string               $content          An XML string
     * @param null|callable|string $schemaOrCallable An XSD schema file path, a callable, or null to disable validation
     *
     * @throws InvalidStateException When parsing of XML file returns error
     * @throws InvalidXmlException   When parsing of XML with schema or callable produces any errors
     *                               unrelated to the XML parsing itself
     * @throws RuntimeException      When DOM extension is missing
     *
     * @return DOMDocument
     */
    public function parse(string $content, $schemaOrCallable = null): DOMDocument
    {
        $internalErrors  = \libxml_use_internal_errors(true);
        \libxml_clear_errors();

        $dom                  = new DOMDocument();
        $dom->validateOnParse = true;

        if (!$dom->loadXML($content, \LIBXML_NONET | (\defined('LIBXML_COMPACT') ? \LIBXML_COMPACT : 0))) {

            $dom->loadHTML($content);
        }

        $dom->normalizeDocument();

        \libxml_use_internal_errors($internalErrors);

        if (null !== $schemaOrCallable) {
            $internalErrors = \libxml_use_internal_errors(true);
            \libxml_clear_errors();

            $e = null;

            if (\is_callable($schemaOrCallable)) {
                try {
                    $valid = $schemaOrCallable($dom, $internalErrors);
                } catch (Exception $e) {
                    $valid = false;
                }
            } elseif (!\is_array($schemaOrCallable) && \is_file((string) $schemaOrCallable)) {
                $schemaSource = \file_get_contents((string) $schemaOrCallable);
                $valid        = @$dom->schemaValidateSource($schemaSource);
            } else {
                \libxml_use_internal_errors($internalErrors);

                throw new InvalidStateException(
                    'The schemaOrCallable argument has to be a valid path to XSD file or callable.'
                );
            }

            if (!$valid) {
                $messages = $this->getXmlErrors($internalErrors);

                if (empty($messages)) {
                    throw new RuntimeException('The XML is not valid.', 0, $e);
                }

                throw new InvalidStateException(\implode("\n", $messages), 0, $e);
            }
        }

        \libxml_clear_errors();
        \libxml_use_internal_errors($internalErrors);

        return $dom;
    }

    /**
     * Converts a \DOMElement object to a PHP array.
     *
     * The following rules applies during the conversion:
     *
     *  * Each tag is converted to a key value or an array
     *    if there is more than one "value"
     *
     *  * The content of a tag is set under a "value" key (<foo>bar</foo>)
     *    if the tag also has some nested tags
     *
     *  * The attributes are converted to keys (<foo foo="bar"/>)
     *
     *  * The nested-tags are converted to keys (<foo><foo>bar</foo></foo>)
     *
     * @param DOMElement $element     A \DOMElement instance
     * @param bool       $checkPrefix Check prefix in an element or an attribute name
     *
     * @return mixed
     */
    public function convertDomElementToArray(DOMElement $element, bool $checkPrefix = true)
    {
        $prefix = (string) $element->prefix;
        $empty  = true;
        $config = [];

        foreach ($element->attributes as $name => $node) {
            if ($checkPrefix && !\in_array((string) $node->prefix, ['', $prefix], true)) {
                continue;
            }
            $config[$name] = $this->phpize($node->value);
            $empty         = false;
        }

        $nodeValue = false;

        foreach ($element->childNodes as $node) {
            if ($node instanceof DOMText) {
                if ('' !== \trim($node->nodeValue)) {
                    $nodeValue = \trim($node->nodeValue);
                    $empty     = false;
                }
            } elseif ($checkPrefix && $prefix != (string) $node->prefix) {
                continue;
            } elseif (!$node instanceof DOMComment) {
                $value = $this->convertDomElementToArray($node, $checkPrefix);

                $key = $node->localName;

                if (isset($config[$key])) {
                    if (!\is_array($config[$key]) || !\is_int(\key($config[$key]))) {
                        $config[$key] = [$config[$key]];
                    }
                    $config[$key][] = $value;
                } else {
                    $config[$key] = $value;
                }

                $empty = false;
            }
        }

        if (false !== $nodeValue) {
            $value = $this->phpize($nodeValue);

            if (\count($config)) {
                $config['value'] = $value;
            } else {
                $config = $value;
            }
        }

        return !$empty ? $config : null;
    }

    /**
     * Reads configuration from XML data.
     *
     * {@inheritdoc}
     */
    public function load(string $file): array
    {
        $content = $this->parse(\file_get_contents($file));

        return $this->convertDomElementToArray($content->documentElement);
    }

    /**
     * Generates configuration in XML format,
     * Process the array|objects for dumping.
     *
     * @param array $config
     *
     * @return string
     */
    public function dump(array $data): string
    {
        $writer = new XMLWriter();
        $writer->openMemory();
        $writer->setIndent(true);
        $writer->setIndentString(\str_repeat(' ', 4));

        if (isset($config['head'], $config['body'])) {
            $writer->writeRaw("<!Doctype html>\n");
            $writer->startElement('html');
        } else {
            $writer->startDocument('1.0', 'UTF-8');
            $writer->startElement('container');
        }

        $data = \array_map(function ($value) {
            $value = $this->resolveClassObject($value);

            if (\is_array($value)) {
                return \array_map([$this, 'resolveClassObject'], $value);
            }

            return $value;
        }, $data);

        foreach ($data as $sectionName => $config) {
            if (!\is_array($config)) {
                $writer->writeAttribute($sectionName, $this->xmlize($config));
            } else {
                $this->addBranch($sectionName, $config, $writer);
            }
        }

        $writer->endElement();
        $writer->endDocument();

        return $writer->outputMemory();
    }

    /**
     * Add a branch to an XML/Html object recursively.
     *
     * @param string    $branchName
     * @param array     $config
     * @param XMLWriter $writer
     *
     * @throws RuntimeException
     */
    protected function addBranch($branchName, array $config, XMLWriter $writer): void
    {
        $branchType = null;

        foreach ($config as $key => $value) {
            if ($branchType === null) {
                if (\is_numeric($key)) {
                    $branchType = 'numeric';
                } else {
                    $writer->startElement($branchName);
                    $branchType = 'string';
                }
            } elseif ($branchType !== (\is_numeric($key) ? 'numeric' : 'string')) {
                throw new RuntimeException('Mixing of string and numeric keys is not allowed');
            }

            if ($branchType === 'numeric') {
                if (\is_array($value) || $value instanceof stdClass) {
                    $this->addBranch($branchName, (array) $value, $writer);
                } else {
                    $writer->writeElement($branchName, $this->xmlize($value));
                }
            } else {
                if (\is_array($value)) {
                    if (\array_key_exists('value', $value)) {
                        $newValue = $value['value'];
                        unset($value['value']);

                        $writer->startElement($key);
                        \array_walk_recursive($value, function ($item, $id) use (&$writer): void {
                            $writer->writeAttribute($id, $item);
                        });

                        $writer->writeRaw($this->xmlize($newValue));
                        $writer->endElement();
                        $writer->endAttribute();
                    } else {
                        $this->addBranch($key, $value, $writer);
                    }
                } else {
                    $writer->writeAttribute($key, $this->xmlize($value));
                }
            }
        }

        if ($branchType === 'string') {
            $writer->endElement();
        }
    }

    /**
     * Converts an xml value to a PHP type.
     *
     * @param mixed $value
     *
     * @return mixed
     */
    protected function phpize($value)
    {
        $value          = (string) $value;
        $lowercaseValue = \strtolower($value);

        switch (true) {
            case 'null' === $lowercaseValue:
                return null;
            case \ctype_digit($value):
                $raw  = $value;
                $cast = (int) $value;

                return '0' == $value[0] ? \octdec($value) : (((string) $raw === (string) $cast) ? $cast : $raw);
            case isset($value[1]) && '-' === $value[0] && \ctype_digit(\substr($value, 1)):
                $raw  = $value;
                $cast = (int) $value;

                return '0' == $value[1] ? \octdec($value) : (((string) $raw === (string) $cast) ? $cast : $raw);
            case 'true' === $lowercaseValue:
                return true;
            case 'false' === $lowercaseValue:
                return false;
            case isset($value[1]) && '0b' == $value[0] . $value[1] && \preg_match('/^0b[01]*$/', $value):
                return \bindec($value);
            case \is_numeric($value):
                return '0x' === $value[0] . $value[1] ? \hexdec($value) : (float) $value;
            case \preg_match('/^0x[0-9a-f]++$/i', $value):
                return \hexdec($value);
            case \preg_match('/^[+-]?[0-9]+(\.[0-9]+)?$/', $value):
                return (float) $value;
            default:
                return $value;
        }
    }

    /**
     * Converts an PHP type to a Xml value.
     *
     * @param mixed $value
     *
     * @return string
     */
    protected function xmlize($value): string
    {
        switch (true) {
            case null === $value:
                return 'null';
            case isset($value[1]) && '-' === $value[0] && \ctype_digit(\substr($value, 1)):
                $raw  = $value;
                $cast = (int) $value;

                return (string) 0 == $value[1] ? \octdec($value) : (((string) $raw === (string) $cast) ? $cast : $raw);
            case true === $value:
                return 'true';
            case false === $value:
                return 'false';
            case isset($value[1]) && '0b' == $value[0] . $value[1] && \preg_match('/^0b[01]*$/', (string) $value):
                return (string) \bindec($value);
            case \preg_match('/^0x[0-9a-f]++$/i', (string) $value):
                return \hexdec($value);
            case \preg_match('/^[+-]?[0-9]+(\.[0-9]+)?$/', (string) $value):
                return (string) (float) $value;
            default:
                return (string) $value;
        }
    }

    protected function resolveClassObject($value)
    {
        if (\is_object($value) && !$value instanceof Closure) {
            $properties = [];

            foreach ((new ReflectionObject($value))->getProperties() as $property) {
                $property->setAccessible(true);
                $properties[$property->getName()] = $property->getValue($value);
            }

            return $properties;
        }

        if ($value instanceof stdClass) {
            return (array) $value;
        }

        return $value;
    }

    protected function getXmlErrors(bool $internalErrors)
    {
        $errors = [];

        foreach (\libxml_get_errors() as $error) {
            $errors[] = \sprintf(
                '[%s %s] %s (in %s - line %d, column %d)',
                \LIBXML_ERR_WARNING == $error->level ? 'WARNING' : 'ERROR',
                $error->code,
                \trim($error->message),
                $error->file ?: 'n/a',
                $error->line,
                $error->column
            );
        }

        \libxml_clear_errors();
        \libxml_use_internal_errors($internalErrors);

        return $errors;
    }
}