Repository: JsonMapper/JsonMapper (php)

Issue:
Allow to map directly from JSON string
As it is quite common to have a JSON in string format. It would be helpful for users of the library to map from the string instead of an object. This would reduce their boiler plate code when using JsonMapper together with an HTTP JSON API.

The desired solution should add some convenience methods such as `mapObjectFromString` and `mapArrayFromString` to the `JsonMapperInterface` and it's implementation.


Pull request description:
Map from string
Closes #43 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d95e9b1..389ad37 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
+### Added
+- Add support for mapping from strings [PR#46](https://github.com/JsonMapper/JsonMapper/pull/46)
 
 ## [1.3.0] - 2020-08-11
 ### Added
diff --git a/composer.json b/composer.json
index d165781..9578146 100644
--- a/composer.json
+++ b/composer.json
@@ -16,7 +16,8 @@
         "myclabs/php-enum": "^1.7",
         "nikic/php-parser": "^4.3",
         "psr/log": "^1.1",
-        "psr/simple-cache": "^1.0"
+        "psr/simple-cache": "^1.0",
+        "symfony/polyfill-php73": "^1.18"
     },
     "require-dev": {
         "squizlabs/php_codesniffer": "^3.5",
diff --git a/src/JsonMapper.php b/src/JsonMapper.php
index 9b0801e..824266b 100644
--- a/src/JsonMapper.php
+++ b/src/JsonMapper.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace JsonMapper;
 
+use JsonException;
 use JsonMapper\ValueObjects\PropertyMap;
 use JsonMapper\Wrapper\ObjectWrapper;
 
@@ -98,6 +99,49 @@ class JsonMapper implements JsonMapperInterface
         return $results;
     }
 
+    public function mapObjectFromString(string $json, object $object): void
+    {
+        $data = $this->decodeJsonString($json);
+
+        if (! $data instanceof \stdClass) {
+            throw new \RuntimeException('Provided string is not a json encoded object');
+        }
+
+        $this->mapObject($data, $object);
+    }
+
+    public function mapArrayFromString(string $json, object $object): array
+    {
+        $data = $this->decodeJsonString($json);
+
+        if (! is_array($data)) {
+            throw new \RuntimeException('Provided string is not a json encoded array');
+        }
+
+        $results = [];
+        foreach ($data as $key => $value) {
+            $results[$key] = clone $object;
+            $this->mapObject($value, $results[$key]);
+        }
+
+        return $results;
+    }
+
+    /** @return \stdClass|\stdClass[] */
+    private function decodeJsonString(string $json)
+    {
+        if (PHP_VERSION_ID >= 70300) {
+            $data = json_decode($json, false, 512, JSON_THROW_ON_ERROR);
+        } else {
+            $data = json_decode($json, false);
+            if (json_last_error() !== JSON_ERROR_NONE) {
+                throw new JsonException(json_last_error_msg(), json_last_error());
+            }
+        }
+
+        return $data;
+    }
+
     private function resolve(): callable
     {
         if (!$this->cached) {
diff --git a/src/JsonMapperInterface.php b/src/JsonMapperInterface.php
index f70a8ff..6ec616b 100644
--- a/src/JsonMapperInterface.php
+++ b/src/JsonMapperInterface.php
@@ -21,4 +21,8 @@ interface JsonMapperInterface
     public function mapObject(\stdClass $json, object $object): void;
 
     public function mapArray(array $json, object $object): array;
+
+    public function mapObjectFromString(string $jsonString, object $object): void;
+
+    public function mapArrayFromString(string $jsonStrings, object $object): array;
 }


Test patch:
diff --git a/tests/Integration/JsonMapperTest.php b/tests/Integration/JsonMapperTest.php
index 7b92b8e..aa7e0f1 100644
--- a/tests/Integration/JsonMapperTest.php
+++ b/tests/Integration/JsonMapperTest.php
@@ -11,6 +11,9 @@ use JsonMapper\Tests\Implementation\Php74;
 use JsonMapper\Tests\Implementation\SimpleObject;
 use PHPUnit\Framework\TestCase;
 
+/**
+ * @covers \JsonMapper\JsonMapper
+ */
 class JsonMapperTest extends TestCase
 {
     public function testItCanMapAnObjectUsingAPublicProperty(): void
@@ -196,6 +199,44 @@ class JsonMapperTest extends TestCase
         self::assertEquals([$one, $two], $object->getChildren());
     }
 
+    public function testItCanMapAnObjectFromString(): void
+    {
+        // Arrange
+        $mapper = (new JsonMapperFactory())->bestFit();
+        $object = new Popo();
+        $json =  '{"name": "one"}';
+
+        // Act
+        $mapper->mapObjectFromString($json, $object);
+
+        // Assert
+        self::assertSame('one', $object->name);
+    }
+
+    public function testItWillThrowAnExceptionWhenMappingObjectFromStringWithJsonArray(): void
+    {
+        // Arrange
+        $mapper = (new JsonMapperFactory())->bestFit();
+        $object = new Popo();
+        $json = '[{"name": "one"}, {"name": "two"}]';
+        $this->expectException(\RuntimeException::class);
+
+        // Act
+        $mapper->mapObjectFromString($json, $object);
+    }
+
+    public function testItWillThrowExceptionOnInvalidJson(): void
+    {
+        // Arrange
+        $mapper = (new JsonMapperFactory())->bestFit();
+        $object = new Popo();
+        $jsonString =  '{"name": one}';
+        $this->expectException(\JsonException::class);
+
+        // Act
+        $mapper->mapObjectFromString($jsonString, $object);
+    }
+
     public function testItCanMapAnArrayOfObjects(): void
     {
         // Arrange
@@ -212,6 +253,34 @@ class JsonMapperTest extends TestCase
         self::assertSame('two', $result[1]->getName());
     }
 
+    public function testItCanMapArrayFromString(): void
+    {
+        // Arrange
+        $mapper = (new JsonMapperFactory())->bestFit();
+        $object = new SimpleObject();
+        $json = '[{"name": "one"}, {"name": "two"}]';
+
+        // Act
+        $result = $mapper->mapArrayFromString($json, $object);
+
+        // Assert
+        self::assertContainsOnly(SimpleObject::class, $result);
+        self::assertSame('one', $result[0]->getName());
+        self::assertSame('two', $result[1]->getName());
+    }
+
+    public function testItWillThrowAnExceptionWhenMappingArrayFromStringWithJsonObject(): void
+    {
+        // Arrange
+        $mapper = (new JsonMapperFactory())->bestFit();
+        $object = new Popo();
+        $json = '{"name": "one"}';
+        $this->expectException(\RuntimeException::class);
+
+        // Act
+        $mapper->mapArrayFromString($json, $object);
+    }
+
     /**
      * @dataProvider scalarValueDataTypes
      */
diff --git a/tests/Unit/Enums/ScalarTypeTest.php b/tests/Unit/Enums/ScalarTypeTest.php
index 5a42a4b..13d4dd8 100644
--- a/tests/Unit/Enums/ScalarTypeTest.php
+++ b/tests/Unit/Enums/ScalarTypeTest.php
@@ -26,7 +26,7 @@ class ScalarTypeTest extends TestCase
      */
     public function testCastOperationThrowsExceptionWhenCastOperationNotSupported(): void
     {
-        $extension = new class('random') extends ScalarType {
+        $extension = new class ('random') extends ScalarType {
             protected const RANDOM = 'random';
         };
 
diff --git a/tests/Unit/Handler/PropertyMapperTest.php b/tests/Unit/Handler/PropertyMapperTest.php
index cce9f29..f4e2ce5 100644
--- a/tests/Unit/Handler/PropertyMapperTest.php
+++ b/tests/Unit/Handler/PropertyMapperTest.php
@@ -182,7 +182,7 @@ class PropertyMapperTest extends TestCase
         $propertyMap = new PropertyMap();
         $propertyMap->addProperty($property);
         $jsonMapper = $this->createMock(JsonMapperInterface::class);
-        $json = (object) ['notes' =>(object) ['note_one' => __FUNCTION__, 'note_two' => __CLASS__]];
+        $json = (object) ['notes' => (object) ['note_one' => __FUNCTION__, 'note_two' => __CLASS__]];
         $object = new Popo();
         $wrapped = new ObjectWrapper($object);
         $propertyMapper = new PropertyMapper();