Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/Converter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Eventjet\Json;

interface Converter
{
public static function fromJson(mixed $value): mixed;
}
11 changes: 8 additions & 3 deletions src/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY)]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)]
final class Field
{
public function __construct(public readonly string $name)
{
/**
* @param class-string<Converter>|null $converter
*/
public function __construct(
public readonly string|null $name = null,
public readonly string|null $converter = null,
) {
}
}
82 changes: 44 additions & 38 deletions src/Json.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,41 @@ public static function decode(string $json, object|string $value): object
return self::decodeClass($json, $value);
}

/**
* @template T of object
* @param class-string<T> $class
* @param array<array-key, mixed> $data
* @return T
*/
public static function instantiateClass(string $class, array $data): object
{
$classReflection = new ReflectionClass($class);
$constructor = $classReflection->getConstructor();
$arguments = [];
if ($constructor !== null) {
$parameters = $constructor->getParameters();
foreach ($parameters as $parameter) {
$name = $parameter->getName();
if (!array_key_exists($name, $data)) {
if ($parameter->isOptional()) {
/** @psalm-suppress MixedAssignment */
$arguments[] = $parameter->getDefaultValue();
continue;
}
throw JsonError::decodeFailed(sprintf('Missing required constructor argument "%s"', $name));
}
/** @psalm-suppress MixedAssignment */
$arguments[] = self::createConstructorArgument($parameter, $data[$name]);
unset($data[$name]);
}
}
$instance = $classReflection->newInstanceArgs($arguments);
if ($data !== []) {
self::populateObject($instance, $data);
}
return $instance;
}

/**
* @psalm-suppress MixedInferredReturnType
* @return string|int|float|bool|array<array-key, mixed>|null
Expand Down Expand Up @@ -258,41 +293,15 @@ private static function getPropertyObject(object $value, string $key): object
return $object;
}

/**
* @param class-string $class
* @param array<array-key, mixed> $data
*/
private static function instantiateClass(string $class, array $data): object
private static function createConstructorArgument(ReflectionParameter $parameter, mixed $jsonValue): mixed
{
$classReflection = new ReflectionClass($class);
$constructor = $classReflection->getConstructor();
$arguments = [];
if ($constructor !== null) {
$parameters = $constructor->getParameters();
foreach ($parameters as $parameter) {
$name = $parameter->getName();
if (!array_key_exists($name, $data)) {
if ($parameter->isOptional()) {
/** @psalm-suppress MixedAssignment */
$arguments[] = $parameter->getDefaultValue();
continue;
}
throw JsonError::decodeFailed(sprintf('Missing required constructor argument "%s"', $name));
}
/** @psalm-suppress MixedAssignment */
$arguments[] = self::createConstructorArgument($parameter, $data[$name]);
unset($data[$name]);
$fieldAttributes = $parameter->getAttributes(Field::class);
if ($fieldAttributes !== []) {
$handler = $fieldAttributes[0]->newInstance()->converter;
if ($handler !== null) {
return $handler::fromJson($jsonValue);
}
}
$instance = $classReflection->newInstanceArgs($arguments);
if ($data !== []) {
self::populateObject($instance, $data);
}
return $instance;
}

private static function createConstructorArgument(ReflectionParameter $parameter, mixed $jsonValue): mixed
{
$type = $parameter->getType();
if ($type === null) {
return $jsonValue;
Expand All @@ -301,7 +310,9 @@ private static function createConstructorArgument(ReflectionParameter $parameter
return self::createConstructorArgumentForNamedType($parameter, $jsonValue);
}
if ($type instanceof ReflectionUnionType) {
return self::createConstructorArgumentForUnionType();
throw JsonError::decodeFailed(
sprintf('Property "%s" has a union type, but no converter is set', $parameter->getName()),
);
}
return self::createConstructorArgumentForIntersectionType();
}
Expand Down Expand Up @@ -357,11 +368,6 @@ private static function createConstructorArgumentForNamedType(ReflectionParamete
return self::instantiateClass($typeName, $value);
}

private static function createConstructorArgumentForUnionType(): mixed
{
throw JsonError::decodeFailed('Union types are not supported');
}

private static function createConstructorArgumentForIntersectionType(): mixed
{
throw JsonError::decodeFailed('Intersection types are not supported');
Expand Down
8 changes: 4 additions & 4 deletions tests/unit/Fixtures/HasUnionType.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

namespace Eventjet\Test\Unit\Json\Fixtures;

use DateInterval;
use DateTimeImmutable;
use Eventjet\Json\Field;

final class HasUnionType
{
public function __construct(public readonly DateTimeImmutable|DateInterval $value)
{
public function __construct(
#[Field(converter: PersonVehicleUnion::class)] public readonly Person|Vehicle $value,
) {
}
}
29 changes: 29 additions & 0 deletions tests/unit/Fixtures/PersonVehicleUnion.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Eventjet\Test\Unit\Json\Fixtures;

use Eventjet\Json\Converter;
use Eventjet\Json\Json;
use InvalidArgumentException;

use function array_key_exists;
use function is_array;

final class PersonVehicleUnion implements Converter
{
public static function fromJson(mixed $value): Person|Vehicle
{
if (!is_array($value)) {
throw new InvalidArgumentException('Expected an array');
}
if (array_key_exists('full_name', $value)) {
return Json::instantiateClass(Person::class, $value);
}
if (array_key_exists('brand', $value) && array_key_exists('model', $value)) {
return Json::instantiateClass(Vehicle::class, $value);
}
throw new InvalidArgumentException('Invalid value');
}
}
15 changes: 15 additions & 0 deletions tests/unit/Fixtures/UnionWithNoConverter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Eventjet\Test\Unit\Json\Fixtures;

use Eventjet\Json\Field;

final class UnionWithNoConverter
{
public function __construct(
#[Field(name: 'value')] public readonly Person|Vehicle $value,
) {
}
}
13 changes: 13 additions & 0 deletions tests/unit/Fixtures/UnionWithNoFieldAttribute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Eventjet\Test\Unit\Json\Fixtures;

final class UnionWithNoFieldAttribute
{
public function __construct(
public readonly Person|Vehicle $value,
) {
}
}
12 changes: 12 additions & 0 deletions tests/unit/Fixtures/Vehicle.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Eventjet\Test\Unit\Json\Fixtures;

final class Vehicle
{
public function __construct(public readonly string $brand, public readonly string $model)
{
}
}
25 changes: 20 additions & 5 deletions tests/unit/JsonTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
use Eventjet\Test\Unit\Json\Fixtures\UndocumentedListItemType;
use Eventjet\Test\Unit\Json\Fixtures\UndocumentedListItemTypeNoDocblock;
use Eventjet\Test\Unit\Json\Fixtures\UndocumentedMap;
use Eventjet\Test\Unit\Json\Fixtures\UnionWithNoConverter;
use Eventjet\Test\Unit\Json\Fixtures\UnionWithNoFieldAttribute;
use Eventjet\Test\Unit\Json\Fixtures\Worldline\AccountOnFile;
use Eventjet\Test\Unit\Json\Fixtures\Worldline\AccountOnFileAttribute;
use Eventjet\Test\Unit\Json\Fixtures\Worldline\AccountOnFileAttributeMustWriteReason;
Expand Down Expand Up @@ -301,6 +303,15 @@ static function (object $object): void {
self::assertNull($object->map);
},
];
yield 'Constructor param has union type' => [
'{"value":{"full_name":"John"}}',
HasUnionType::class,
static function (object $object): void {
self::assertInstanceOf(HasUnionType::class, $object);
self::assertInstanceOf(Person::class, $object->value);
self::assertSame('John', $object->value->fullName);
},
];
}

/**
Expand Down Expand Up @@ -430,11 +441,15 @@ public function __construct(public DoesNotExist|null $nested = null)
HasIntersectionType::class,
'Intersection types are not supported',
];
// We might be able to support union types later
yield 'Constructor param has union type' => [
'{"value":{"datetime":"2023-01-23T12:34:56+00:00"}}',
HasUnionType::class,
'Union types are not supported',
yield 'Object union without a Field attribute' => [
'{"value":{"full_name":"John Doe"}}',
UnionWithNoFieldAttribute::class,
'Property "value" has a union type, but no converter is set',
];
yield 'Object union without a converter' => [
'{"value":{"full_name":"John Doe"}}',
UnionWithNoConverter::class,
'Property "value" has a union type, but no converter is set',
];
yield 'Non-array value for object constructor type' => [
'{"displayHints":"not-an-object"}',
Expand Down