diff --git a/src/Cryptography/CryptographySubscriber.php b/src/Cryptography/CryptographySubscriber.php index 23e5f38..2f5a793 100644 --- a/src/Cryptography/CryptographySubscriber.php +++ b/src/Cryptography/CryptographySubscriber.php @@ -5,8 +5,13 @@ namespace Patchlevel\Hydrator\Cryptography; use Patchlevel\Hydrator\Event\PostExtract; +use Patchlevel\Hydrator\Event\PreExtract; use Patchlevel\Hydrator\Event\PreHydrate; +use Patchlevel\Hydrator\Metadata\ClassMetadata; +use Stringable; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use function is_int; +use function is_string; final class CryptographySubscriber implements EventSubscriberInterface { @@ -17,12 +22,30 @@ public function __construct( public function preHydrate(PreHydrate $event): void { - $event->data = $this->cryptography->decrypt($event->metadata, $event->data); + $parentSubjectId = $event->context['subjectId'] ?? null; + + $currentSubjectId = $this->subjectIdFromData($event->data, $event->metadata) ?: $parentSubjectId; + $event->data = $this->cryptography->decrypt($event->metadata, $event->data, $currentSubjectId); + + $event->context['subjectId'] = $currentSubjectId; + } + + public function preExtract(PreExtract $event): void + { + $parentSubjectId = $event->context['subjectId'] ?? null; + + $currentSubjectId = $this->subjectIdFromObject($event->object, $event->metadata) ?: $parentSubjectId; + + $event->context['subjectId'] = $currentSubjectId; } public function postExtract(PostExtract $event): void { - $event->data = $this->cryptography->encrypt($event->metadata, $event->data); + $event->data = $this->cryptography->encrypt( + $event->metadata, + $event->data, + $event->context['subjectId'] ?? null + ); } /** @return array> */ @@ -30,7 +53,75 @@ public static function getSubscribedEvents(): array { return [ PreHydrate::class => 'preHydrate', + PreExtract::class => 'preExtract', PostExtract::class => 'postExtract', ]; } + + private function subjectIdFromObject(object $object, ClassMetadata $metadata): string|null + { + $subjectIdField = $metadata->dataSubjectIdField(); + + if ($subjectIdField === null) { + return null; + } + + $property = $metadata->propertyForField($subjectIdField); + + $value = $property->getValue($object); + + if ($value === null) { + return null; + } + + $normalizer = $property->normalizer(); + + if ($normalizer !== null) { + $value = $normalizer->normalize($value); + } + + if ($value instanceof Stringable) { + $value = (string)$value; + } + + if (is_int($value)) { + $value = (string)$value; + } + + if (!is_string($value)) { + throw new UnsupportedSubjectId($metadata->className(), $subjectIdField, $value); + } + + return $value; + } + + /** @param array $data */ + private function subjectIdFromData(array $data, ClassMetadata $metadata): string|null + { + $subjectIdField = $metadata->dataSubjectIdField(); + + if ($subjectIdField === null) { + return null; + } + + $value = $data[$subjectIdField] ?? null; + + if ($value === null) { + return null; + } + + if ($value instanceof Stringable) { + $value = (string)$value; + } + + if (is_int($value)) { + $value = (string)$value; + } + + if (!is_string($value)) { + throw new UnsupportedSubjectId($metadata->className(), $subjectIdField, $value); + } + + return $value; + } } diff --git a/src/Cryptography/PersonalDataPayloadCryptographer.php b/src/Cryptography/PersonalDataPayloadCryptographer.php index d9f2151..eff8dd5 100644 --- a/src/Cryptography/PersonalDataPayloadCryptographer.php +++ b/src/Cryptography/PersonalDataPayloadCryptographer.php @@ -34,9 +34,9 @@ public function __construct( * * @return array */ - public function encrypt(ClassMetadata $metadata, array $data): array + public function encrypt(ClassMetadata $metadata, array $data, string|null $overrideSubjectId = null): array { - $subjectId = $this->subjectId($metadata, $data); + $subjectId = $overrideSubjectId ?: $this->subjectId($metadata, $data); if ($subjectId === null) { return $data; @@ -78,9 +78,9 @@ public function encrypt(ClassMetadata $metadata, array $data): array * * @return array */ - public function decrypt(ClassMetadata $metadata, array $data): array + public function decrypt(ClassMetadata $metadata, array $data, string|null $overrideSubjectId = null): array { - $subjectId = $this->subjectId($metadata, $data); + $subjectId = $overrideSubjectId ?: $this->subjectId($metadata, $data); if ($subjectId === null) { return $data; diff --git a/src/Event/PostExtract.php b/src/Event/PostExtract.php index 55a45b9..72b155f 100644 --- a/src/Event/PostExtract.php +++ b/src/Event/PostExtract.php @@ -8,10 +8,14 @@ final class PostExtract { - /** @param array $data */ + /** + * @param array $data + * @param array $context + */ public function __construct( public array $data, public readonly ClassMetadata $metadata, + public array $context = [], ) { } } diff --git a/src/Event/PreExtract.php b/src/Event/PreExtract.php new file mode 100644 index 0000000..50d03e7 --- /dev/null +++ b/src/Event/PreExtract.php @@ -0,0 +1,20 @@ + $context + */ + public function __construct( + public object $object, + public readonly ClassMetadata $metadata, + public array $context = [], + ) { + } +} diff --git a/src/Event/PreHydrate.php b/src/Event/PreHydrate.php index f6f86b6..39289e0 100644 --- a/src/Event/PreHydrate.php +++ b/src/Event/PreHydrate.php @@ -8,10 +8,14 @@ final class PreHydrate { - /** @param array $data */ + /** + * @param array $data + * @param array $context + */ public function __construct( public array $data, public readonly ClassMetadata $metadata, + public array $context = [], ) { } } diff --git a/src/HydratorWithContext.php b/src/HydratorWithContext.php new file mode 100644 index 0000000..d4936b7 --- /dev/null +++ b/src/HydratorWithContext.php @@ -0,0 +1,28 @@ + $class + * @param array $data + * @param array $context + * + * @return T + * + * @throws ClassNotSupported if the class is not supported or not found. + * + * @template T of object + */ + public function hydrate(string $class, array $data, array $context = []): object; + + /** + * @param array $context + * + * @return array + */ + public function extract(object $object, array $context = []): array; +} diff --git a/src/MetadataHydrator.php b/src/MetadataHydrator.php index e1a37a7..2b58f1d 100644 --- a/src/MetadataHydrator.php +++ b/src/MetadataHydrator.php @@ -7,6 +7,7 @@ use Patchlevel\Hydrator\Cryptography\CryptographySubscriber; use Patchlevel\Hydrator\Cryptography\PayloadCryptographer; use Patchlevel\Hydrator\Event\PostExtract; +use Patchlevel\Hydrator\Event\PreExtract; use Patchlevel\Hydrator\Event\PreHydrate; use Patchlevel\Hydrator\Guesser\BuiltInGuesser; use Patchlevel\Hydrator\Guesser\ChainGuesser; @@ -16,21 +17,20 @@ use Patchlevel\Hydrator\Metadata\ClassNotFound; use Patchlevel\Hydrator\Metadata\MetadataFactory; use Patchlevel\Hydrator\Normalizer\HydratorAwareNormalizer; +use Patchlevel\Hydrator\Normalizer\NormalizerWithContext; use ReflectionClass; use ReflectionParameter; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Throwable; use TypeError; - use function array_key_exists; use function array_values; use function is_object; use function spl_object_id; - use const PHP_VERSION_ID; -final class MetadataHydrator implements Hydrator +final class MetadataHydrator implements HydratorWithContext { /** @var array */ private array $stack = []; @@ -55,14 +55,15 @@ public function __construct( } /** - * @param class-string $class + * @param class-string $class * @param array $data + * @param array $context * * @return T * * @template T of object */ - public function hydrate(string $class, array $data): object + public function hydrate(string $class, array $data, array $context = []): object { try { $metadata = $this->metadataFactory->metadata($class); @@ -71,34 +72,38 @@ public function hydrate(string $class, array $data): object } if (PHP_VERSION_ID < 80400) { - return $this->doHydrate($metadata, $data); + return $this->doHydrate($metadata, $data, $context); } $lazy = $metadata->lazy() ?? $this->defaultLazy; if (!$lazy) { - return $this->doHydrate($metadata, $data); + return $this->doHydrate($metadata, $data, $context); } return (new ReflectionClass($class))->newLazyProxy( - function () use ($metadata, $data): object { - return $this->doHydrate($metadata, $data); + function () use ($metadata, $data, $context): object { + return $this->doHydrate($metadata, $data, $context); }, ); } /** - * @param ClassMetadata $metadata + * @param ClassMetadata $metadata * @param array $data + * @param array $context * * @return T * * @template T of object */ - private function doHydrate(ClassMetadata $metadata, array $data): object + private function doHydrate(ClassMetadata $metadata, array $data, array $context = []): object { if ($this->eventDispatcher) { - $data = $this->eventDispatcher->dispatch(new PreHydrate($data, $metadata))->data; + $event = $this->eventDispatcher->dispatch(new PreHydrate($data, $metadata, $context)); + + $data = $event->data; + $context = $event->context; } $object = $metadata->newInstance(); @@ -137,8 +142,13 @@ private function doHydrate(ClassMetadata $metadata, array $data): object } try { - /** @psalm-suppress MixedAssignment */ - $value = $normalizer->denormalize($value); + if ($normalizer instanceof NormalizerWithContext) { + /** @psalm-suppress MixedAssignment */ + $value = $normalizer->denormalize($value, $context); + } else { + /** @psalm-suppress MixedAssignment */ + $value = $normalizer->denormalize($value); + } } catch (Throwable $e) { throw new DenormalizationFailure( $metadata->className(), @@ -167,8 +177,12 @@ private function doHydrate(ClassMetadata $metadata, array $data): object return $object; } - /** @return array */ - public function extract(object $object): array + /** + * @param array $context + * + * @return array + */ + public function extract(object $object, array $context = []): array { $objectId = spl_object_id($object); @@ -188,6 +202,13 @@ public function extract(object $object): array $callback->invoke($object); } + if ($this->eventDispatcher) { + $event = $this->eventDispatcher->dispatch(new PreExtract($object, $metadata, $context)); + + $object = $event->object; + $context = $event->context; + } + $data = []; foreach ($metadata->properties() as $propertyMetadata) { @@ -202,8 +223,13 @@ public function extract(object $object): array } try { - /** @psalm-suppress MixedAssignment */ - $value = $normalizer->normalize($value); + if ($normalizer instanceof NormalizerWithContext) { + /** @psalm-suppress MixedAssignment */ + $value = $normalizer->normalize($value, $context); + } else { + /** @psalm-suppress MixedAssignment */ + $value = $normalizer->normalize($value); + } } catch (CircularReference $e) { throw $e; } catch (Throwable $e) { @@ -225,7 +251,9 @@ public function extract(object $object): array } if ($this->eventDispatcher) { - return $this->eventDispatcher->dispatch(new PostExtract($data, $metadata))->data; + $event = $this->eventDispatcher->dispatch(new PostExtract($data, $metadata, $context)); + + $data = $event->data; } return $data; diff --git a/src/Normalizer/NormalizerWithContext.php b/src/Normalizer/NormalizerWithContext.php new file mode 100644 index 0000000..7a976e7 --- /dev/null +++ b/src/Normalizer/NormalizerWithContext.php @@ -0,0 +1,22 @@ + $context + * + * @throws InvalidArgument + */ + public function normalize(mixed $value, array $context = []): mixed; + + /** + * @param array $context + * + * @throws InvalidArgument + */ + public function denormalize(mixed $value, array $context = []): mixed; +} diff --git a/src/Normalizer/ObjectNormalizer.php b/src/Normalizer/ObjectNormalizer.php index 591dfba..90b32a1 100644 --- a/src/Normalizer/ObjectNormalizer.php +++ b/src/Normalizer/ObjectNormalizer.php @@ -6,6 +6,7 @@ use Attribute; use Patchlevel\Hydrator\Hydrator; +use Patchlevel\Hydrator\HydratorWithContext; use ReflectionType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\NullableType; @@ -14,7 +15,7 @@ use function is_array; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)] -final class ObjectNormalizer implements Normalizer, ReflectionTypeAwareNormalizer, TypeAwareNormalizer, HydratorAwareNormalizer +final class ObjectNormalizer implements NormalizerWithContext, ReflectionTypeAwareNormalizer, TypeAwareNormalizer, HydratorAwareNormalizer { private Hydrator|null $hydrator = null; @@ -24,8 +25,12 @@ public function __construct( ) { } - /** @return array|null */ - public function normalize(mixed $value): array|null + /** + * @param array $context + * + * @return array|null + */ + public function normalize(mixed $value, array $context = []): array|null { if (!$this->hydrator) { throw new MissingHydrator(); @@ -41,10 +46,15 @@ public function normalize(mixed $value): array|null throw InvalidArgument::withWrongType($className . '|null', $value); } + if ($this->hydrator instanceof HydratorWithContext) { + return $this->hydrator->extract($value, $context); + } + return $this->hydrator->extract($value); } - public function denormalize(mixed $value): object|null + /** @param array $context */ + public function denormalize(mixed $value, array $context = []): object|null { if (!$this->hydrator) { throw new MissingHydrator(); @@ -60,6 +70,10 @@ public function denormalize(mixed $value): object|null $className = $this->getClassName(); + if ($this->hydrator instanceof HydratorWithContext) { + return $this->hydrator->hydrate($className, $value, $context); + } + return $this->hydrator->hydrate($className, $value); } diff --git a/tests/ReturnCallback.php b/tests/ReturnCallback.php new file mode 100644 index 0000000..77c8aae --- /dev/null +++ b/tests/ReturnCallback.php @@ -0,0 +1,25 @@ +, mixed}> $series */ + public function __construct( + private array $series, + ) { + } + + public function __invoke(mixed ...$args): mixed + { + [$expectedArgs, $return] = array_shift($this->series); + Assert::assertEquals($expectedArgs, $args); + + return $return; + } +} diff --git a/tests/Unit/Cryptography/CryptographySubscriberTest.php b/tests/Unit/Cryptography/CryptographySubscriberTest.php index a8c6e03..01547ee 100644 --- a/tests/Unit/Cryptography/CryptographySubscriberTest.php +++ b/tests/Unit/Cryptography/CryptographySubscriberTest.php @@ -7,6 +7,7 @@ use Patchlevel\Hydrator\Cryptography\CryptographySubscriber; use Patchlevel\Hydrator\Cryptography\PayloadCryptographer; use Patchlevel\Hydrator\Event\PostExtract; +use Patchlevel\Hydrator\Event\PreExtract; use Patchlevel\Hydrator\Event\PreHydrate; use Patchlevel\Hydrator\Metadata\ClassMetadata; use PHPUnit\Framework\Attributes\CoversClass; @@ -21,6 +22,7 @@ public function testSubscriptions(): void { self::assertEquals([ PreHydrate::class => 'preHydrate', + PreExtract::class => 'preExtract', PostExtract::class => 'postExtract', ], CryptographySubscriber::getSubscribedEvents()); } diff --git a/tests/Unit/MetadataHydratorTest.php b/tests/Unit/MetadataHydratorTest.php index c5efaa9..1a8c0a2 100644 --- a/tests/Unit/MetadataHydratorTest.php +++ b/tests/Unit/MetadataHydratorTest.php @@ -12,6 +12,7 @@ use Patchlevel\Hydrator\Cryptography\PayloadCryptographer; use Patchlevel\Hydrator\DenormalizationFailure; use Patchlevel\Hydrator\Event\PostExtract; +use Patchlevel\Hydrator\Event\PreExtract; use Patchlevel\Hydrator\Event\PreHydrate; use Patchlevel\Hydrator\Guesser\Guesser; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; @@ -19,6 +20,7 @@ use Patchlevel\Hydrator\NormalizationFailure; use Patchlevel\Hydrator\NormalizationMissing; use Patchlevel\Hydrator\Normalizer\Normalizer; +use Patchlevel\Hydrator\Tests\ReturnCallback; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle1Dto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle2Dto; use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto; @@ -307,7 +309,7 @@ public function testEncrypt(): void self::assertSame($encryptedPayload, $return); } - public function testPreHydrate(): void + public function testHydrateEvents(): void { $object = new ProfileCreated( ProfileId::fromString('1'), @@ -343,7 +345,7 @@ public function testPreHydrate(): void self::assertEquals($object, $return); } - public function testPostExtract(): void + public function testExtractEvents(): void { $object = new ProfileCreated( ProfileId::fromString('1'), @@ -355,6 +357,11 @@ public function testPostExtract(): void $metadataFactory = new AttributeMetadataFactory(); + $preExtract = new PreExtract( + $object, + $metadataFactory->metadata(ProfileCreated::class), + ); + $event = new PostExtract( $payload, $metadataFactory->metadata(ProfileCreated::class), @@ -366,8 +373,20 @@ public function testPostExtract(): void ); $eventDispatcher = $this->createMock(EventDispatcherInterface::class); - $eventDispatcher->expects($this->once())->method('dispatch')->with($event) - ->willReturn($eventReturn); + $eventDispatcher->expects($this->exactly(2)) + ->method('dispatch') + ->willReturnCallback( + new ReturnCallback([ + [ + [$preExtract, null], + $preExtract, + ], + [ + [$event, null], + $eventReturn, + ], + ]), + ); $hydrator = new MetadataHydrator($metadataFactory, eventDispatcher: $eventDispatcher);