From c6959f423caae0f887bc90cd8131c69419ca465f Mon Sep 17 00:00:00 2001 From: Chris Leversuch Date: Thu, 24 Oct 2019 13:17:45 +0100 Subject: [PATCH] Add library and README --- .gitignore | 1 + README.md | 151 ++++++++++++++++++++++++++++++++++++++++++- composer.json | 13 ++++ src/Deserializer.php | 133 +++++++++++++++++++++++++++++++++++++ src/Helpers.php | 41 ++++++++++++ src/Serializer.php | 85 ++++++++++++++++++++++++ src/XmlProperty.php | 8 +++ 7 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 src/Deserializer.php create mode 100644 src/Helpers.php create mode 100644 src/Serializer.php create mode 100644 src/XmlProperty.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..485dee6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea diff --git a/README.md b/README.md index 88ca8b8..cb0c69f 100644 --- a/README.md +++ b/README.md @@ -1 +1,150 @@ -# PHP-XML \ No newline at end of file +# PHP-XML + +This is a library for serialization and deserialization between XML and PHP objects. The mapping between XML tag and PHP property is +done using annotations inside PHPDoc comments. + +## Basic Usage + +Given the following classes: + +```php +namespace App; + +class Films { + /** + * @var \App\Film[] + * @serializedName Films + * @serializedList Film + */ + public $films; +} +``` + +```php +namespace App; + +class Film { + /** + * Film title + * + * @var string|null + * @serializedName Title + */ + public $title; + + /** + * Release year (USA) + * + * @var int|null + * @serializedName ReleaseYear + */ + public $releaseYear; +} +``` + +### Deserializing + +```php +$body = << + + Avengers: Assemble + 2012 + + + Avengers: Age of Ultron + 2015 + + + Avengers: Infinity War + 2018 + + + Avengers: Endgame + 2019 + + +EOF; + +$deserializer = new Deserializer(); +try { + $object = $deserializer->parse($body, Films::class); + var_dump($object); +} catch (ReflectionException $e) { + var_dump($e); +} +``` + +Output +```php +object(App\Films)[7] + public 'films' => + array (size=4) + 0 => + object(App\Film)[13] + public 'title' => string 'Avengers: Assemble' (length=18) + public 'releaseYear' => int 2012 + 1 => + object(App\Film)[14] + public 'title' => string 'Avengers: Age of Ultron' (length=23) + public 'releaseYear' => int 2015 + 2 => + object(App\Film)[15] + public 'title' => string 'Avengers: Infinity War' (length=22) + public 'releaseYear' => int 2018 + 3 => + object(App\Film)[16] + public 'title' => string 'Avengers: Endgame' (length=17) + public 'releaseYear' => int 2019 +``` + +### Serializing + +```php +$films = new Films( + [ + new Film("Guardians of the Galaxy", 2014), + new Film("Guardians of the Galaxy Vol. 2", 2017), + ] +); + +$serializer = new Serializer(); +try { + $xml = $serializer->write($films, null); + var_dump($xml); +} catch (ReflectionException $e) { + var_dump($e); +} +``` + +Output +```xml + +Guardians of the Galaxy2014Guardians of the Galaxy Vol. 22017 +``` + +## Annotations + +- @var Defines the type of the property. + - Note: Types must always be fully qualified e.g. ```@var \App\Film``` rather than ```@var Film``` +- @serializedName Defines the XML tag for the property +- @serializedList Defines the property as an array, and specifies the XML tag of the child elements +- @cdata Specifies that the property should be wrapped in a CDATA tag (Used in serialization only) + +## Types + +This library currently supports the following types: + +- DateTime with the format ```Y-m-d\TH:i:s``` +- Scalar types: string, int, float, double, bool +- Custom classes and arrays of custom classes e.g. Foo, Foo[] + +## Possible improvements + +- Allow custom converters to be defined, such as int <-> bool, DateTime formats +- Use [Doctrine Annotations](https://github.com/doctrine/annotations) + +## See Also + +- https://github.com/runz0rd/mapper-php +- http://sabre.io/xml/ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..58ad5ff --- /dev/null +++ b/composer.json @@ -0,0 +1,13 @@ +{ + "name": "brightec/php-xml", + "description": "Serialize and deserialize between XML and PHP objects", + "license": "MIT", + "require": { + "php": "^7.2" + }, + "autoload": { + "psr-4": { + "PHP_XML\\": "src/" + } + } +} diff --git a/src/Deserializer.php b/src/Deserializer.php new file mode 100644 index 0000000..59cd007 --- /dev/null +++ b/src/Deserializer.php @@ -0,0 +1,133 @@ +reader = new XMLReader(); + $this->reader->xml($xml); + return $this->process($this->reader, $class); + } + + /** + * @param XMLReader $reader + * @param string $root Class or primitive type to process + * @param array $tags + * @param mixed $array + * @return mixed + * @throws ReflectionException + */ + private function process(XMLReader $reader, $root, $tags = [], &$array = null) { + if (Helpers::isTypeScalar($root)) { + $propertyMap = []; + } else { + $reflectionClass = new ReflectionClass($root); + $properties = $reflectionClass->getProperties(); + $propertyMap = $this->createPropertyMap($properties); + $object = $reflectionClass->newInstanceWithoutConstructor(); + } + + $targetTagDepth = max(0, count($tags) - 1); + + while ($reader->read()) { + $xmlProperty = $propertyMap[$reader->name] ?? null; + + if ($reader->nodeType == XMLReader::ELEMENT && !$reader->isEmptyElement) { + $tags[] = $reader->name; + + if (!is_null($xmlProperty)) { + if (isset($xmlProperty->annotations['var'])) { + $type = Helpers::getTypeFromAnnotation($xmlProperty->annotations['var'][0]); + + if ($type == 'DateTime') { + $value = DateTime::createFromFormat('Y-m-d\TH:i:s', strtok($reader->readString(), '.')); + if (!$value) { + $value = DateTime::createFromFormat('Y-m-d\TH:i:sP', strtok($reader->readString(), '.')); + } + } elseif (Helpers::isTypeScalar($type)) { + $value = $reader->readString(); + settype($value, $type); + } else { + $type = str_replace('[]', '', $type); + + if (isset($xmlProperty->annotations['serializedList'])) { + $value = []; + $this->process($reader, $type, ['array'], $value); + $this->processEndTag($xmlProperty, $object, $value, $tags); + } else { + $value = $this->process($reader, $type, $tags); + $this->processEndTag($xmlProperty, $object, $value, $tags); + } + } + } + } elseif (Helpers::isTypeScalar($root)) { + $value = $reader->readString(); + settype($value, $root); + } + } elseif ($reader->nodeType == XMLReader::END_ELEMENT) { + $this->processEndTag($xmlProperty, $object ?? null, $value ?? null, $tags); + + if (!is_null($array) && count($tags) == $targetTagDepth + 1) { + if (isset($object)) { + $array[] = $object; + $object = $reflectionClass->newInstanceWithoutConstructor(); + } else { + $array[] = $value; + } + } + + if (count($tags) == $targetTagDepth) { + break; + } + } + } + + return $object ?? null; + } + + private function processEndTag($xmlProperty, $object, $value, &$tags) { + if (!is_null($xmlProperty)) { + $xmlProperty->property->setValue($object, $value); + } + + array_pop($tags); + } + + /** + * @param \ReflectionProperty[] $properties + * @return array + */ + private function createPropertyMap(array $properties): array { + $propertyMap = []; + foreach ($properties as $property) { + $docComment = $property->getDocComment(); + $annotations = Helpers::getAnnotations($docComment); + $property->setAccessible(true); + if (isset($annotations['serializedName']) && isset($annotations['var'])) { + $xmlProperty = new XmlProperty(); + $xmlProperty->property = $property; + $xmlProperty->annotations = $annotations; + + $xmlKey = $annotations['serializedName'][0]; + $propertyMap[$xmlKey] = $xmlProperty; + } + } + return $propertyMap; + } +} diff --git a/src/Helpers.php b/src/Helpers.php new file mode 100644 index 0000000..29f74cd --- /dev/null +++ b/src/Helpers.php @@ -0,0 +1,41 @@ +[A-Za-z_-]+)(?:[ \t]+(?P.*?))?[ \t]*\r?$/m'; + if (preg_match_all($re, $docComment, $matches)) { + $numMatches = count($matches[0]); + + for ($i = 0; $i < $numMatches; ++$i) { + $annotations[$matches['name'][$i]][] = $matches['value'][$i]; + } + } + + return $annotations; + } + + public static function getTypeFromAnnotation(string $annotation) { + $type = $annotation; + $parts = explode("|", $type); + if (count($parts) > 1) { + $type = $parts[0]; + } + return $type; + } + + public static function isTypeScalar(string $type): bool { + return in_array($type, ['string', 'int', 'float', 'double', 'bool']); + } +} diff --git a/src/Serializer.php b/src/Serializer.php new file mode 100644 index 0000000..845e3b4 --- /dev/null +++ b/src/Serializer.php @@ -0,0 +1,85 @@ +openMemory(); + $writer->startDocument(); + if ($rootKey !== null) { + $writer->startElement($rootKey); + } + $this->process($object, $writer); + if ($rootKey !== null) { + $writer->endElement(); + } + $writer->endDocument(); + return $writer->outputMemory(); + } + + /** + * @param object $object + * @param XMLWriter $writer + * @throws \ReflectionException + */ + private function process(object $object, XMLWriter $writer) { + $reflectionClass = new ReflectionClass($object); + $properties = $reflectionClass->getProperties(); + foreach ($properties as $property) { + $property->setAccessible(true); + $docComment = $property->getDocComment(); + $annotations = Helpers::getAnnotations($docComment); + if (isset($annotations['serializedName']) && isset($annotations['var'])) { + $value = $property->getValue($object); + if (is_null($value)) { + continue; + } + + $writer->startElement($annotations['serializedName'][0]); + $type = Helpers::getTypeFromAnnotation($annotations['var'][0]); + $isCharacterData = isset($annotations['cdata']); + if ($type == 'DateTime') { + $this->writeElement($value->format('Y-m-d\TH:i:s'), $writer, $isCharacterData); + } elseif (Helpers::isTypeScalar($type)) { + $this->writeElement($value, $writer, $isCharacterData); + } elseif (isset($annotations['serializedList'])) { + $type = str_replace('[]', '', $type); + + foreach ($property->getValue($object) as $item) { + $writer->startElement($annotations['serializedList'][0]); + if (Helpers::isTypeScalar($type)) { + $this->writeElement($item, $writer, $isCharacterData); + } else { + $this->process($item, $writer); + } + $writer->endElement(); + } + } elseif (!is_null($value)) { + $this->process($value, $writer); + } + + $writer->endElement(); + } + } + } + + private function writeElement($value, XMLWriter $writer, bool $isCharacterData) { + if ($isCharacterData) { + $writer->startCdata(); + } + $writer->text($value); + if ($isCharacterData) { + $writer->endCdata(); + } + } +} diff --git a/src/XmlProperty.php b/src/XmlProperty.php new file mode 100644 index 0000000..41b181c --- /dev/null +++ b/src/XmlProperty.php @@ -0,0 +1,8 @@ +