diff --git a/features/main/standard_put.feature b/features/main/standard_put.feature index 8a95f9f5930..670ab6d0d0b 100644 --- a/features/main/standard_put.feature +++ b/features/main/standard_put.feature @@ -26,6 +26,60 @@ Feature: Spec-compliant PUT support } """ + Scenario: Create a new resource with JSON-LD attributes + When I add "Content-Type" header equal to "application/ld+json" + And I send a "PUT" request to "/standard_puts/6" with body: + """ + { + "@id": "/standard_puts/6", + "@context": "/contexts/StandardPut", + "@type": "StandardPut", + "foo": "a", + "bar": "b" + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the JSON should be equal to: + """ + { + "@context": "/contexts/StandardPut", + "@id": "/standard_puts/6", + "@type": "StandardPut", + "id": 6, + "foo": "a", + "bar": "b" + } + """ + + Scenario: Fails to create a new resource with the wrong JSON-LD @id + When I add "Content-Type" header equal to "application/ld+json" + And I send a "PUT" request to "/standard_puts/7" with body: + """ + { + "@id": "/dummies/6", + "@context": "/contexts/StandardPut", + "@type": "StandardPut", + "foo": "a", + "bar": "b" + } + """ + Then the response status code should be 400 + + Scenario: Fails to create a new resource when the JSON-LD @id doesn't match the URI + When I add "Content-Type" header equal to "application/ld+json" + And I send a "PUT" request to "/standard_puts/7" with body: + """ + { + "@id": "/standard_puts/6", + "@context": "/contexts/StandardPut", + "@type": "StandardPut", + "foo": "a", + "bar": "b" + } + """ + Then the response status code should be 400 + Scenario: Replace an existing resource When I add "Content-Type" header equal to "application/ld+json" And I send a "PUT" request to "/standard_puts/5" with body: diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 25c6d90c028..3c2ed5696ef 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -17,10 +17,12 @@ use ApiPlatform\Api\ResourceClassResolverInterface as LegacyResourceClassResolverInterface; use ApiPlatform\JsonLd\AnonymousContextBuilderInterface; use ApiPlatform\JsonLd\ContextBuilderInterface; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; @@ -47,6 +49,29 @@ final class ItemNormalizer extends AbstractItemNormalizer use JsonLdContextTrait; public const FORMAT = 'jsonld'; + private const JSONLD_KEYWORDS = [ + '@context', + '@direction', + '@graph', + '@id', + '@import', + '@included', + '@index', + '@json', + '@language', + '@list', + '@nest', + '@none', + '@prefix', + '@propagate', + '@protected', + '@reverse', + '@set', + '@type', + '@value', + '@version', + '@vocab', + ]; public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface|LegacyIriConverterInterface $iriConverter, ResourceClassResolverInterface|LegacyResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null) { @@ -148,9 +173,26 @@ public function denormalize(mixed $data, string $class, ?string $format = null, throw new NotNormalizableValueException('Update is not allowed for this operation.'); } - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($data['@id'], $context + ['fetch_data' => true]); + try { + $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($data['@id'], $context + ['fetch_data' => true], $context['operation'] ?? null); + } catch (ItemNotFoundException $e) { + $operation = $context['operation'] ?? null; + if (!($operation instanceof Put && ($operation->getExtraProperties()['standard_put'] ?? false))) { + throw $e; + } + } } return parent::denormalize($data, $class, $format, $context); } + + protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool + { + $allowedAttributes = parent::getAllowedAttributes($classOrObject, $context, $attributesAsString); + if (\is_array($allowedAttributes) && ($context['api_denormalize'] ?? false)) { + $allowedAttributes = array_merge($allowedAttributes, self::JSONLD_KEYWORDS); + } + + return $allowedAttributes; + } } diff --git a/src/Symfony/Routing/IriConverter.php b/src/Symfony/Routing/IriConverter.php index 9de95f35f87..2f727e508e6 100644 --- a/src/Symfony/Routing/IriConverter.php +++ b/src/Symfony/Routing/IriConverter.php @@ -78,6 +78,16 @@ public function getResourceFromIri(string $iri, array $context = [], ?Operation throw new InvalidArgumentException(sprintf('No resource associated to "%s".', $iri)); } + foreach ($context['uri_variables'] ?? [] as $key => $value) { + if (!isset($parameters[$key]) || $parameters[$key] !== (string) $value) { + throw new InvalidArgumentException(sprintf('The iri "%s" does not reference the correct resource.', $iri)); + } + } + + if ($operation && !is_a($parameters['_api_resource_class'], $operation->getClass(), true)) { + throw new InvalidArgumentException(sprintf('The iri "%s" does not reference the correct resource.', $iri)); + } + $operation = $parameters['_api_operation'] = $this->resourceMetadataCollectionFactory->create($parameters['_api_resource_class'])->getOperation($parameters['_api_operation_name']); if ($operation instanceof CollectionOperationInterface) {