Skip to content

Fix allow creating resources with custom identifers #2022

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 12 commits into from
Closed

Fix allow creating resources with custom identifers #2022

wants to merge 12 commits into from

Conversation

bendavies
Copy link
Contributor

@bendavies bendavies commented Jun 13, 2018

Q A
Bug fix? yes
New feature? no
BC breaks? no
Deprecations? no
Tests pass? yes
Fixed tickets #1937, #2018, #1741, api-platform/api-platform#343
License MIT

This is a re-submission of #1939 with fixes and passing tests. @Toflar's commits are retained.

@bendavies bendavies changed the title Fix allow creating resources with custom idenfifers Fix allow creating resources with custom identifers Jun 13, 2018
throw new InvalidArgumentException('Update is not allowed for this operation.');
}

// TODO: use $this->iriConverter->getIriFromPlainIdentifier() once https://github.com/api-platform/core/pull/1837 is merged.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The #1837 PR is based on master.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, but the todo is still valid, it can be updated in master.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was a note for myself :D

),
$context + ['fetch_data' => true]
);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we're using twice the same logic here, can't we avoid to repeat these?

Copy link
Contributor Author

@bendavies bendavies Jun 14, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i noticed yes... it would be nice,

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@soyuka if you have some ideas, please say. I can't see a very nice way atm.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you shed some light on why this is needed? Looks like a fallback algorithm that should go into the iriconverter?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a comment a few lines up. it is a fix for #857

Not sure I agree with the fix, but I have kept it as per the original as a behat feature tests it.

@Toflar
Copy link
Contributor

Toflar commented Jun 14, 2018

Nice one! Want me to close the other PR then, right?

@bendavies
Copy link
Contributor Author

@Toflar I'm happy to close this and you can cherry pick into the original PR if you like. this PR is rebased on 2.2 btw.

@Toflar
Copy link
Contributor

Toflar commented Jun 14, 2018

Nah, I'm perfectly fine with you taking over! Thanks for the work!

@bendavies
Copy link
Contributor Author

@dunglas @teohhanhui @meyerbaptiste can i ask for a review please?

@soyuka
Copy link
Member

soyuka commented Jun 17, 2018

features/main/custom_normalized.feature:52 failed though

@bendavies
Copy link
Contributor Author

bendavies commented Jun 17, 2018 via email

@bendavies
Copy link
Contributor Author

think I've fixed this by making the feature correct, to correctly address #857.

if (isset($context['api_allow_update']) && true !== $context['api_allow_update']) {
throw new InvalidArgumentException('Update is not allowed for this operation.');
try {
parent::setObjectToPopulate($data, $class, $context);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a return; here and move the code in the catch part outside?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you mean an empty catch right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

// https://github.com/api-platform/core/issues/857
$identifiers = $this->identifiersExtractor->getIdentifiersFromResourceClass($class);
$identifiersData = \array_intersect_key($data, array_flip($identifiers));
if (0 === \count($identifiersData)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (!$identifiersData) {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a style adopted by API-P? i prefer to be explicit but happy to align with project style.

$identifiers = $this->getIdentifiersForDenormalization($class);
$identifiersData = \array_intersect_key($data, array_flip($identifiers));

if (0 === \count($identifiersData)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (!$identifiersData)) {

protected function getIdentifiersForDenormalization(string $class): array
{
// Backwards compatibility
if (null === $this->identifiersExtractor) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (!$this->identifiersExtractor) {

Copy link
Contributor Author

@bendavies bendavies Jun 19, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, happy to follow the style of the project, but if (null === $variable) is done quite a lot in this project, so i'm not sure there's any point unless consistency is introduced.

}

if (!$updateAllowed) {
throw new InvalidArgumentException('Update is not allowed for this operation.');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should not this exception be thrown before the previous condition (the return;)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't think so, we only throw if identifiers are provided.

When I add "Accept" header equal to "application/json"
And I send a "PUT" request to "/related_normalized_dummies/1" with body:
"""
{
"name": "My Dummy",
"customNormalizedDummy":[{
"@context": "/contexts/CustomNormalizedDummy",
"@id": "/custom_normalized_dummies/1",
"@id": "app_dev.php/custom_normalized_dummies/1",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why app_dev.php?

Copy link
Contributor Author

@bendavies bendavies Jun 19, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nothing specific about app_dev.php. this is a scenario for #857 (#877) and i needed a way to force the custom behavior in the Json LD ItemNormalizer. i.e the iri must be incorrect.

@bendavies
Copy link
Contributor Author

For completeness, as I said to @dunglas privately, I won't have a chance to work on this proposal for quite a while. So if anyone else has some time, please feel free :)

@teohhanhui
Copy link
Contributor

teohhanhui commented Jun 22, 2018

I'm -1 for using @id field in a non JSON-LD context. It only creates confusion and adds no value.

@dunglas
Copy link
Member

dunglas commented Jun 22, 2018

I'm -1 for using @id field in a non JSON-LD context. It only creates confusion and adds no value.

@id has several benefits:

  • @ is a reserved char in PHP, we're sure it cannot conflict with a property defined by the user
  • it's an existing convention (JSON-LD), we don't reinvent something entirely proprietary

Anyway we need a special key for the IRI. We may also use the GraphQL convention (id for the IRI, _id for the internal raw id) but it has several drawbacks:

  • it will be harder to port legacy API to API Platform: most legacy APIs usually use id for the raw id, it's not possible with this convention
  • _ isn't a special char in PHP

So to me @id is the best bet. It has a last advantage: it allows to drop the ItemNormalizer for the JSON-LD one (less code, less maintenance, only 1 implementation = less edge cases).

@teohhanhui
Copy link
Contributor

My stand is consistent with what I've said about the Problem RFC. We don't get any benefit by mimicking a standard. It only leads to confusion as to whether we're actually following the standard or not (we aren't, so it shouldn't look like we are).

@maks-rafalko
Copy link
Contributor

maks-rafalko commented Sep 3, 2018

Hello,

what is the status of this particular PR and the issue itself?

From time to time people keep creating bugs in different repositories about this problem, and the freshest PR is this one which is also not active anymore.

Questions:

  • Have you agreed with the approach internally @dunglas?
  • Do you need any help and can you guide someone to resolve this issue?
  • Do you plan to move forward with this approach or start from the scratch?

This bug is quite old and very annoying, so I would like to point your attention to it and try to move it forward.

Thank you.

@maks-rafalko
Copy link
Contributor

maks-rafalko commented Sep 6, 2018

Well, a couple of days with debugger and it works now.

  • The code below is for those who find a solution right meow before anything is done officially in the repository (which I suspect may take months)
  • Use it on your own risk, but it works for me and all tests are green

What we did is just replaced ItemNormalizer with our own implementation, that allows to post the following JSON:

POST /resources
{
    "id": 1234545,
    "anyOther": "fields"
}
Custom ItemNormalizer (click to show)
declare(strict_types=1);

namespace App\Serializer\Normalizer;

use ApiPlatform\Core\Exception\InvalidArgumentException;
use ApiPlatform\Core\Serializer\AbstractItemNormalizer;


class ItemNormalizer extends AbstractItemNormalizer
{
    private const IDENTIFIER = 'id';

    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);
    }

    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 = $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];
    }
}

To replace api-platform's analyzer with this custom one - just override class in CompilerPass

src/Kernel.php

+ use App\Serializer\Normalizer\ItemNormalizer;

- class Kernel extends BaseKernel
+ class Kernel extends BaseKernel implements CompilerPassInterface

+    public function process(ContainerBuilder $container)
+    {
+        $container->getDefinition('api_platform.serializer.normalizer.item')->setClass(ItemNormalizer::class);
+    }
 }

Still, I want to move forward this discussion. This is one of the feature that I expect to work out of the box from platform named api-platform.

And by the way, provided patch in this PR does not work, I literally copied code to vendor folder and try to debug it.


UPD: the full code for api-platform 2.4.3+ is here api-platform/api-platform#343 (comment)

@dunglas
Copy link
Member

dunglas commented Sep 11, 2018

Can you rebase against 2.3 please?

@bendavies
Copy link
Contributor Author

bendavies commented Sep 11, 2018

@dunglas why? You have proposed a better solution above. This pr should not be merged, I thought we decided.

@dunglas
Copy link
Member

dunglas commented Sep 11, 2018

My bad, I read this PR too fast.

@soyuka
Copy link
Member

soyuka commented Sep 12, 2018

Let's close this then.

@bendavies
Copy link
Contributor Author

let's try not forget about your proposal @dunglas :)

@maks-rafalko
Copy link
Contributor

I suggest opening another issue with that proposal, because it will be forgotten 100% ;)

@soyuka
Copy link
Member

soyuka commented Sep 12, 2018

lol was doing too many things at once and forgot to submit the issue, done :)

@clemherreman
Copy link
Contributor

Hello everyone,
Are there any news on this? I noticed #2201, and I wanted to know if there is any ETA on this. Like @borNfreee I'm willing to help on that, but I guess I'd need some guidance on how :)

My use case is that I have an application, consuming my API, that needs to work offline, and still create new resources instances locally, while offline. A good solution would be to have my resources uses UUID as identifiers, and allow to specify the identifiers in POST calls. This way the application and the API can create new instances independently.

As this issues is closed, I'll post it on ##2201 too, because I'm not sure somebody is reading here :)

@nfacciolo
Copy link

Well, a couple of days with debugger and it works now.

* The code below is for those who find a solution right meow before anything is done officially in the repository (which I suspect may take months)

* Use it on your own risk, but it works for me and all tests are green

What we did is just replaced ItemNormalizer with our own implementation, that allows to post the following JSON:

POST /resources
{
    "id": 1234545,
    "anyOther": "fields"
}

Custom ItemNormalizer (click to show)

To replace api-platform's analyzer with this custom one - just override class in CompilerPass

src/Kernel.php

+ use App\Serializer\Normalizer\ItemNormalizer;

- class Kernel extends BaseKernel
+ class Kernel extends BaseKernel implements CompilerPassInterface

+    public function process(ContainerBuilder $container)
+    {
+        $container->getDefinition('api_platform.serializer.normalizer.item')->setClass(ItemNormalizer::class);
+    }
 }

Still, I want to move forward this discussion. This is one of the feature that I expect to work out of the box from platform named api-platform.

And by the way, provided patch in this PR does not work, I literally copied code to vendor folder and try to debug it.

UPD: the full code for api-platform 2.4.3+ is here api-platform/api-platform#343 (comment)

See api-platform/api-platform#343 (comment) if you want make it work

@loicleng
Copy link

Hello everyone,

Here is an example I made for ApiPlatform >= 3.0 that converts plain ids sent for relation field (either for item or collection relation) to IRI's before denormalization.
It may be improved but seems to be working for me so I'll still post it here in case it may help someone as I personnally struggled to find custom normalizer examples.

<?php

namespace App\Serializer;

use ApiPlatform\Api\IriConverterInterface;
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\PropertyInfo\Type;

// Enable to send plain identifiers as object relation or collection
final class CustomItemNormalizer implements NormalizerInterface, DenormalizerInterface
{

    public function __construct(
        private readonly NormalizerInterface $normalizer,
        private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory,
        private readonly IriConverterInterface $iriConverter
    )
    {
        if (!$normalizer instanceof DenormalizerInterface) {
            throw new \InvalidArgumentException('The normalizer must implement the DenormalizerInterface');
        }
    }

    public function denormalize($data, $class, $format = null, array $context = [])
    {
        if ($class == $context["operation"]->getClass()) {
            foreach ($data as $key => $value) {
                $propertyMetadata = $this->propertyMetadataFactory->create($class, $key);
                $propertyTypeObject = $propertyMetadata->getBuiltinTypes()[0];
                $propertyType = $propertyTypeObject->getBuiltinType();

                // If property is a relation and is not a translation relation
                if ($propertyType === 'object' && $key !== 'translations') {
                    $data = $this->convertIdRelationsToIRIs($data, $key, $value, $propertyTypeObject);
                }
            }
        }

        return $this->normalizer->denormalize($data, $class, $format, $context);
    }

    private function convertIdRelationsToIRIs(array $data, string $key, mixed $value, Type $propertyTypeObject): array {

        // For collection relation: Will replace data embbed relation sent as "categories": [1, 2, 3] to "categories": ['/catalog/categories/1', '/catalog/categories/2', ...]
        if ($propertyTypeObject->isCollection() && !str_contains('Translation', $propertyTypeObject->getCollectionValueTypes()[0]->getClassName())) {
            $data = $this->handleCollectionIdsConversion($data, $key, $value, $propertyTypeObject);

        // For single relation : Converts "category": 1 to "category": "/catalog/categories/1"
        } else {
            $data = $this->handleItemIdConversion($data, $key, $value, $propertyTypeObject);
        }

        return $data;
    }

    private function handleCollectionIdsConversion(array $data, string $key, mixed $value, Type $propertyTypeObject): array
    {
        $embbedClass = new ($propertyTypeObject->getCollectionValueTypes()[0]->getClassName());
        $manyToManyIRIsList = [];
        foreach ($value as $manyToManyEmbbedObjectId) {
            if (!is_int($manyToManyEmbbedObjectId)) {
                throw new \InvalidArgumentException('Parameters for relation ' . $embbedClass::class . ' must be integers' );
            }
            $iri = $this->iriConverter->getIriFromResource(resource: $embbedClass::class, context: ['uri_variables' => ['id' => $manyToManyEmbbedObjectId]]);
            $manyToManyIRIsList[] = $iri;
        }
        if (count($manyToManyIRIsList) > 0) {
            $data[$key] = $manyToManyIRIsList;
        }

        return $data;
    }

    private function handleItemIdConversion(array $data, string $key, mixed $value, Type $propertyTypeObject): array
    {
        $embbedClass = new ($propertyTypeObject->getClassName());

        if (!is_int($value)) {
            throw new \InvalidArgumentException('Parameter for relation ' . $embbedClass::class . ' must be integer' );
        }

        $iri = $this->iriConverter->getIriFromResource(resource: $embbedClass::class, context: ['uri_variables' => ['id' => $value]]);
        $data[$key] = $iri;

        return $data;
    }

    public function supportsDenormalization($data, $type, $format = null)
    {
        return $this->normalizer->supportsDenormalization($data, $type, $format);
    }

    public function normalize($object, $format = null, array $context = [])
    {
        return $this->normalizer->normalize($object, $format, $context);
    }

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

Which is registered such as follow in services.yaml :

services:

    'App\Serializer\CustomItemNormalizer':
        arguments: [ '@api_platform.serializer.normalizer.item' ]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.