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/ + + + + + + + + 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(); + } +}