Allow to directly use IRIs for attachmennts without the need to rely on _type

This commit is contained in:
Jan Böhmer 2026-05-25 22:22:55 +02:00
parent 87874230ef
commit 0da5befd7b
2 changed files with 63 additions and 6 deletions

View file

@ -23,9 +23,13 @@ declare(strict_types=1);
namespace App\Serializer\APIPlatform; namespace App\Serializer\APIPlatform;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
use ApiPlatform\Metadata\IriConverterInterface;
use ApiPlatform\Serializer\ItemNormalizer; use ApiPlatform\Serializer\ItemNormalizer;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator; 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\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerAwareInterface; 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 * 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 * 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. * 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")] #[AsDecorator("api_platform.serializer.normalizer.item")]
class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface
@ -42,13 +50,38 @@ class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterf
public const DISABLE_ITEM_NORMALIZER = 'DISABLE_ITEM_NORMALIZER'; 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 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); return $this->inner->denormalize($data, $type, $format, $context);
} }
@ -87,4 +120,4 @@ class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterf
'object' => false 'object' => false
]; ];
} }
} }

View file

@ -69,4 +69,28 @@ final class PartEndpointTest extends CrudEndpointTestCase
{ {
$this->_testDeleteItem(1); $this->_testDeleteItem(1);
} }
}
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]]);
}
}