Repository: JsonMapper/JsonMapper (php)

Issue:
Add (multiple) middleware that apply case conversion on the JSON object
With different several formats between different programming languages it would be helpful to offer case conversion as middleware that can be pushed to the stack.

As there is no recommended case format (not even in PSR-1, see https://www.php-fig.org/psr/psr-1/#42-properties) targets should be: `$StudlyCaps`, `$camelCase`, or `$under_score`. Source could be any of the targets but also `$kebab-case`. Also consider more subtle differences such as a leading uppercase.

Ideally the source and target are only settings of a single middleware called `CaseConversionMiddleware`

Reference: 
https://github.com/cweiske/jsonmapper/issues/149
https://github.com/cweiske/jsonmapper/issues/152

Pull request description:
Add case conversion middleware; Resolves #4

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3652424..d780ddc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ## [Unreleased]
 ### Added 
 - New FinalCallback middleware to invoke a final callback when mapping is completed.
+- New CaseConversion middleware to handle difference between text notation in JSON and object
 
 ## [0.2.1] - 2020-03-25
 ### Fixed
diff --git a/src/Enums/TextNotation.php b/src/Enums/TextNotation.php
new file mode 100644
index 0000000..e12db5d
--- /dev/null
+++ b/src/Enums/TextNotation.php
@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+namespace JsonMapper\Enums;
+
+use MyCLabs\Enum\Enum;
+
+/**
+ * @method static TextNotation STUDLY_CAPS()
+ * @method static TextNotation CAMEL_CASE()
+ * @method static TextNotation UNDERSCORE()
+ * @method static TextNotation KEBAB_CASE()
+ */
+class TextNotation extends Enum
+{
+    private const STUDLY_CAPS = 'studly_caps';
+    private const CAMEL_CASE = 'camel_case';
+    private const UNDERSCORE = 'underscore';
+    private const KEBAB_CASE = 'kebab-case';
+}
diff --git a/src/Middleware/CaseConversion.php b/src/Middleware/CaseConversion.php
new file mode 100644
index 0000000..4e42e23
--- /dev/null
+++ b/src/Middleware/CaseConversion.php
@@ -0,0 +1,115 @@
+<?php
+
+declare(strict_types=1);
+
+namespace JsonMapper\Middleware;
+
+use JsonMapper\Enums\TextNotation;
+use JsonMapper\JsonMapperInterface;
+use JsonMapper\ValueObjects\PropertyMap;
+use JsonMapper\Wrapper\ObjectWrapper;
+
+class CaseConversion extends AbstractMiddleware
+{
+    /** @var TextNotation */
+    private $searchSeparator;
+    /** @var TextNotation */
+    private $replacementSeparator;
+
+    public function __construct(TextNotation $searchSeparator, TextNotation $replacementSeparator)
+    {
+        $this->searchSeparator = $searchSeparator;
+        $this->replacementSeparator = $replacementSeparator;
+    }
+
+    public function handle(\stdClass $json, ObjectWrapper $object, PropertyMap $map, JsonMapperInterface $mapper): void
+    {
+        if ($this->searchSeparator->equals($this->replacementSeparator)) {
+            return;
+        }
+
+        $keys = array_keys((array) $json);
+        foreach ($keys as $key) {
+            $replacementKey = $this->getReplacementKey($key);
+
+            $json->$replacementKey = $json->$key;
+            unset($json->$key);
+        }
+    }
+
+    private function getReplacementKey(string $key): string
+    {
+        switch ($this->searchSeparator) {
+            case TextNotation::CAMEL_CASE():
+                return $this->replaceFromCamelCase($key);
+            case TextNotation::STUDLY_CAPS():
+                return $this->replaceFromStudlyCaps($key);
+            case TextNotation::UNDERSCORE():
+                return $this->replaceFromUnderscore($key);
+            case TextNotation::KEBAB_CASE():
+                return $this->replaceFromKebabCase($key);
+            default:
+                return $key;
+        }
+    }
+
+    private function replaceFromCamelCase(string $key): string
+    {
+        switch ($this->replacementSeparator) {
+            case TextNotation::STUDLY_CAPS():
+                return ucfirst($key);
+            case TextNotation::UNDERSCORE():
+                return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
+            case TextNotation::KEBAB_CASE():
+                return strtolower(preg_replace('/(?<!^)[A-Z]/', '-$0', $key));
+            case TextNotation::CAMEL_CASE():
+            default:
+                return $key;
+        }
+    }
+
+    private function replaceFromStudlyCaps(string $key): string
+    {
+        switch ($this->replacementSeparator) {
+            case TextNotation::CAMEL_CASE():
+                return lcfirst($key);
+            case TextNotation::UNDERSCORE():
+                return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
+            case TextNotation::KEBAB_CASE():
+                return strtolower(preg_replace('/(?<!^)[A-Z]/', '-$0', $key));
+            case TextNotation::STUDLY_CAPS():
+            default:
+                return $key;
+        }
+    }
+
+    private function replaceFromUnderscore(string $key): string
+    {
+        switch ($this->replacementSeparator) {
+            case TextNotation::STUDLY_CAPS():
+                return ucfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $key))));
+            case TextNotation::CAMEL_CASE():
+                return lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $key))));
+            case TextNotation::KEBAB_CASE():
+                return str_replace('_', '-', $key);
+            case TextNotation::UNDERSCORE():
+            default:
+                return $key;
+        }
+    }
+
+    private function replaceFromKebabCase(string $key): string
+    {
+        switch ($this->replacementSeparator) {
+            case TextNotation::STUDLY_CAPS():
+                return ucfirst(str_replace(' ', '', ucwords(str_replace('-', ' ', $key))));
+            case TextNotation::CAMEL_CASE():
+                return lcfirst(str_replace(' ', '', ucwords(str_replace('-', ' ', $key))));
+            case TextNotation::UNDERSCORE():
+                return str_replace('-', '_', $key);
+            case TextNotation::KEBAB_CASE():
+            default:
+                return $key;
+        }
+    }
+}


Test patch:
diff --git a/tests/Integration/Middleware/CaseConversionTest.php b/tests/Integration/Middleware/CaseConversionTest.php
new file mode 100644
index 0000000..9406429
--- /dev/null
+++ b/tests/Integration/Middleware/CaseConversionTest.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace JsonMapper\Tests\Integration\Middleware;
+
+use JsonMapper\Enums\TextNotation;
+use JsonMapper\JsonMapperFactory;
+use JsonMapper\Middleware\CaseConversion;
+use JsonMapper\Tests\Implementation\ComplexObject;
+use PHPUnit\Framework\TestCase;
+
+class CaseConversionTest extends TestCase
+{
+    public function testCaseConversionMiddlewareDoesCaseConversion(): void
+    {
+        $mapper = (new JsonMapperFactory())->default();
+        $mapper->push(new CaseConversion(TextNotation::STUDLY_CAPS(), TextNotation::CAMEL_CASE()));
+        $object = new ComplexObject();
+        $json = (object) ['User' => (object) ['Name' => __METHOD__]];
+
+        $mapper->mapObject($json, $object);
+
+        self::assertEquals(__METHOD__, $object->getUser()->getName());
+    }
+}
diff --git a/tests/Unit/Middleware/CaseConversionTest.php b/tests/Unit/Middleware/CaseConversionTest.php
new file mode 100644
index 0000000..bf36e93
--- /dev/null
+++ b/tests/Unit/Middleware/CaseConversionTest.php
@@ -0,0 +1,80 @@
+<?php
+
+declare(strict_types=1);
+
+namespace JsonMapper\Tests\Unit\Middleware;
+
+use JsonMapper\Enums\TextNotation;
+use JsonMapper\JsonMapperInterface;
+use JsonMapper\Middleware\CaseConversion;
+use JsonMapper\ValueObjects\PropertyMap;
+use JsonMapper\Wrapper\ObjectWrapper;
+use PHPUnit\Framework\TestCase;
+
+class CaseConversionTest extends TestCase
+{
+    /**
+     * @covers \JsonMapper\Middleware\CaseConversion
+     * @dataProvider conversionDataProvider
+     */
+    public function testCanConvertObject(
+        TextNotation $search,
+        TextNotation $replacement,
+        string $original,
+        string $replacementKey
+    ): void {
+        $middleware = new CaseConversion($search, $replacement);
+        $json = (object) [$original => 'placeholder'];
+        $object = new ObjectWrapper(new \stdClass());
+
+        $middleware->handle($json, $object, new PropertyMap(), $this->createMock(JsonMapperInterface::class));
+
+        self::assertObjectHasAttribute($replacementKey, $json);
+        self::assertEquals('placeholder', $json->$replacementKey);
+        self::assertObjectNotHasAttribute($original, $json);
+    }
+
+    /**
+     * @covers \JsonMapper\Middleware\CaseConversion
+     * @dataProvider possibleTextNotationDataProvider
+     */
+    public function testWillRemainUntouchedOnSameTextNotation(TextNotation $search): void
+    {
+        $middleware = new CaseConversion($search, $search);
+        $key = 'StudlyCase-CamelCase_underscore';
+        $json = (object) [$key => 'placeholder'];
+        $object = new ObjectWrapper(new \stdClass());
+
+        $middleware->handle($json, $object, new PropertyMap(), $this->createMock(JsonMapperInterface::class));
+
+        self::assertObjectHasAttribute($key, $json);
+    }
+
+    public function conversionDataProvider(): array
+    {
+        return [
+            'Studly caps to camel case' => [TextNotation::STUDLY_CAPS(), TextNotation::CAMEL_CASE(), 'DeliveryAddress', 'deliveryAddress'],
+            'Studly caps to underscore' => [TextNotation::STUDLY_CAPS(), TextNotation::UNDERSCORE(), 'DeliveryAddress', 'delivery_address'],
+            'Studly caps to kebab case' => [TextNotation::STUDLY_CAPS(), TextNotation::KEBAB_CASE(), 'DeliveryAddress', 'delivery-address'],
+            'Camel case to studly caps' => [TextNotation::CAMEL_CASE(), TextNotation::STUDLY_CAPS(), 'deliveryAddress', 'DeliveryAddress'],
+            'Camel case to underscore' => [TextNotation::CAMEL_CASE(), TextNotation::UNDERSCORE(), 'deliveryAddress', 'delivery_address'],
+            'Camel case to kebab case' => [TextNotation::CAMEL_CASE(), TextNotation::KEBAB_CASE(), 'deliveryAddress', 'delivery-address'],
+            'Underscore to studly caps' => [TextNotation::UNDERSCORE(), TextNotation::STUDLY_CAPS(), 'delivery_address', 'DeliveryAddress'],
+            'Underscore to camel case' => [TextNotation::UNDERSCORE(), TextNotation::CAMEL_CASE(), 'delivery_address', 'deliveryAddress'],
+            'Underscore to kebab case' => [TextNotation::UNDERSCORE(), TextNotation::KEBAB_CASE(), 'delivery_address', 'delivery-address'],
+            'Kebab case to studly caps' => [TextNotation::KEBAB_CASE(), TextNotation::STUDLY_CAPS(), 'delivery-address', 'DeliveryAddress'],
+            'Kebab case to camel case' => [TextNotation::KEBAB_CASE(), TextNotation::CAMEL_CASE(), 'delivery-address', 'deliveryAddress'],
+            'Kebab case to underscore' => [TextNotation::KEBAB_CASE(), TextNotation::UNDERSCORE(), 'delivery-address', 'delivery_address'],
+        ];
+    }
+
+    public function possibleTextNotationDataProvider(): array
+    {
+        return [
+            'Studly caps' => [TextNotation::STUDLY_CAPS()],
+            'Camel case' => [TextNotation::CAMEL_CASE()],
+            'Underscore' => [TextNotation::UNDERSCORE()],
+            'Kebab case' => [TextNotation::KEBAB_CASE()],
+        ];
+    }
+}