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
51 changes: 51 additions & 0 deletions src/Extension/Upcast/CallbackUpcaster.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Extension\Upcast;

use Closure;
use Patchlevel\Hydrator\Metadata\ClassMetadata;

/** @experimental */
final class CallbackUpcaster implements Upcaster
{
/** @var Closure(array<string, mixed>, array<string, mixed>): array<string, mixed> */
private readonly Closure $callback;

/**
* @param class-string $className
* @param callable(array<string, mixed>, array<string, mixed>): array<string, mixed> $callback
*/
public function __construct(private readonly string $className, callable $callback)
{
$this->callback = Closure::fromCallable($callback);
}

/**
* @param class-string $className
* @param callable(array<string, mixed>, array<string, mixed>): array<string, mixed> $callback
*/
public static function forClass(string $className, callable $callback): self
{
return new self($className, $callback);
}

/**
* @param ClassMetadata<T> $metadata
* @param array<string, mixed> $data
* @param array<string, mixed> $context
*
* @return array<string, mixed>
*
* @template T of object
*/
public function upcast(ClassMetadata $metadata, array $data, array $context): array
{
if ($metadata->className !== $this->className) {
return $data;
}

return ($this->callback)($data, $context);
}
}
44 changes: 44 additions & 0 deletions src/Extension/Upcast/UpcastExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Extension\Upcast;

use Patchlevel\Hydrator\Extension;
use Patchlevel\Hydrator\StackHydratorBuilder;

/** @experimental */
final readonly class UpcastExtension implements Extension
{
private const BEFORE_CRYPTOGRAPHY_PRIORITY = 66;
private const AFTER_CRYPTOGRAPHY_PRIORITY = 63;

/**
* @param list<Upcaster> $beforeCryptography
* @param list<Upcaster> $afterCryptography
*/
public function __construct(
private array $beforeCryptography = [],
private array $afterCryptography = [],
) {
}

public function configure(StackHydratorBuilder $builder): void
{
if ($this->beforeCryptography !== []) {
$builder->addMiddleware(
new UpcastMiddleware($this->beforeCryptography),
self::BEFORE_CRYPTOGRAPHY_PRIORITY,
);
}

if ($this->afterCryptography === []) {
return;
}

$builder->addMiddleware(
new UpcastMiddleware($this->afterCryptography),
self::AFTER_CRYPTOGRAPHY_PRIORITY,
);
}
}
51 changes: 51 additions & 0 deletions src/Extension/Upcast/UpcastMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Extension\Upcast;

use Patchlevel\Hydrator\Metadata\ClassMetadata;
use Patchlevel\Hydrator\Middleware\Middleware;
use Patchlevel\Hydrator\Middleware\Stack;

/** @experimental */
final readonly class UpcastMiddleware implements Middleware
{
/** @param list<Upcaster> $upcasters */
public function __construct(
private array $upcasters,
) {
}

/**
* @param ClassMetadata<T> $metadata
* @param array<string, mixed> $data
* @param array<string, mixed> $context
*
* @return T
*
* @template T of object
*/
public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object
{
foreach ($this->upcasters as $upcaster) {
$data = $upcaster->upcast($metadata, $data, $context);
}

return $stack->next()->hydrate($metadata, $data, $context, $stack);
}

/**
* @param ClassMetadata<T> $metadata
* @param T $object
* @param array<string, mixed> $context
*
* @return array<string, mixed>
*
* @template T of object
*/
public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array
{
return $stack->next()->extract($metadata, $object, $context, $stack);
}
}
22 changes: 22 additions & 0 deletions src/Extension/Upcast/Upcaster.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Extension\Upcast;

use Patchlevel\Hydrator\Metadata\ClassMetadata;

/** @experimental */
interface Upcaster
{
/**
* @param ClassMetadata<T> $metadata
* @param array<string, mixed> $data
* @param array<string, mixed> $context
*
* @return array<string, mixed>
*
* @template T of object
*/
public function upcast(ClassMetadata $metadata, array $data, array $context): array;
}
53 changes: 53 additions & 0 deletions tests/Unit/Extension/Upcast/CallbackUpcasterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Tests\Unit\Extension\Upcast;

use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster;
use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory;
use Patchlevel\Hydrator\Tests\Unit\Extension\Lifecycle\Fixture\LifecycleFixture;
use Patchlevel\Hydrator\Tests\Unit\Extension\Upcast\Fixture\UpcastFixture;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

use function assert;
use function is_string;

#[CoversClass(CallbackUpcaster::class)]
final class CallbackUpcasterTest extends TestCase
{
public function testUpcast(): void
{
$metadata = (new AttributeMetadataFactory())->metadata(UpcastFixture::class);
$upcaster = CallbackUpcaster::forClass(
UpcastFixture::class,
static function (array $data, array $context): array {
$prefix = $context['prefix'] ?? '';
$name = $data['name'] ?? '';
assert(is_string($prefix));
assert(is_string($name));

$data['name'] = $prefix . $name;

return $data;
},
);

self::assertSame(
['name' => 'Upcast: foo'],
$upcaster->upcast($metadata, ['name' => 'foo'], ['prefix' => 'Upcast: ']),
);
}

public function testSkipDifferentClass(): void
{
$metadata = (new AttributeMetadataFactory())->metadata(LifecycleFixture::class);
$upcaster = CallbackUpcaster::forClass(
UpcastFixture::class,
static fn (array $data): array => ['name' => 'changed'],
);

self::assertSame(['name' => 'foo'], $upcaster->upcast($metadata, ['name' => 'foo'], []));
}
}
13 changes: 13 additions & 0 deletions tests/Unit/Extension/Upcast/Fixture/UpcastFixture.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Tests\Unit\Extension\Upcast\Fixture;

final class UpcastFixture
{
public function __construct(
public string $name,
) {
}
}
110 changes: 110 additions & 0 deletions tests/Unit/Extension/Upcast/UpcastExtensionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Tests\Unit\Extension\Upcast;

use Patchlevel\Hydrator\CoreExtension;
use Patchlevel\Hydrator\Cryptography\PayloadCryptographer;
use Patchlevel\Hydrator\Extension\Cryptography\Cryptographer;
use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension;
use Patchlevel\Hydrator\Extension\Cryptography\CryptographyMiddleware;
use Patchlevel\Hydrator\Extension\Cryptography\LegacyCryptographyDecryptMiddleware;
use Patchlevel\Hydrator\Extension\Upcast\CallbackUpcaster;
use Patchlevel\Hydrator\Extension\Upcast\UpcastExtension;
use Patchlevel\Hydrator\Extension\Upcast\UpcastMiddleware;
use Patchlevel\Hydrator\StackHydratorBuilder;
use Patchlevel\Hydrator\Tests\Unit\Extension\Upcast\Fixture\UpcastFixture;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

use function assert;
use function is_string;

#[CoversClass(UpcastExtension::class)]
final class UpcastExtensionTest extends TestCase
{
public function testIntegration(): void
{
$hydrator = (new StackHydratorBuilder())
->useExtension(new CoreExtension())
->useExtension(new UpcastExtension(
afterCryptography: [
CallbackUpcaster::forClass(
UpcastFixture::class,
static function (array $data): array {
$firstName = $data['firstName'] ?? '';
$lastName = $data['lastName'] ?? '';
assert(is_string($firstName));
assert(is_string($lastName));

$data['name'] = $firstName . ' ' . $lastName;

return $data;
},
),
],
))
->build();

$object = $hydrator->hydrate(UpcastFixture::class, ['firstName' => 'Jane', 'lastName' => 'Doe']);

self::assertSame('Jane Doe', $object->name);
}

public function testConfigureAroundCryptography(): void
{
$beforeCryptographyUpcaster = CallbackUpcaster::forClass(
UpcastFixture::class,
static fn (array $data): array => $data,
);
$afterCryptographyUpcaster = CallbackUpcaster::forClass(
UpcastFixture::class,
static fn (array $data): array => $data,
);

$builder = new StackHydratorBuilder();
$builder->useExtension(new UpcastExtension(
beforeCryptography: [$beforeCryptographyUpcaster],
afterCryptography: [$afterCryptographyUpcaster],
));
$builder->useExtension(new CryptographyExtension($this->createMock(Cryptographer::class)));

$middlewares = $builder->middlewares();

self::assertCount(3, $middlewares);
self::assertInstanceOf(UpcastMiddleware::class, $middlewares[0]);
self::assertInstanceOf(CryptographyMiddleware::class, $middlewares[1]);
self::assertInstanceOf(UpcastMiddleware::class, $middlewares[2]);
}

public function testConfigureAroundLegacyCryptography(): void
{
$beforeCryptographyUpcaster = CallbackUpcaster::forClass(
UpcastFixture::class,
static fn (array $data): array => $data,
);
$afterCryptographyUpcaster = CallbackUpcaster::forClass(
UpcastFixture::class,
static fn (array $data): array => $data,
);

$builder = new StackHydratorBuilder();
$builder->useExtension(new UpcastExtension(
beforeCryptography: [$beforeCryptographyUpcaster],
afterCryptography: [$afterCryptographyUpcaster],
));
$builder->useExtension(new CryptographyExtension(
$this->createMock(Cryptographer::class),
$this->createMock(PayloadCryptographer::class),
));

$middlewares = $builder->middlewares();

self::assertCount(4, $middlewares);
self::assertInstanceOf(UpcastMiddleware::class, $middlewares[0]);
self::assertInstanceOf(LegacyCryptographyDecryptMiddleware::class, $middlewares[1]);
self::assertInstanceOf(CryptographyMiddleware::class, $middlewares[2]);
self::assertInstanceOf(UpcastMiddleware::class, $middlewares[3]);
}
}
Loading
Loading