diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..9007f6b
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,12 @@
+root = true
+
+[*]
+charset = utf-8
+indent_size = 4
+indent_style = space
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{yml,yaml}]
+indent_size = 2
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..0df131e
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,42 @@
+name: "Tests"
+
+on:
+ push:
+ branches:
+ - 'main'
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ tests:
+ name: "Package Tests"
+ runs-on: ubuntu-20.04
+ continue-on-error: false
+
+ steps:
+ - name: "Checkout"
+ uses: "actions/checkout@v4"
+ with:
+ ref: ${{ github.event.pull_request.head.ref }}
+ fetch-depth: 100
+
+ - name: "Install PHP"
+ uses: "shivammathur/setup-php@v2"
+ with:
+ ini-values: "memory_limit=-1,display_errors=1"
+ php-version: "8.3"
+ coverage: "pcov"
+
+ - name: "Install Dependencies"
+ run: "composer install"
+
+ - name: "Run Tests"
+ run: "vendor/bin/phpunit --coverage-php /tmp/${{ github.sha }}_coverage.cov"
+
+ - uses: "actions/upload-artifact@v4"
+ with:
+ name: "tests_coverage"
+ path: "/tmp/${{ github.sha }}_coverage.cov"
+ retention-days: 1
diff --git a/.gitignore b/.gitignore
index a67d42b..90ccc9f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,13 @@
composer.phar
/vendor/
+.idea
+phpunit.xml
+composer.lock
+.DS_Store
+.php-cs-fixer.cache
+.hg
+.phpunit.result.cache
+.phpunit.cache
# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
diff --git a/LICENSE b/LICENSE
index 569a40e..695aacf 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2024 Andrey Postal
+Copyright (c) Andrey Postal
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..46c1b46
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,24 @@
+{
+ "name": "andreypostal/json-handler-php",
+ "description": "Just a light and simple JSON helper that will make it easy for you to deal with json and objects",
+ "type": "library",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Andrey Postal",
+ "email": "andreypostal@gmail.com"
+ }
+ ],
+ "minimum-stability": "dev",
+ "autoload": {
+ "psr-4": {
+ "Andrey\\JsonHandler\\": "src/"
+ }
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.4@dev"
+ },
+ "scripts": {
+ "test": "phpunit"
+ }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..a5c1ef2
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,26 @@
+
+
+
+
+
+ tests
+ src/
+
+
+
+
+
+
+
diff --git a/src/JsonHandler.php b/src/JsonHandler.php
new file mode 100644
index 0000000..12493c6
--- /dev/null
+++ b/src/JsonHandler.php
@@ -0,0 +1,27 @@
+hydrateObject($json, clone $obj);
+ }
+
+ /**
+ * @throws JsonException
+ */
+ public function hydrateObject(string|array $json, object $obj): object
+ {
+ $jsonArr = is_string($json) ? JsonHandler::Decode($json) : $json;
+ $reflectionClass = new ReflectionClass($obj);
+ $data = $this->processClass($reflectionClass, $jsonArr);
+ if ($reflectionClass->hasMethod('hydrate')) {
+ $obj->hydrate($data);
+ } else {
+ foreach ($data as $key => $value) {
+ $obj->{$key} = $value;
+ }
+ }
+ return $obj;
+ }
+
+ /**
+ * @throws JsonException
+ */
+ private function processClass(ReflectionClass $class, array $jsonArr): array
+ {
+ $output = [];
+ $properties = $class->getProperties();
+ foreach ($properties as $property) {
+ $output[$property->getName()] = $this->processProperty($property, $jsonArr);
+ }
+ return $output;
+ }
+
+ /**
+ * @throws JsonException
+ */
+ private function processProperty(ReflectionProperty $property, array $jsonArr): mixed
+ {
+ $attributes = $property->getAttributes(JsonItemAttribute::class);
+ $attr = $attributes[0] ?? null;
+ if ($attr === null) {
+ return null;
+ }
+
+ /** @var JsonItemAttribute $item */
+ $item = $attr->newInstance();
+ $key = $item->key ?? $property->getName();
+ if ($item->required && !array_key_exists($key, $jsonArr)) {
+ throw new InvalidArgumentException(sprintf('required item <%s> not found', $key));
+ }
+
+ if ($property->getType()?->isBuiltin()) {
+ if ($item->type !== null && $property->getType()?->getName() === 'array') {
+ $output = [];
+ $classExists = class_exists($item->type);
+ foreach ($jsonArr[$key] ?? [] as $k => $v) {
+ $value = $v;
+ if ($classExists) {
+ $value = $this->hydrateObject($v, new $item->type);
+ } elseif (gettype($v) !== $item->type) {
+ throw new LogicException(sprintf('expected array with items of type <%s> but found <%s>', $item->type, gettype($v)));
+ }
+ $output[$k] = $value;
+ }
+ return $output;
+ }
+ return $jsonArr[$key] ?? $property->getDefaultValue();
+ }
+
+ return $this->hydrateObject(
+ $jsonArr[$key],
+ new ($property->getType()?->getName())(),
+ );
+ }
+}
diff --git a/src/JsonItemAttribute.php b/src/JsonItemAttribute.php
new file mode 100644
index 0000000..61825b8
--- /dev/null
+++ b/src/JsonItemAttribute.php
@@ -0,0 +1,10 @@
+getProperties();
+ foreach ($properties as $property) {
+ $attributes = $property->getAttributes(JsonItemAttribute::class);
+ $attr = $attributes[0] ?? null;
+ if ($attr === null) {
+ continue;
+ }
+ /** @var JsonItemAttribute $item */
+ $item = $attr->newInstance();
+ $key = $item->key ?? $property->name;
+
+ if ($property->getType()?->isBuiltin()) {
+ $output[$key] = $property->getValue($obj);
+ continue;
+ }
+ $output[$key] = $this->serialize($property->getValue($obj));
+ }
+ return $output;
+ }
+}
diff --git a/tests/HydratorTest.php b/tests/HydratorTest.php
new file mode 100644
index 0000000..1f3001b
--- /dev/null
+++ b/tests/HydratorTest.php
@@ -0,0 +1,150 @@
+hydrateObject($json, $obj);
+
+ $this->assertEquals('str', $obj->string);
+ $this->assertEquals(1, $obj->int);
+ $this->assertEquals(1.5, $obj->float);
+ $this->assertFalse($obj->bool);
+ }
+
+ /**
+ * @throws JsonException
+ */
+ public function testImmutableHydrate(): void
+ {
+ $json = '{"string": "str", "int": 1, "float": 1.50, "bool": false}';
+
+ $obj = new SimpleTestObject();
+ $handler = new JsonHandler();
+ $modified = $handler->hydrateObjectImmutable($json, $obj);
+
+ // Assert modified values
+ $this->assertEquals('str', $modified->string);
+ $this->assertEquals(1, $modified->int);
+ $this->assertEquals(1.5, $modified->float);
+ $this->assertFalse($modified->bool);
+
+ // Assert original (default) values were not modified
+ $this->assertEquals('string', $obj->string);
+ $this->assertEquals(11, $obj->int);
+ $this->assertEquals(11.5, $obj->float);
+ $this->assertTrue($obj->bool);
+ }
+
+ /**
+ * @throws JsonException
+ */
+ public function testHydrateWithoutOptionalItems(): void
+ {
+ $json = '{"string": "str", "int": 1}';
+ $obj = new SimpleTestObject();
+ $handler = new JsonHandler();
+
+ $handler->hydrateObject($json, $obj);
+ // Modified items
+ $this->assertEquals('str', $obj->string);
+ $this->assertEquals(1, $obj->int);
+ // Default value items
+ $this->assertEquals(11.5, $obj->float);
+ $this->assertTrue($obj->bool);
+ }
+
+ /**
+ * @throws JsonException
+ */
+ public function testHydrateWithoutRequiredItem(): void
+ {
+ $json = '{"int": 1}';
+ $obj = new SimpleTestObject();
+ $handler = new JsonHandler();
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('required item not found');
+ $handler->hydrateObject($json, $obj);
+ }
+
+ /**
+ * @throws JsonException
+ */
+ public function testHydrateWithMultipleLevels(): void
+ {
+ $json = '{"id": "myId", "child": { "string": "newString" }}';
+ $obj = new WithChildObject();
+
+ $handler = new JsonHandler();
+ $handler->hydrateObject($json, $obj);
+
+ $this->assertEquals('myId', $obj->id);
+ $this->assertIsObject($obj->child);
+ $this->assertEquals('newString', $obj->child->string);
+ }
+
+ /**
+ * @throws JsonException
+ */
+ public function testHydrateWithArray(): void
+ {
+ $json = '{"string": "myStr", "arr": [ 5, 6 ]}';
+ $obj = new SimpleTestWithArrayObject();
+
+ $handler = new JsonHandler();
+ $handler->hydrateObject($json, $obj);
+
+ $this->assertEquals('myStr', $obj->string);
+ $this->assertCount(2, $obj->arr);
+ $this->assertEquals(5, $obj->arr[0]);
+ $this->assertEquals(6, $obj->arr[1]);
+ }
+
+ /**
+ * @throws JsonException
+ */
+ public function testHydrateWithInvalidArray(): void
+ {
+ $json = '{"string": "myStr", "arr": [ "5", 6 ]}';
+ $obj = new SimpleTestWithArrayObject();
+
+ $handler = new JsonHandler();
+
+ $this->expectException(LogicException::class);
+ $this->expectExceptionMessage('expected array with items of type but found ');
+ $handler->hydrateObject($json, $obj);
+ }
+
+ /**
+ * @throws JsonException
+ */
+ public function testHydrateWithArrayOfObjects(): void
+ {
+ $json = '{"id": "myId", "children": [ { "string": "abc" } ]}';
+ $obj = new WithArrayOfChildObject();
+
+ $handler = new JsonHandler();
+ $handler->hydrateObject($json, $obj);
+
+ $this->assertEquals('myId', $obj->id);
+ $this->assertCount(1, $obj->children);
+ $this->assertIsObject($obj->children[0]);
+ $this->assertEquals('abc', $obj->children[0]->string);
+ }
+}
diff --git a/tests/SerializerTest.php b/tests/SerializerTest.php
new file mode 100644
index 0000000..a7c7a5b
--- /dev/null
+++ b/tests/SerializerTest.php
@@ -0,0 +1,129 @@
+serialize($obj);
+
+ $this->assertArrayHasKey('string', $arr);
+ $this->assertArrayHasKey('int', $arr);
+ $this->assertArrayHasKey('float', $arr);
+ $this->assertArrayHasKey('bool', $arr);
+
+ $this->assertIsBool($arr['bool']);
+ $this->assertTrue($arr['bool']);
+
+ $this->assertIsInt($arr['int']);
+ $this->assertEquals(11, $arr['int']);
+
+ $this->assertIsFloat($arr['float']);
+ $this->assertEquals(11.50, $arr['float']);
+
+ $this->assertIsString($arr['string']);
+ $this->assertEquals('string', $arr['string']);
+
+ // No exceptions
+ $json = JsonHandler::Encode($arr);
+ $this->assertStringContainsString('float', $json);
+ $this->assertStringContainsString('int', $json);
+ $this->assertStringContainsString('bool', $json);
+ $this->assertStringContainsString('string', $json);
+ }
+
+ /**
+ * @throws JsonException
+ */
+ public function testSerializeWithKeyModified(): void
+ {
+
+ $obj = new class {
+ #[JsonItemAttribute(key: 'modified_key')]
+ public string $string = 'string';
+ #[JsonItemAttribute]
+ public int $sameKey = 11;
+ };
+
+ $handler = new JsonHandler();
+ $arr = $handler->serialize($obj);
+
+ $this->assertArrayHasKey('modified_key', $arr);
+ $this->assertArrayHasKey('sameKey', $arr);
+
+ $this->assertIsInt($arr['sameKey']);
+ $this->assertEquals(11, $arr['sameKey']);
+
+ $this->assertIsString($arr['modified_key']);
+ $this->assertEquals('string', $arr['modified_key']);
+
+ JsonHandler::Encode($arr);
+ }
+
+ /**
+ * @throws JsonException
+ */
+ public function testSerializeMultiLevel(): void
+ {
+
+ $obj = new WithChildObject();
+
+ $handler = new JsonHandler();
+ $arr = $handler->serialize($obj);
+
+ $this->assertArrayHasKey('id', $arr);
+ $this->assertArrayHasKey('child', $arr);
+
+ $this->assertIsArray($arr['child']);
+ $this->assertArrayHasKey('string', $arr['child']);
+
+ $this->assertIsString($arr['id']);
+ $this->assertEquals('id', $arr['id']);
+
+ JsonHandler::Encode($arr);
+ }
+
+ /**
+ * @throws JsonException
+ */
+ public function testSerializeWithExtraItems(): void
+ {
+
+ $obj = new class {
+ public string $string = 'string';
+ #[JsonItemAttribute(key: 'my_item')]
+ public int $child = 1;
+ };
+
+ $handler = new JsonHandler();
+ $arr = $handler->serialize($obj);
+
+ $this->assertArrayNotHasKey('string', $arr);
+ $this->assertArrayHasKey('my_item', $arr);
+
+ JsonHandler::Encode($arr);
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..6704a92
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,8 @@
+children = [
+ new SimpleTestObject(),
+ new SimpleTestObject(),
+ ];
+ }
+}
diff --git a/tests/utils/WithChildObject.php b/tests/utils/WithChildObject.php
new file mode 100644
index 0000000..7266faf
--- /dev/null
+++ b/tests/utils/WithChildObject.php
@@ -0,0 +1,17 @@
+child = new SimpleTestObject();
+ }
+}