Repository: JsonMapper/JsonMapper (php)

Issue:
Add support for nullable properties
While serializing objects I'm getting some properties set to null sometimes, and this is crashing my application with the following message:
```
Argument 1 passed to JsonMapper\JsonMapper::mapObject() must be an instance of stdClass, null given
```
As `json-mapper/json-mapper/src/JsonMapper.php::mapObject` gets the null value instead of the json object corresponding to the standard response.

I've tried setting the variable[ as nullable](https://docs.phpdoc.org/latest/references/phpdoc/tags/var.html) in the docblock but it seems the whole comment line is taken as a string instead of separated in components.
When I update this:
```php
/**
 * @var TravelperkBankAccount
 */
public $travelperkBankAccount;
```

To this:
```php
/**
 * @var TravelperkBankAccount|null
 */
public $travelperkBankAccount;
```

I get this:
```
Class 'Namelivia\TravelPerk\Expenses\Types\TravelperkBankAccount|null' not found
```
on `json-mapper/json-mapper/src/Handler/PropertyMapper.php::mapPropertyValue`.

To my understanding what comes on the type comment is not being parsed and broken down into components, because I get a similar error too if I write:
```php
/**
 * @var TravelperkBankAccount This is a bank account number.
 */
public $travelperkBankAccount;
```

More or less I think how could this new feature would be implemented, so if you confirm my assumptions are right I can try implementing it myself and opening a PR.

Pull request:
Fix docblock type parsing
This replaces #58 and will resolve #57 

Patch:
diff --git a/src/Handler/PropertyMapper.php b/src/Handler/PropertyMapper.php
index fc894d6..18bd0a4 100644
--- a/src/Handler/PropertyMapper.php
+++ b/src/Handler/PropertyMapper.php
@@ -7,6 +7,7 @@ namespace JsonMapper\Handler;
 use JsonMapper\Enums\ScalarType;
 use JsonMapper\Enums\Visibility;
 use JsonMapper\JsonMapperInterface;
+use JsonMapper\ValueObjects\Property;
 use JsonMapper\ValueObjects\PropertyMap;
 use JsonMapper\Wrapper\ObjectWrapper;
 
@@ -40,6 +41,15 @@ class PropertyMapper
             $propertyInfo = $propertyMap->getProperty($key);
             $type = $propertyInfo->getType();
 
+            if (! $propertyInfo->isNullable() && is_null($value)) {
+                throw new \RuntimeException("Null provided in json where {$object->getName()}::{$key} doesn't allow null value");
+            }
+
+            if ($propertyInfo->isNullable() && is_null($value)) {
+                $this->setValue($object, $propertyInfo, null);
+                continue;
+            }
+
             if ($propertyInfo->isArray()) {
                 $value = array_map(function ($value) use ($mapper, $type) {
                     return $this->mapPropertyValue($mapper, $type, $value);
@@ -48,16 +58,27 @@ class PropertyMapper
                 $value = $this->mapPropertyValue($mapper, $type, $value);
             }
 
-            if ($propertyInfo->getVisibility()->equals(Visibility::PUBLIC())) {
-                $object->getObject()->$key = $value;
-                continue;
-            }
+            $this->setValue($object, $propertyInfo, $value);
+        }
+    }
 
-            $setterMethod = 'set' . ucfirst($key);
-            if (method_exists($object->getObject(), $setterMethod)) {
-                $object->getObject()->$setterMethod($value);
-            }
+    /**
+     * @param mixed $value
+     */
+    private function setValue(ObjectWrapper $object, Property $propertyInfo, $value): void
+    {
+        if ($propertyInfo->getVisibility()->equals(Visibility::PUBLIC())) {
+            $object->getObject()->{$propertyInfo->getName()} = $value;
+            return;
+        }
+
+        $setterMethod = 'set' . ucfirst($propertyInfo->getName());
+        if (method_exists($object->getObject(), $setterMethod)) {
+            $object->getObject()->$setterMethod($value);
+            return;
         }
+
+        throw new \RuntimeException("{$object->getName()}::{$propertyInfo->getName()} is non-public and no setter method was found");
     }
 
     /**
diff --git a/src/Middleware/Attributes/Attributes.php b/src/Middleware/Attributes/Attributes.php
index 8f3d359..643ed15 100644
--- a/src/Middleware/Attributes/Attributes.php
+++ b/src/Middleware/Attributes/Attributes.php
@@ -36,4 +36,4 @@ class Attributes extends AbstractMiddleware
             }
         }
     }
-}
\ No newline at end of file
+}
diff --git a/src/Middleware/Attributes/MapFrom.php b/src/Middleware/Attributes/MapFrom.php
index e5bca1e..18cdf38 100644
--- a/src/Middleware/Attributes/MapFrom.php
+++ b/src/Middleware/Attributes/MapFrom.php
@@ -16,4 +16,4 @@ class MapFrom
     {
         $this->source = $source;
     }
-}
\ No newline at end of file
+}
diff --git a/src/Middleware/DocBlockAnnotations.php b/src/Middleware/DocBlockAnnotations.php
index 36c8a20..fd70da7 100644
--- a/src/Middleware/DocBlockAnnotations.php
+++ b/src/Middleware/DocBlockAnnotations.php
@@ -15,7 +15,7 @@ use Psr\SimpleCache\CacheInterface;
 
 class DocBlockAnnotations extends AbstractMiddleware
 {
-    private const DOC_BLOCK_REGEX = '/@(?P<name>[A-Za-z_-]+)(?:[ \t]+(?P<value>.*?))?[ \t]*\r?$/m';
+    private const DOC_BLOCK_REGEX = '/@(?P<name>[A-Za-z_-]+)[ \t]+(?P<value>[\w\[\]\\\\|]*).*$/m';
 
     /** @var CacheInterface */
     private $cache;
@@ -86,9 +86,10 @@ class DocBlockAnnotations extends AbstractMiddleware
             $type = substr($type, 0, -2);
         }
 
-        $nullable = stripos('|' . $type . '|', '|null|') !== false;
+        $nullable = stripos($docBlock, '|null') !== false;
+        $cleanedType = str_replace(['null|', '|null'], '', $type);
 
-        return new PropertyType($type, $nullable, $isArray);
+        return new PropertyType($cleanedType, $nullable, $isArray);
     }
 
     public static function parseDocBlockToAnnotationMap(string $docBlock): AnnotationMap


Test patch:
diff --git a/tests/Implementation/ComplexObject.php b/tests/Implementation/ComplexObject.php
index 3619e9b..5807d26 100644
--- a/tests/Implementation/ComplexObject.php
+++ b/tests/Implementation/ComplexObject.php
@@ -8,7 +8,7 @@ use JsonMapper\Tests\Implementation\Models\User;
 
 class ComplexObject
 {
-    /** @var SimpleObject */
+    /** @var SimpleObject|null */
     private $child;
     /** @var SimpleObject[] */
     private $children;
@@ -17,12 +17,12 @@ class ComplexObject
     /** @var mixed  */
     public $mixedParam;
 
-    public function getChild(): SimpleObject
+    public function getChild(): ?SimpleObject
     {
         return $this->child;
     }
 
-    public function setChild(SimpleObject $child): void
+    public function setChild(?SimpleObject $child): void
     {
         $this->child = $child;
     }
diff --git a/tests/Implementation/Php80/AttributePopo.php b/tests/Implementation/Php80/AttributePopo.php
index 87eab0f..d86dc94 100644
--- a/tests/Implementation/Php80/AttributePopo.php
+++ b/tests/Implementation/Php80/AttributePopo.php
@@ -14,4 +14,4 @@ class AttributePopo
     public string $name;
     #[MapFrom("email")]
     public string $email;
-}
\ No newline at end of file
+}
diff --git a/tests/Implementation/PrivatePropertyWithoutSetter.php b/tests/Implementation/PrivatePropertyWithoutSetter.php
new file mode 100644
index 0000000..6eb421e
--- /dev/null
+++ b/tests/Implementation/PrivatePropertyWithoutSetter.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace JsonMapper\Tests\Implementation;
+
+
+class PrivatePropertyWithoutSetter
+{
+    /** @var int */
+    private $number;
+}
\ No newline at end of file
diff --git a/tests/Integration/JsonMapperTest.php b/tests/Integration/JsonMapperTest.php
index aa7e0f1..a6ceb65 100644
--- a/tests/Integration/JsonMapperTest.php
+++ b/tests/Integration/JsonMapperTest.php
@@ -165,6 +165,20 @@ class JsonMapperTest extends TestCase
         self::assertSame(__METHOD__, $object->getChild()->getName());
     }
 
+    public function testItCanMapAnObjectWithANullClassAttribute(): void
+    {
+        // Arrange
+        $mapper = (new JsonMapperFactory())->bestFit();
+        $object = new ComplexObject();
+        $json = (object) ['child' => null];
+
+        // Act
+        $mapper->mapObject($json, $object);
+
+        // Assert
+        self::assertNull($object->getChild());
+    }
+
     public function testItCanMapAnObjectWithACustomClassAttributeFromAnotherNamespace(): void
     {
         // Arrange
diff --git a/tests/Integration/Middleware/Attributes/AttributeTest.php b/tests/Integration/Middleware/Attributes/AttributeTest.php
index 48a341b..29326e5 100644
--- a/tests/Integration/Middleware/Attributes/AttributeTest.php
+++ b/tests/Integration/Middleware/Attributes/AttributeTest.php
@@ -30,4 +30,4 @@ class AttributeTest extends TestCase
         self::assertSame(42, $object->id);
         self::assertSame('John Doe', $object->name);
     }
-}
\ No newline at end of file
+}
diff --git a/tests/Unit/Handler/PropertyMapperTest.php b/tests/Unit/Handler/PropertyMapperTest.php
index 34b5d8c..bc9d79e 100644
--- a/tests/Unit/Handler/PropertyMapperTest.php
+++ b/tests/Unit/Handler/PropertyMapperTest.php
@@ -13,6 +13,7 @@ use JsonMapper\JsonMapperInterface;
 use JsonMapper\Tests\Implementation\ComplexObject;
 use JsonMapper\Tests\Implementation\Models\UserWithConstructor;
 use JsonMapper\Tests\Implementation\Popo;
+use JsonMapper\Tests\Implementation\PrivatePropertyWithoutSetter;
 use JsonMapper\Tests\Implementation\SimpleObject;
 use JsonMapper\Tests\Implementation\UserWithConstructorParent;
 use JsonMapper\ValueObjects\PropertyMap;
@@ -228,4 +229,82 @@ class PropertyMapperTest extends TestCase
 
         self::assertEquals(new UserWithConstructor(1234, 'John Doe'), $object->user);
     }
+
+    /**
+     * @covers \JsonMapper\Handler\PropertyMapper
+     */
+    public function testWillSetNullOnNullablePropertyIfNullProvided(): void
+    {
+        $property = PropertyBuilder::new()
+            ->setName('child')
+            ->setType(SimpleObject::class)
+            ->setIsNullable(true)
+            ->setVisibility(Visibility::PRIVATE())
+            ->setIsArray(false)
+            ->build();
+        $propertyMap = new PropertyMap();
+        $propertyMap->addProperty($property);
+        $jsonMapper = $this->createMock(JsonMapperInterface::class);
+        $json = (object) ['child' => null];
+        $object = new ComplexObject();
+        $object->setChild(new SimpleObject());
+        $wrapped = new ObjectWrapper($object);
+        $propertyMapper = new PropertyMapper();
+
+        $propertyMapper->__invoke($json, $wrapped, $propertyMap, $jsonMapper);
+
+        self::assertNull($object->getChild());
+    }
+
+    /**
+     * @covers \JsonMapper\Handler\PropertyMapper
+     */
+    public function testPublicNotNullableCustomClassThrowsException(): void
+    {
+        $property = PropertyBuilder::new()
+            ->setName('child')
+            ->setType(SimpleObject::class)
+            ->setIsNullable(false)
+            ->setVisibility(Visibility::PRIVATE())
+            ->setIsArray(false)
+            ->build();
+        $propertyMap = new PropertyMap();
+        $propertyMap->addProperty($property);
+        $jsonMapper = $this->createMock(JsonMapperInterface::class);
+        $json = (object) ['child' => null];
+        $object = new ComplexObject();
+        $wrapped = new ObjectWrapper($object);
+        $propertyMapper = new PropertyMapper();
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage("Null provided in json where JsonMapper\Tests\Implementation\ComplexObject::child doesn't allow null value");
+
+        $propertyMapper->__invoke($json, $wrapped, $propertyMap, $jsonMapper);
+    }
+
+    /**
+     * @covers \JsonMapper\Handler\PropertyMapper
+     */
+    public function testNonPublicPropertyWithoutSetterThrowsException(): void
+    {
+        $property = PropertyBuilder::new()
+            ->setName('number')
+            ->setType('int')
+            ->setIsNullable(false)
+            ->setVisibility(Visibility::PRIVATE())
+            ->setIsArray(false)
+            ->build();
+        $propertyMap = new PropertyMap();
+        $propertyMap->addProperty($property);
+        $jsonMapper = $this->createMock(JsonMapperInterface::class);
+        $json = (object) ['number' => 42];
+        $object = new PrivatePropertyWithoutSetter();
+        $wrapped = new ObjectWrapper($object);
+        $propertyMapper = new PropertyMapper();
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage("JsonMapper\Tests\Implementation\PrivatePropertyWithoutSetter::number is non-public and no setter method was found");
+
+        $propertyMapper->__invoke($json, $wrapped, $propertyMap, $jsonMapper);
+    }
 }
diff --git a/tests/Unit/Middleware/DocBlockAnnotationsTest.php b/tests/Unit/Middleware/DocBlockAnnotationsTest.php
index 45e5d01..a7e5348 100644
--- a/tests/Unit/Middleware/DocBlockAnnotationsTest.php
+++ b/tests/Unit/Middleware/DocBlockAnnotationsTest.php
@@ -33,7 +33,7 @@ class DocBlockAnnotationsTest extends TestCase
         self::assertTrue($propertyMap->hasProperty('child'));
         self::assertEquals('SimpleObject', $propertyMap->getProperty('child')->getType());
         self::assertEquals(Visibility::PRIVATE(), $propertyMap->getProperty('child')->getVisibility());
-        self::assertFalse($propertyMap->getProperty('child')->isNullable());
+        self::assertTrue($propertyMap->getProperty('child')->isNullable());
         self::assertFalse($propertyMap->getProperty('child')->isArray());
         self::assertTrue($propertyMap->hasProperty('children'));
         self::assertEquals('SimpleObject', $propertyMap->getProperty('children')->getType());
@@ -126,4 +126,23 @@ class DocBlockAnnotationsTest extends TestCase
 
         $middleware->handle(new \stdClass(), $objectWrapper, $propertyMap, $jsonMapper);
     }
+
+    /**
+     * @covers \JsonMapper\Middleware\DocBlockAnnotations
+     */
+    public function testTypeIsCorrectlyCalculatedForNullableVars(): void
+    {
+        $middleware = new DocBlockAnnotations(new NullCache());
+        $object = new class {
+            /** @var NullableNumber|null This is a nullable number*/
+            public $nullableNumber;
+        };
+        $propertyMap = new PropertyMap();
+        $jsonMapper = $this->createMock(JsonMapperInterface::class);
+
+        $middleware->handle(new \stdClass(), new ObjectWrapper($object), $propertyMap, $jsonMapper);
+
+        self::assertEquals('NullableNumber', $propertyMap->getProperty('nullableNumber')->getType());
+        self::assertTrue($propertyMap->getProperty('nullableNumber')->isNullable());
+    }
 }