Skip to content

Not able to send ID in POST method #343

Closed
@akadlec

Description

@akadlec

I would like to send generated ID from UI to API but right now it is not possible. API is trying to do update, response is "Method is not allowed" So right now i'm sending it as uuid and then in the event doing extraction from request setting to entity.

It will be great to allow send ID param to POST and to have "optional" parameter for writing. What do you think?

Activity

Simperfit

Simperfit commented on Jun 22, 2017

@Simperfit
Contributor

What do you mean ?

If you do POST /something with

{
"id":"value"
}

And the setters exists (and the good groups if you use any), that should work.

akadlec

akadlec commented on Jun 22, 2017

@akadlec
Author

Yes this, but this does not work, the result is: Update is not allowed for this operation.

Simperfit

Simperfit commented on Jun 25, 2017

@Simperfit
Contributor

Could you please paste the entity you are using ?

akadlec

akadlec commented on Jul 7, 2017

@akadlec
Author
/**
 * @ApiResource(
 *     attributes={
 *         "normalization_context"={
 *             "groups"={
 *                 "dashboard-read"
 *             }
 *         },
 *         "denormalization_context"={
 *             "groups"={
 *                 "dashboard-write"
 *             }
 *         }
 *     },
 *     itemOperations={
 *         "get"={
 *             "method"="GET", "path"="/v1/dashboards/{id}"
 *         },
 *         "put"={
 *             "method"="PUT", "path"="/v1/dashboards/{id}"
 *         },
 *         "delete"={
 *             "method"="DELETE", "path"="/v1/dashboards/{id}"
 *         }
 *     },
 *     collectionOperations ={
 *         "get"={
 *             "method"="GET", "path"="/v1/dashboards"
 *         },
 *         "post"={
 *             "method"="POST", "path"="/v1/dashboards"
 *         }
 *     }
 * )
 * @ORM\Entity
 */
class Dashboard implements IDashboard
{
	/**
	 * @var Uuid\Uuid
	 *
	 * @SerializerGroups({"dashboard-read"})
	 * @ORM\Id
	 * @ORM\Column(type="uuid_binary", name="dashboard_id")
	 * @ORM\CustomIdGenerator(class="Ramsey\Uuid\Doctrine\UuidGenerator")
	 */
	private $id;

	/**
	 * @param string $id
	 *
	 * @return void
	 */
	public function setId(string $id)
	{
		$this->id = Uuid\Uuid::fromString($id);
	}

	/**
	 * @return Uuid\UuidInterface
	 */
	public function getId() : Uuid\UuidInterface
	{
		return $this->id;
	}
}

Problem is, when in POST is ID api-platform is thinking that i want to do an UPDATE action.

GonZOO82

GonZOO82 commented on Sep 12, 2017

@GonZOO82

Any idea?

akadlec

akadlec commented on Sep 12, 2017

@akadlec
Author

From me, no, i am leaving this bundle and going back to other solution

dunglas

dunglas commented on Sep 12, 2017

@dunglas
Member

It's weird to have a setter for an ID that is generated externally. I'm not sure to get what you try to achieve.

akadlec

akadlec commented on Sep 12, 2017

@akadlec
Author

@dunglas it is normal to send ID from app. I case you have app with optimistic ui and all ID's are in UUID format, you could generate ID in you app and send it to api endpoint and you don't have to wait for positive response.

dunglas

dunglas commented on Sep 12, 2017

@dunglas
Member

@akadlec I agree on that, but in this case you shouldn't have this line @ORM\CustomIdGenerator(class="Ramsey\Uuid\Doctrine\UuidGenerator").

akadlec

akadlec commented on Sep 12, 2017

@akadlec
Author

Right now i'm not 100% surre but i thing i did a test where i remove this row. Problem is not in doctrine mapping but in apiplatform. There is a method which check request and when an ID is present in request and request is in POST method, platform refuse it

yoshz

yoshz commented on Feb 8, 2018

@yoshz

This issue still exists for me and I only get the error message "Update is not allowed for this operation" if actually post an id that has the property name "id". For other entities that have an id on the property "code" for example I don't get this error message. So this is pretty weird.

Should this check not rely on that a IdGenerator is actually defined in Doctrine for that entity?

coudenysj

coudenysj commented on Mar 30, 2018

@coudenysj
Contributor

Any news on this? Or ideas how to prepare a merge request to add support for this?

arnedesmedt

arnedesmedt commented on Mar 30, 2018

@arnedesmedt

+1

Toflar

Toflar commented on May 1, 2018

@Toflar

This issue still exists. I'm trying to fix it but I'm pretty lost and it's hard to fix it without guidance as I'm not sure why things are the way they are at several places in the code.
First of all, this issue should be moved to core (not sure about the policy here though).
Then the issue occurs here: https://github.com/api-platform/core/blob/master/src/Serializer/ItemNormalizer.php#L35

OBJECT_TO_POPULATE is not set at this place yet, it happens only later. And api_allow_update is false because it's a POST request and this variable is only set to true if PATCH or PUT requests are sent (see https://github.com/api-platform/core/blob/master/src/Serializer/SerializerContextBuilder.php#L72).
So I'm not sure how to proceed here but it's perfectly valid to send a POST request with an id if you have no autogenerated strategy.

10 remaining items

nfacciolo

nfacciolo commented on Aug 28, 2019

@nfacciolo

Seriously there is no way to [POST] an object with a specified id named "id" !?

maks-rafalko

maks-rafalko commented on Aug 28, 2019

@maks-rafalko
Contributor

no, we had to implement it ourselves (see my comment)

nfacciolo

nfacciolo commented on Aug 28, 2019

@nfacciolo

I tried your solution but it leads to this error:
Argument 10 passed to ApiPlatform\Core\Serializer\AbstractItemNormalizer::__construct() must be of the type array, null given, called in /usr/src/api/var/cache/dev/ContainerVNe5Qdr/srcApp_KernelDevDebugContainer.php on line 823

btw thanks for the code

maks-rafalko

maks-rafalko commented on Aug 28, 2019

@maks-rafalko
Contributor

This is the full code of our class that allows positing custom id fields and works with 2.4.3 api-platform:

<?php

declare(strict_types=1);

namespace App\Serializer\Normalizer;

use ApiPlatform\Core\Api\IriConverterInterface;
use ApiPlatform\Core\Api\ResourceClassResolverInterface;
use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
use ApiPlatform\Core\Exception\InvalidArgumentException;
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Serializer\AbstractItemNormalizer;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;

/**
 * This class overrides api-platform's built in ItemNormalizer in order to make it possible to POST resources
 * with custom provided ID
 *
 * Related not merged PR and discussion: https://github.com/api-platform/core/pull/2022
 */
class ItemNormalizer extends AbstractItemNormalizer
{
    private const IDENTIFIER = 'id';

    /**
     * @var LoggerInterface
     */
    private $logger;

    public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false, LoggerInterface $logger = null, iterable $dataTransformers = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null)
    {
        parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $itemDataProvider, $allowPlainIdentifiers, [], $dataTransformers, $resourceMetadataFactory);

        $this->logger = $logger ?: new NullLogger();
    }

    /**
     * @param mixed $data
     * @param string $class
     * @param string $format
     * @param array $context
     *
     * @return object
     */
    public function denormalize($data, $class, $format = null, array $context = [])
    {
        $context['api_denormalize'] = true;

        if (!isset($context['resource_class'])) {
            $context['resource_class'] = $class;
        }

        $this->setObjectToPopulate($data, $context);

        return parent::denormalize($data, $class, $format, $context);
    }

    /**
     * @param string|object $classOrObject
     * @param array $context
     * @param bool $attributesAsString
     *
     * @return array|bool|string[]|\Symfony\Component\Serializer\Mapping\AttributeMetadataInterface[]
     */
    protected function getAllowedAttributes($classOrObject, array $context, $attributesAsString = false)
    {
        $allowedAttributes = parent::getAllowedAttributes(
            $classOrObject,
            $context,
            $attributesAsString
        );

        if (\array_key_exists('allowed_extra_attributes', $context)) {
            $allowedAttributes = array_merge($allowedAttributes, $context['allowed_extra_attributes']);
        }

        return $allowedAttributes;
    }

    /**
     * @param mixed $data
     * @param array $context
     */
    protected function setObjectToPopulate($data, array &$context): void
    {
        // in PUT request OBJECT_TO_POPULATE is already set by this moment
        if (!\is_array($data) || isset($context[self::OBJECT_TO_POPULATE])) {
            return;
        }

        [$identifierName, $identifierMetadata] = $this->getResourceIdentifierData($context);

        $isUpdateAllowed = (bool) ($context['api_allow_update'] ?? false);
        $hasIdentifierInRequest = \array_key_exists(self::IDENTIFIER, $data);
        $hasWritableIdentifierInRequest = $hasIdentifierInRequest && $identifierMetadata->isWritable();
        // when it is POST, update is not allowed for top level resource, but is allowed for nested resources
        $isTopLevelResourceInPostRequest = !$isUpdateAllowed
            && $context['operation_type'] === 'collection'
            && $context['collection_operation_name'] === 'post';

        // if Resource does not have an ID OR if it is writable custom id - we should not populate Entity from DB
        if (!$hasIdentifierInRequest || ($hasWritableIdentifierInRequest && $isTopLevelResourceInPostRequest)) {
            return;
        }

        if (!$isUpdateAllowed) {
            throw new InvalidArgumentException('Update is not allowed for this operation.');
        }

        try {
            $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getItemFromIri(
                (string) $data[self::IDENTIFIER],
                $context + ['fetch_data' => true]
            );
        } catch (InvalidArgumentException $e) {
            $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getItemFromIri(
                sprintf(
                    '%s/%s',
                    $this->iriConverter->getIriFromResourceClass($context['resource_class']),
                    $data[$identifierName]
                ),
                $context + ['fetch_data' => true]
            );
        }
    }

    private function getResourceIdentifierData(array $context): array
    {
        $identifierPropertyName = null;
        $identifierPropertyMetadata = null;
        $className = $context['resource_class'];

        $properties = $this->propertyNameCollectionFactory->create($className, $context);

        foreach ($properties as $propertyName) {
            $property = $this->propertyMetadataFactory->create($className, $propertyName);

            if ($property->isIdentifier()) {
                $identifierPropertyName = $propertyName;
                $identifierPropertyMetadata = $property;
                break;
            }
        }

        if ($identifierPropertyMetadata === null) {
            throw new \LogicException(
                sprintf(
                    'Resource "%s" must have an identifier. Properties: %s.',
                    $className,
                    implode(',', iterator_to_array($properties->getIterator()))
                )
            );
        }

        return [$identifierPropertyName, $identifierPropertyMetadata];
    }
}
nfacciolo

nfacciolo commented on Aug 29, 2019

@nfacciolo

With the full code on api-platform version 2.4.6, it works for simple entities. But if there are nested entities, it does not work.

Thanks for the code and the time.

quentinus95

quentinus95 commented on Dec 18, 2019

@quentinus95

I have found out that posting with the content-type header set to application/ld+json fixes the issue on my side.

moay

moay commented on May 5, 2020

@moay

This is the full code of our class that allows positing custom id fields and works with 2.4.3 api-platform:

Thanks. We added a slight modification to allow for nested id references, which we automatically convert to iris.

Here is our adapted version:
https://gist.github.com/moay/47ef07b67d701c2ef7355d0bbba8b4d6

Renrhaf

Renrhaf commented on Jun 10, 2020

@Renrhaf
Contributor

Same here with a custom POST action, using a custom input DTO object.
A key "id" is passed to identify some other object from the application.
With content type json/ld everything is fine as API platform is searching for a key "@id" but with simple json it fails with the error "Update is not allowed".

MGDSoft

MGDSoft commented on Jun 21, 2022

@MGDSoft

Thanks @maks-rafalko for the code it works perfectly in a simple entities
Its strange API platform can't do a simple insert by id 😞

Renrhaf

Renrhaf commented on Aug 1, 2023

@Renrhaf
Contributor

Simplest solution : creating two decorators for Json & JsonLd item normalizers.
It seems that API Platform ElasticSearch component is already decorating the JsonLD ItemNormalizer (ApiPlatform\Elasticsearch\Serializer\ItemNormalizer), with a higher priority (-895). So we need to keep that in mind and put some higher priority on ours.

Code snippet

Normalizer for JsonLD requests :

<?php

declare(strict_types=1);

namespace App\Serializer;

use ApiPlatform\Metadata\Post;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerInterface;

/**
 * Custom ItemNormalizer class.
 */
#[AsDecorator('api_platform.jsonld.normalizer.item', priority: -900)]
class ItemJsonLdNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface, CacheableSupportsMethodInterface
{

    public function __construct(
        #[AutowireDecorated] private readonly NormalizerInterface $decorated,
    ) {}

    public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
    {
        return $this->decorated->normalize($object, $format, $context);
    }

    public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool
    {
        return $this->decorated->supportsDenormalization($data, $type, $format, $context);
    }

    public function denormalize(mixed $data, string $type, string $format = null, array $context = []): mixed
    {
        // When denormalizing some API Platform resource,
        // we want to allow clients to send the resource identifier in POST operations.
        // By default, it's only allowed on PUT/PATCH operations.
        if ($context['operation'] instanceof Post) {
            $context['api_allow_update'] = true;
        }

        return $this->decorated->denormalize($data, $type, $format, $context);
    }

    public function hasCacheableSupportsMethod(): bool
    {
        return $this->decorated->hasCacheableSupportsMethod();
    }

    public function supportsNormalization(mixed $data, string $format = null)
    {
        return $this->decorated->supportsNormalization($data, $format);
    }

    public function setSerializer(SerializerInterface $serializer)
    {
        return $this->decorated->setSerializer($serializer);
    }

}

Normalizer for Json requests :

<?php

declare(strict_types=1);

namespace App\Serializer;

use ApiPlatform\Metadata\Post;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerInterface;

#[AsDecorator('api_platform.serializer.normalizer.item', priority: -900)]
class ItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface, CacheableSupportsMethodInterface
{

    public function __construct(
        #[AutowireDecorated] private readonly NormalizerInterface $decorated,
    ) {}

    public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
    {
        return $this->decorated->normalize($object, $format, $context);
    }

    public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool
    {
        return $this->decorated->supportsDenormalization($data, $type, $format, $context);
    }

    public function denormalize(mixed $data, string $type, string $format = null, array $context = []): mixed
    {
        // When denormalizing some API Platform resource,
        // we want to allow clients to send the resource identifier in POST operations.
        // By default, it's only allowed on PUT/PATCH operations.
        if ($context['operation'] instanceof Post) {
            $context['api_allow_update'] = true;
        }

        return $this->decorated->denormalize($data, $type, $format, $context);
    }

    public function hasCacheableSupportsMethod(): bool
    {
        return $this->decorated->hasCacheableSupportsMethod();
    }

    public function supportsNormalization(mixed $data, string $format = null)
    {
        return $this->decorated->supportsNormalization($data, $format);
    }

    public function setSerializer(SerializerInterface $serializer)
    {
        return $this->decorated->setSerializer($serializer);
    }

}

trusek

trusek commented on Apr 26, 2024

@trusek

Context modifications can be done directly.

#[ApiResource(
    operations: [
        new Post(
            normalizationContext: ['api_allow_update' => true],
            denormalizationContext: ['api_allow_update' => true],
        ),
    ],
)]
class ApiResource{}
galliroberto

galliroberto commented on Sep 26, 2024

@galliroberto

Context modifications can be done directly.

#[ApiResource(
    operations: [
        new Post(
            normalizationContext: ['api_allow_update' => true],
            denormalizationContext: ['api_allow_update' => true],
        ),
    ],
)]
class ApiResource{}

I get

{
  "title": "An error occurred",
  "detail": "Provider not found on operation \"_api_api/tenants/{id}{._format}_get\"",
  "status": 500,
  "type": "/errors/500",

But I don't want to use a provider

noahsmyers

noahsmyers commented on Nov 25, 2024

@noahsmyers

In my case, I could write a Symfony Event Listener that would just set the content type to application/ld+json if application/json is detected, as the LD+JSON does not have this issue when supplying the $id property in POST requests.

app/src/EventListener/JsonToLdJsonListener.php

<?php
declare(strict_types=1);
namespace App\EventListener;

use Symfony\Component\HttpKernel\Event\RequestEvent;

class JsonToLdJsonListener {
    public function onKernelRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();

        // Only handle requests with application/json content type
        if ($request->getContentTypeFormat() === 'json') {
            $request->headers->set('Content-Type', 'application/ld+json');
        }
    }
}

app/config/services.yaml

services:
    App\EventListener\JsonToLdJsonListener:
        tags:
            - { name: kernel.event_listener, event: kernel.request, priority: 10 }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

      Development

      No branches or pull requests

        Participants

        @dunglas@coudenysj@yoshz@Toflar@Renrhaf

        Issue actions

          Not able to send ID in POST method · Issue #343 · api-platform/api-platform