diff --git a/src/Shared/Infrastructure/ApiPlatform/Serializer/ContractRelationDenormalizer.php b/src/Shared/Infrastructure/ApiPlatform/Serializer/ContractRelationDenormalizer.php new file mode 100644 index 0000000..d173c67 --- /dev/null +++ b/src/Shared/Infrastructure/ApiPlatform/Serializer/ContractRelationDenormalizer.php @@ -0,0 +1,96 @@ + resolved through the IriConverter + * - an array value is an embedded object -> denormalized into the concrete entity + */ +final class ContractRelationDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface +{ + use DenormalizerAwareTrait; + + private const CONTRACT_NAMESPACE = 'App\Shared\Domain\Contract\\'; + + /** @var array */ + private array $resolved = []; + + public function __construct( + private readonly IriConverterInterface $iriConverter, + private readonly EntityManagerInterface $entityManager, + ) {} + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return null !== $this->concreteClassFor($type); + } + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ?object + { + $concrete = $this->concreteClassFor($type); + if (null === $concrete) { + return null; + } + + if (is_string($data)) { + return $this->iriConverter->getResourceFromIri($data, $context); + } + + // Embedded object payload: denormalize into the resolved concrete entity. + return $this->denormalizer->denormalize($data, $concrete, $format, $context); + } + + public function getSupportedTypes(?string $format): array + { + // Support depends on the runtime-resolved Doctrine mapping, so it cannot be + // statically cached by the serializer. + return ['object' => false]; + } + + /** + * @return ?class-string the concrete entity a contract interface resolves to, or null + */ + private function concreteClassFor(string $type): ?string + { + if (array_key_exists($type, $this->resolved)) { + return $this->resolved[$type]; + } + + if (!str_starts_with($type, self::CONTRACT_NAMESPACE) || !interface_exists($type)) { + return $this->resolved[$type] = null; + } + + try { + $name = $this->entityManager->getClassMetadata($type)->getName(); + } catch (Throwable) { + // Not a Doctrine-mapped (resolve_target_entities) interface. + return $this->resolved[$type] = null; + } + + return $this->resolved[$type] = ($name !== $type ? $name : null); + } +} diff --git a/tests/Functional/Module/Shared/InterfaceCollectionDenormalizationTest.php b/tests/Functional/Module/Shared/InterfaceCollectionDenormalizationTest.php new file mode 100644 index 0000000..6437c98 --- /dev/null +++ b/tests/Functional/Module/Shared/InterfaceCollectionDenormalizationTest.php @@ -0,0 +1,119 @@ +get(EntityManagerInterface::class)->getConnection(); + $conn->executeStatement("DELETE FROM time_entry_task_type WHERE time_entry_id IN (SELECT id FROM time_entry WHERE title = 'iface-denorm-te')"); + $conn->executeStatement("DELETE FROM time_entry WHERE title = 'iface-denorm-te'"); + $conn->executeStatement("DELETE FROM task_collaborator WHERE task_id IN (SELECT id FROM task WHERE title = 'iface-denorm-task')"); + $conn->executeStatement("DELETE FROM task WHERE title = 'iface-denorm-task'"); + parent::tearDown(); + } + + public function testPostTimeEntryWithInterfaceTypedTags(): void + { + $client = self::createClient(); + $em = self::getContainer()->get(EntityManagerInterface::class); + $this->loginAdmin($client); + + $userId = $this->adminId($em); + $tagId = $this->aTaskTagId($em); + + $client->request('POST', '/api/time_entries', server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + ], content: json_encode([ + 'title' => 'iface-denorm-te', + 'startedAt' => '2026-06-22T10:00:00+02:00', + 'stoppedAt' => '2026-06-22T11:00:00+02:00', + 'user' => '/api/users/'.$userId, + 'tags' => ['/api/task_tags/'.$tagId], + ])); + + self::assertResponseStatusCodeSame(201, $client->getResponse()->getContent() ?: ''); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertCount(1, $data['tags'] ?? []); + } + + public function testPostTaskWithInterfaceTypedCollaborators(): void + { + $client = self::createClient(); + $em = self::getContainer()->get(EntityManagerInterface::class); + $this->loginAdmin($client); + + $userId = $this->adminId($em); + $projectId = $this->aProjectId($em); + + $client->request('POST', '/api/tasks', server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + ], content: json_encode([ + 'title' => 'iface-denorm-task', + 'project' => '/api/projects/'.$projectId, + 'collaborators' => ['/api/users/'.$userId], + ])); + + self::assertResponseStatusCodeSame(201, $client->getResponse()->getContent() ?: ''); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertCount(1, $data['collaborators'] ?? []); + } + + private function loginAdmin(KernelBrowser $client): void + { + $em = self::getContainer()->get(EntityManagerInterface::class); + $user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + self::assertInstanceOf(User::class, $user); + $client->loginUser($user); + } + + private function adminId(EntityManagerInterface $em): int + { + $user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + self::assertInstanceOf(User::class, $user); + + return $user->getId(); + } + + private function aTaskTagId(EntityManagerInterface $em): int + { + $tag = $em->getRepository(TaskTag::class)->findOneBy([]); + if (null === $tag) { + $tag = new TaskTag(); + $tag->setLabel('iface-denorm-tag'); + $em->persist($tag); + $em->flush(); + } + + return $tag->getId(); + } + + private function aProjectId(EntityManagerInterface $em): int + { + $project = $em->getRepository(Project::class)->findOneBy([]); + self::assertInstanceOf(Project::class, $project); + + return $project->getId(); + } +}