From 2c94738b1a97be17a95bd47ddbeb70a391e852b6 Mon Sep 17 00:00:00 2001 From: kodeart Date: Thu, 30 Aug 2018 22:05:25 +0200 Subject: [PATCH] The library updated with new functionalities - adds new `Serializer\XmlSerializer` class - adds new functions: json_serialize(), json_unserialize(), xml_serialize(), xml_unserialize(), php_serialize() and php_unserialize() - updates the composer.json (some extensions requirements, new tags, branch alias) - updates the unit test (some files are renamed) --- Serializer/JsonSerializer.php | 7 +- Serializer/XmlSerializer.php | 172 ++++++++++++++++++ Tests/ConfigTest.php | 4 +- Tests/ExtendedArgumentsTest.php | 2 +- Tests/ImmutableObjectTest.php | 2 +- Tests/JsonSerializerFunctionsTest.php | 42 +++++ Tests/PhpSerializerFunctionsTest.php | 31 ++++ Tests/Serializer/XmlSerializerTest.php | 44 +++++ Tests/XmlSerializerFunctionsTest.php | 31 ++++ Tests/fixtures/error-message.php | 26 +++ Tests/fixtures/error-message.xml | 20 ++ .../{nested_array.php => nested-array.php} | 0 VERSION | 2 +- composer.json | 24 ++- functions.php | 86 +++++++++ phpunit.xml | 2 +- 16 files changed, 481 insertions(+), 14 deletions(-) create mode 100644 Serializer/XmlSerializer.php create mode 100644 Tests/JsonSerializerFunctionsTest.php create mode 100644 Tests/PhpSerializerFunctionsTest.php create mode 100644 Tests/Serializer/XmlSerializerTest.php create mode 100644 Tests/XmlSerializerFunctionsTest.php create mode 100644 Tests/fixtures/error-message.php create mode 100644 Tests/fixtures/error-message.xml rename Tests/fixtures/{nested_array.php => nested-array.php} (100%) diff --git a/Serializer/JsonSerializer.php b/Serializer/JsonSerializer.php index a97ef5d..c17f86e 100644 --- a/Serializer/JsonSerializer.php +++ b/Serializer/JsonSerializer.php @@ -17,6 +17,11 @@ final class JsonSerializer implements StringSerializable */ private $options; + /** + * JsonSerializer constructor. + * + * @param int $options [optional] JSON encode options + */ public function __construct(int $options = null) { $this->options = $options ?? @@ -33,7 +38,7 @@ public function serialize($value): string public function unserialize(string $value) { - $json = json_decode($value, true); + $json = json_decode(utf8_encode($value), true, 512, JSON_BIGINT_AS_STRING); if (JSON_ERROR_NONE !== json_last_error()) { throw KodedException::generic(json_last_error_msg()); diff --git a/Serializer/XmlSerializer.php b/Serializer/XmlSerializer.php new file mode 100644 index 0000000..571d540 --- /dev/null +++ b/Serializer/XmlSerializer.php @@ -0,0 +1,172 @@ + + * + * Please view the LICENSE distributed with this source code + * for the full copyright and license information. + * + */ + +namespace Koded\Stdlib\Serializer; + +use DateTime; +use DateTimeInterface; +use DOMDocument; +use DOMElement; +use Exception; +use Koded\Stdlib\Interfaces\StringSerializable; + +/** + * Class XmlSerializer is heavily copied from excellent + * Propel 3 runtime parser (XmlParser) and modified. + * + */ +final class XmlSerializer implements StringSerializable +{ + + private $root; + + public function __construct(string $root) + { + $this->root = $root; + } + + /** + * @param iterable $data + * + * @return string XML + */ + public function serialize($data): string + { + $xml = new DOMDocument('1.0', 'UTF-8'); + $xml->preserveWhiteSpace = false; + $xml->formatOutput = true; + + $root = $xml->createElement($this->root); + $xml->appendChild($root); + $this->parseFromArray($data, $root); + + return $xml->saveXML(); + } + + /** + * @param string $document XML string + * + * @return array + */ + public function unserialize(string $document) + { + $xml = new DOMDocument('1.0', 'UTF-8'); + + try { + $xml->loadXML(utf8_encode($document)); + } catch (Exception $e) { + return []; + } + + return $this->parseFromElement($xml->documentElement); + } + + private function parseFromArray(iterable $data, DOMElement $element): DOMElement + { + foreach ($data as $key => $value) { + if (is_numeric($key)) { + $key = $element->nodeName; + if ('s' === mb_substr($key, -1, 1)) { + $key = mb_substr($key, 0, mb_strlen($key) - 1); + } + } + + try { + $child = $element->ownerDocument->createElement($key); + } catch (Exception $e) { + error_log(sprintf('[%s] thrown while parsing the data into XML, with message "%s" for the key %s and value %s', + get_class($e), + $e->getMessage(), + var_export($key, true), + var_export($value, true) + )); + continue; + } + + if (is_array($value)) { + $child = $this->parseFromArray($value, $child); + } elseif (is_string($value)) { + $value = htmlentities($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $child->appendChild($child->ownerDocument->createCDATASection($value)); + } elseif ($value instanceof DateTimeInterface) { + $child->setAttribute('type', 'xsd:dateTime'); + $child->appendChild($child->ownerDocument->createTextNode($value->format(DateTime::ISO8601))); + } elseif (is_object($value)) { + $child->setAttribute('type', 'xsd:token'); + $child->appendChild($child->ownerDocument->createCDATASection(serialize($value))); + } else { + $child->appendChild($child->ownerDocument->createTextNode($value)); + } + + $element->appendChild($child); + } + + return $element; + } + + private function parseFromElement(DOMElement $element): array + { + $result = []; + $names = []; + + /** @var DOMElement $node */ + foreach ($element->childNodes as $node) { + if (XML_TEXT_NODE === $node->nodeType) { + continue; + } + + $name = $node->nodeName; + + if (isset($names[$name])) { + if (isset($result[$name])) { + $result[$names[$name]] = $result[$name]; + unset($result[$name]); + } + + $names[$name] += 1; + $index = $names[$name]; + } else { + $names[$name] = 0; + $index = $name; + } + + $hasChildNodes = $node->hasChildNodes(); + + if (false === $hasChildNodes) { + $result[$index] = null; + } elseif ('xsd:token' === $node->getAttribute('type')) { + $result[$index] = unserialize($node->firstChild->textContent); + } elseif ($hasChildNodes && false === $this->hasOnlyTextNodes($node)) { + $result[$index] = $this->parseFromElement($node); + } elseif ($hasChildNodes && XML_CDATA_SECTION_NODE === $node->firstChild->nodeType) { + $result[$index] = html_entity_decode($node->firstChild->textContent, ENT_QUOTES | ENT_HTML5); + } elseif ('xsd:dateTime' === $node->getAttribute('type')) { + $result[$index] = new DateTime($node->textContent); + } else { + $result[$index] = $node->textContent; + } + } + + return $result; + } + + private function hasOnlyTextNodes(DOMElement $node): bool + { + foreach ($node->childNodes as $child) { + if (($child->nodeType !== XML_CDATA_SECTION_NODE) && ($child->nodeType !== XML_TEXT_NODE)) { + return false; + } + } + + return true; + } +} diff --git a/Tests/ConfigTest.php b/Tests/ConfigTest.php index 8ee4298..ccb1011 100644 --- a/Tests/ConfigTest.php +++ b/Tests/ConfigTest.php @@ -98,7 +98,7 @@ public function test_should_load_env_file_and_trim_the_namespace() public function test_should_load_from_env_variable() { - putenv('CONFIG_FILE=Tests/fixtures/nested_array.php'); + putenv('CONFIG_FILE=Tests/fixtures/nested-array.php'); $config = new Config; $config->fromEnvVariable('CONFIG_FILE'); @@ -204,6 +204,6 @@ class MockOtherConfigInstance extends Config public function __construct() { parent::__construct(); - $this->fromPhpFile(__DIR__ . '/fixtures/nested_array.php'); + $this->fromPhpFile(__DIR__ . '/fixtures/nested-array.php'); } } diff --git a/Tests/ExtendedArgumentsTest.php b/Tests/ExtendedArgumentsTest.php index 3191e29..a80532f 100644 --- a/Tests/ExtendedArgumentsTest.php +++ b/Tests/ExtendedArgumentsTest.php @@ -95,7 +95,7 @@ public function test_flatten($input) public function test_frakked_up_keys() { - $data = include __DIR__ . '/fixtures/nested_array.php'; + $data = include __DIR__ . '/fixtures/nested-array.php'; $arguments = new ExtendedArguments($data); $this->assertSame($data, $arguments->toArray(), 'The data is intact'); diff --git a/Tests/ImmutableObjectTest.php b/Tests/ImmutableObjectTest.php index b7083c2..983d58c 100644 --- a/Tests/ImmutableObjectTest.php +++ b/Tests/ImmutableObjectTest.php @@ -98,6 +98,6 @@ public function test_should_filter_out_the_data() protected function setUp() { - $this->SUT = new Immutable(require __DIR__ . '/fixtures/nested_array.php'); + $this->SUT = new Immutable(require __DIR__ . '/fixtures/nested-array.php'); } } diff --git a/Tests/JsonSerializerFunctionsTest.php b/Tests/JsonSerializerFunctionsTest.php new file mode 100644 index 0000000..fdd0852 --- /dev/null +++ b/Tests/JsonSerializerFunctionsTest.php @@ -0,0 +1,42 @@ +assertEquals(JsonSerializerTest::SERIALIZED_JSON, json_serialize($data)); + } + + public function test_unserialize_json() + { + $this->assertEquals( + json_decode(JsonSerializerTest::SERIALIZED_JSON, true), + json_unserialize(JsonSerializerTest::SERIALIZED_JSON) + ); + } + + public function test_unserialize_error() + { + $this->expectException(KodedException::class); + $this->expectExceptionMessage('[Exception] Syntax error'); + + json_unserialize(''); + } + + public function data() + { + return [ + [ + require __DIR__ . '/fixtures/config-test.php' + ] + ]; + } +} diff --git a/Tests/PhpSerializerFunctionsTest.php b/Tests/PhpSerializerFunctionsTest.php new file mode 100644 index 0000000..fc27021 --- /dev/null +++ b/Tests/PhpSerializerFunctionsTest.php @@ -0,0 +1,31 @@ +assertEquals($this->serialized, php_serialize($this->original)); + } + + public function test_unserialize_php() + { + $this->assertEquals($this->original, php_unserialize($this->serialized)); + } + + protected function setUp() + { + $this->original = require __DIR__ . '/fixtures/config-test.php'; + $this->serialized = php_serialize($this->original); + } +} diff --git a/Tests/Serializer/XmlSerializerTest.php b/Tests/Serializer/XmlSerializerTest.php new file mode 100644 index 0000000..9493265 --- /dev/null +++ b/Tests/Serializer/XmlSerializerTest.php @@ -0,0 +1,44 @@ +SUT->serialize(require self::PHP_FILE); + $this->assertXmlStringEqualsXmlFile(self::XML_FILE, $xml); + } + + public function test_unserialize() + { + $array = $this->SUT->unserialize(file_get_contents(self::XML_FILE)); + $this->assertEquals(require self::PHP_FILE, $array); + } + + public function test_unserialize_error_should_return_empty_array() + { + $this->assertSame([], $this->SUT->unserialize('')); + } + + public function test_frankenstein_array() + { + $array = require __DIR__ . '/../fixtures/nested-array.php'; + $this->SUT->serialize($array); + $this->assertEquals(require __DIR__ . '/../fixtures/nested-array.php', $array); + } + + protected function setUp() + { + $this->SUT = new XmlSerializer('payload'); + } +} diff --git a/Tests/XmlSerializerFunctionsTest.php b/Tests/XmlSerializerFunctionsTest.php new file mode 100644 index 0000000..f2d14e6 --- /dev/null +++ b/Tests/XmlSerializerFunctionsTest.php @@ -0,0 +1,31 @@ +assertXmlStringEqualsXmlFile( + XmlSerializerTest::XML_FILE, + xml_serialize('payload', require XmlSerializerTest::PHP_FILE) + ); + } + + public function test_unserialize() + { + $this->assertEquals( + require XmlSerializerTest::PHP_FILE, + xml_unserialize('payload', file_get_contents(XmlSerializerTest::XML_FILE)) + ); + } + + public function test_unserialize_error_should_return_empty_array() + { + $this->assertSame([], xml_unserialize('', '')); + } +} diff --git a/Tests/fixtures/error-message.php b/Tests/fixtures/error-message.php new file mode 100644 index 0000000..9b758ab --- /dev/null +++ b/Tests/fixtures/error-message.php @@ -0,0 +1,26 @@ +key = 'value'; + +return [ + 'message' => 'The error message & it\'s "details"', + 'code' => 400, + 'errors' => [ + 'field-one' => [ + 'explain' => 'Field one', + 'reason' => 'The Reason' + ], + 'field-two' => 'Field two' + ], + 'arguments' => [ + 'it', + 'equals', + 42 + ], + 'datetime' => $datetime, + 'nothing' => null, + 'instance' => $stdClass, +]; \ No newline at end of file diff --git a/Tests/fixtures/error-message.xml b/Tests/fixtures/error-message.xml new file mode 100644 index 0000000..e36af49 --- /dev/null +++ b/Tests/fixtures/error-message.xml @@ -0,0 +1,20 @@ + + + + 400 + + + + + + + + + + + 42 + + 2167-04-30T09:16:15+0000 + + + \ No newline at end of file diff --git a/Tests/fixtures/nested_array.php b/Tests/fixtures/nested-array.php similarity index 100% rename from Tests/fixtures/nested_array.php rename to Tests/fixtures/nested-array.php diff --git a/VERSION b/VERSION index 56fea8a..a0cd9f0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.0.0 \ No newline at end of file +3.1.0 \ No newline at end of file diff --git a/composer.json b/composer.json index 44a99bf..9b38768 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,18 @@ "type": "library", "license": "BSD-3-Clause", "description": "A standard library for shareable classes and functions", - "keywords": ["mime", "mime-types", "uuid", "uuid-generator", "immutable", "utility", "exceptions", "dto", "dot-array", "php7"], + "keywords": [ + "mime", + "mime-types", + "uuid", + "uuid-generator", + "immutable", + "utility", + "exceptions", + "dto", + "dot-array", + "serializer" + ], "authors": [ { "name": "Mihail Binev", @@ -12,7 +23,11 @@ ], "require": { "php": "^7.1.4", - "psr/http-message": "~1" + "psr/http-message": "~1", + "ext-json": "*", + "ext-dom": "*", + "ext-libxml": "*", + "ext-simplexml": "*" }, "autoload": { "psr-4": { @@ -33,10 +48,5 @@ "files": [ "functions-dev.php" ] - }, - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } } } diff --git a/functions.php b/functions.php index f012f24..47ab10e 100644 --- a/functions.php +++ b/functions.php @@ -13,6 +13,7 @@ namespace Koded\Stdlib; use Koded\Stdlib\Interfaces\{ Argument, Data }; +use Koded\Stdlib\Serializer\{JsonSerializer, PhpSerializer, XmlSerializer}; /** * Creates a new Argument instance with optional arbitrary number of arguments. @@ -99,3 +100,88 @@ function camel_to_snake_case(string $string): string { return strtolower(preg_replace('/(?<=\\w)([A-Z])/', '_\\1', trim($string))); } + +/** + * Serializes the iterable instance or array into JSON format. + * + * @param mixed $data The data to be serialized, except resource + * @param int $options [optional] JSON bitmask options + * + * @return string JSON encoded string + * @see http://php.net/manual/en/function.json-encode.php + */ +function json_serialize($data, int $options = null): string +{ + return (new JsonSerializer($options))->serialize($data); +} + +/** + * Decodes the encoded JSON string into appropriate PHP type. + * + * @param string $json The encoded JSON string + * + * @return mixed The value encoded in JSON in appropriate PHP type + * @throws \Koded\Exceptions\KodedException on error + */ +function json_unserialize(string $json) +{ + return (new JsonSerializer)->unserialize($json); +} + +/** + * Serializes the PHP object into string. + * + * @param object $object The PHP object to be serialized + * @param bool $binary [optional] TRUE for igbinary serialization, + * or standard PHP serialize() function + * + * @return string byte-stream representation of the serialized PHP object + */ +function php_serialize($object, bool $binary = false): string +{ + return (new PhpSerializer($binary))->serialize($object); +} + +/** + * Unserialize the serialized PHP object into it's appropriate type. + * + * @param string $serialized The serialized PHP object + * @param bool $binary [optional] TRUE for igbinary serialization, + * or standard PHP serialize() function + * + * @return mixed The PHP variant if serialization was successful, + * or FALSE if converted object is unserializeable + */ +function php_unserialize(string $serialized, bool $binary = false) +{ + return (new PhpSerializer($binary))->unserialize($serialized); +} + +/** + * Serializes the data into XML document. + * + * @param string $root The XML document root name + * @param iterable $data The data to be encoded + * + * @return string XML document + */ +function xml_serialize(string $root, iterable $data): string +{ + return (new XmlSerializer($root))->serialize($data); + +} + +/** + * Unserialize the XML document into PHP array. + * This function does not deal with magical conversions of complicated XML structures. + * + * @param string $root The XML document root name + * @param string $xml The XML document to be decoded into array + * + * @return array Decoded version of the XML string, + * or empty array on malformed XML + */ +function xml_unserialize(string $root, string $xml): array +{ + return (new XmlSerializer($root))->unserialize($xml); +} diff --git a/phpunit.xml b/phpunit.xml index 68a1188..b1707f0 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -6,7 +6,7 @@ colors="true"> - Tests + Tests