From 0da5befd7b0d8fc91bac9e958579ff7a3c9cf98f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 25 May 2026 22:22:55 +0200 Subject: [PATCH] Allow to directly use IRIs for attachmennts without the need to rely on _type --- .../APIPlatform/SkippableItemNormalizer.php | 43 ++++++++++++++++--- tests/API/Endpoints/PartEndpointTest.php | 26 ++++++++++- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/Serializer/APIPlatform/SkippableItemNormalizer.php b/src/Serializer/APIPlatform/SkippableItemNormalizer.php index 5568c4cb..787121e6 100644 --- a/src/Serializer/APIPlatform/SkippableItemNormalizer.php +++ b/src/Serializer/APIPlatform/SkippableItemNormalizer.php @@ -23,9 +23,13 @@ declare(strict_types=1); namespace App\Serializer\APIPlatform; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Serializer\ItemNormalizer; use Symfony\Component\DependencyInjection\Attribute\AsDecorator; -use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerAwareInterface; @@ -35,6 +39,10 @@ use Symfony\Component\Serializer\SerializerInterface; * This class decorates API Platform's ItemNormalizer to allow skipping the normalization process by setting the * DISABLE_ITEM_NORMALIZER context key to true. This is useful for all kind of serialization operations, where the API * Platform subsystem should not be used. + * + * It also works around a bug in API Platform's AbstractItemNormalizer where IRI strings for abstract resource classes + * with a discriminator map fail deserialization when objectToPopulate is null (the discriminator is checked before + * the IRI string check). See: https://github.com/Part-DB/Part-DB-server/issues/1370 */ #[AsDecorator("api_platform.serializer.normalizer.item")] class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface @@ -42,13 +50,38 @@ class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterf public const DISABLE_ITEM_NORMALIZER = 'DISABLE_ITEM_NORMALIZER'; - public function __construct(private readonly ItemNormalizer $inner) - { - + public function __construct( + private readonly ItemNormalizer $inner, + private readonly IriConverterInterface $iriConverter, + ) { } public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { + // API Platform's AbstractItemNormalizer has a bug: when objectToPopulate is null and data is an IRI + // string, it tries to resolve the discriminator class from [$iri_string] before reaching the IRI + // check (line 271). For abstract resource classes with a discriminator map (e.g. Attachment), this + // fails because the array has no _type key. Fix by resolving IRI strings directly. + // See: https://github.com/Part-DB/Part-DB-server/issues/1370 + if (is_string($data)) { + try { + return $this->iriConverter->getResourceFromIri($data, $context + ['fetch_data' => true]); + } catch (ItemNotFoundException $e) { + if (false === ($context['denormalize_throw_on_relation_not_found'] ?? true)) { + return null; + } + if (!isset($context['not_normalizable_value_exceptions'])) { + throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e); + } + throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [$type], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); + } catch (InvalidArgumentException $e) { + if (!isset($context['not_normalizable_value_exceptions'])) { + throw new UnexpectedValueException(sprintf('Invalid IRI "%s".', $data), $e->getCode(), $e); + } + throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Invalid IRI "%s".', $data), $data, [$type], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); + } + } + return $this->inner->denormalize($data, $type, $format, $context); } @@ -87,4 +120,4 @@ class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterf 'object' => false ]; } -} \ No newline at end of file +} diff --git a/tests/API/Endpoints/PartEndpointTest.php b/tests/API/Endpoints/PartEndpointTest.php index 8d66d362..ef697321 100644 --- a/tests/API/Endpoints/PartEndpointTest.php +++ b/tests/API/Endpoints/PartEndpointTest.php @@ -69,4 +69,28 @@ final class PartEndpointTest extends CrudEndpointTestCase { $this->_testDeleteItem(1); } -} \ No newline at end of file + + public function testAttachmentPatchWithIRI(): void + { + $client = static::createAuthenticatedClient(); + + // Create a new attachment with a picture URL for Part 1 + $response = $client->request('POST', '/api/attachments', ['json' => [ + 'name' => 'Test Picture', + 'url' => 'http://example.com/test.jpg', + '_type' => 'Part', + 'element' => '/api/parts/1', + 'attachment_type' => '/api/attachment_types/1', + ]]); + self::assertResponseIsSuccessful(); + $attachmentIri = $response->toArray()['@id']; + + // Now PATCH Part 1 to set master_picture_attachment + $client->request('PATCH', '/api/parts/1', [ + 'json' => ['master_picture_attachment' => $attachmentIri], + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + ]); + self::assertResponseIsSuccessful(); + self::assertJsonContains(['master_picture_attachment' => ['@id' => $attachmentIri]]); + } +}